once

A Clojure library and CLI tool that automates provisioning and configuration of cloud infrastructure using OpenTofu and Ansible. It targets vibe coders who want one-click deployment via Basecamp's ONCE.

Clojure 1.12.4 Babashka OpenTofu Ansible JVM / Linux / macOS MIT

Introduction

once wraps OpenTofu and Ansible into a single, six-stage pipeline that takes you from nothing to a running container on a public domain with verified SMTP and DNS — in one command. It is built on top of big-config, which provides workflow orchestration, template rendering, and step execution primitives.

The deployment target is Basecamp's ONCE: you supply a domain, a list of containerized apps (image + host + env), and credentials; once provisions a server, configures DNS, sets up email, installs ONCE, and reconciles the apps onto the host.

What you give it

  • A domain you control (Cloudflare-managed).
  • A list of applications (:host, :image, optional :env).
  • Credentials for the chosen cloud, SMTP, DNS, and state backend.
  • An ssh-agent with :compute-pubkey loaded — Ansible uses it to reach the freshly provisioned host (cloud compute profiles only).

What you get

  • A provisioned VM (DigitalOcean, Hetzner Cloud, OCI, or pre-existing).
  • Cloudflare DNS with apex + wildcard A records, hardened TLS settings, plus the SPF/DKIM/MX records required by Resend.
  • Resend domain verified end-to-end.
  • A remote host with Docker, ONCE, Babashka, s-nail, a restricted deploy user, and your applications running.
  • An entry in ~/.ssh/config so you can ssh once immediately.

Quick Start

1. Clone and configure

git clone https://github.com/amiorin/once
cd once
# pick / customize an active profile
$EDITOR src/clj/io/github/amiorin/once/options.clj

Switch the active profile by changing the last (def bb …) line:

;; options.clj
(def bb website)  ;; or: online, space, no-infra

2. Provide credentials

Put secrets in .envrc.private (gitignored) — direnv sources it automatically through .envrc.

# .envrc.private
export BC_PAR_HCLOUD_TOKEN="…"
export BC_PAR_CLOUDFLARE_API_TOKEN="…"
export BC_PAR_RESEND_API_KEY="…"
export BC_PAR_RESEND_PASSWORD="…"
export BC_PAR_R2_ACCESS_KEY_ID="…"
export BC_PAR_R2_SECRET_ACCESS_KEY="…"

3. Pre-flight

bb validate

Verifies schema, required CLIs, credentials (live API calls including a Cloudflare zone lookup), that every :image resolves on its registry, and that :compute-pubkey is loaded in ssh-agent for cloud compute profiles.

4. Provision

bb once create

Runs all six stages. On success, your apps are reachable at the hosts in :once :applications.

5. Describe

bb describe

Verify what's actually running on the new host: configured providers, SSH reachability, and every ONCE application with its image, tag, running digest, and an update-available? flag derived from comparing against the live registry.

6. Tear down

export BC_PAR_COMPUTE_PREVENT_DESTROY=false
bb once delete
WarnCompute resources render with lifecycle { prevent_destroy = true } by default. bb once delete will fail until you flip the flag — that is the intended safety net.

Prerequisites

The exact list of CLIs depends on your active profile. bb validate tells you which ones are missing.

ToolRequired forInstall hint
tofuall profilesopentofu.org
ansible-playbookall profilespipx install ansible
sshall profilesdistro openssh-client
curlall profilesdistro curl
skopeoimage checkscontainers/skopeo
ocioci computepip install oci-cli
hcloudhcloud computehetznercloud/cli
doctldigitalocean computeDO docs
awss3 or r2 backendAWS CLI v2
clojure + bbbuilding & runningclojure.org · babashka.org

Dev environment (Nix + devenv + direnv)

The repo ships with a reproducible devenv.nix that pulls every CLI it needs:

{ pkgs, ... }: {
  languages.clojure.enable = true;
  languages.ansible.enable = true;
  languages.opentofu.enable = true;
  packages = [
    pkgs.babashka pkgs.jet pkgs.hcl2json
    pkgs.awscli2 pkgs.skopeo pkgs.hcloud pkgs.doctl
  ];
}

With devenv and direnv installed, the .envrc at the repo root activates the shell automatically:

export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"
use devenv 2>/dev/null
source_env_if_exists .envrc.private

Drop your secrets into .envrc.private. It is gitignored via the whitelist .gitignore.

Architecture overview

once is intentionally thin: a handful of namespaces, each pinned to one responsibility, layered on top of big-config's workflow primitives.

NamespaceResponsibility
optionsProfile maps and the (def bb …) selector.
packageThe create / delete workflow definitions.
paramsReading Tofu outputs back into the workflow's ::workflow/params map.
toolsThe Tofu and Ansible tool wrappers, plus the backend-render plugin step.
validationMalli schema, tool / credential / image / ssh-agent pre-flight checks.
describePost-provisioning report: providers, SSH reachability, and remote ONCE applications (with running vs. registry digest comparison).

Templates live alongside the code in src/resources/io/github/amiorin/once/tools/ and are rendered into .dist/ at runtime.

The six-stage create pipeline

Defined in package.clj. Each stage is a big-config step that renders templates into .dist/<stage>/ and then runs the embedded tool against them.

1
tofu
compute VM
2
tofu-smtp
Resend domain
3
tofu-dns
Cloudflare zone
4
tofu-smtp-post
verify domain
5
ansible-local
~/.ssh/config
6
ansible
remote host

1 · tofu — compute

Provisions a VM on the active provider-compute: digitalocean, hcloud, oci, or no-infra (skip — use existing). Outputs the public IP under the params output, which downstream stages consume.

2 · tofu-smtp — Resend domain

Creates the domain inside Resend and emits a list of DNS records (SPF, DKIM, MX, return-path) under the params output.

3 · tofu-dns — Cloudflare zone

Provider cloudflare ~> 5.0. Creates apex (@) and wildcard (*) A records pointing at the VM IP (proxied through Cloudflare) and an SPF/DKIM/MX bundle generated from tofu-smtp's output. Applies a curated zone-settings bundle: TLS 1.3, strict SSL, always-use-HTTPS, brotli, and friends.

The Resend records are produced by tools/render-fn :smtp, which builds cloudflare_dns_record resources via big-tofu's ->Construct helper and writes them to smtp.tf.json.

4 · tofu-smtp-post — verify domain

Now that DNS exists, this stage finalises Resend setup: typically calling Resend's domain-verify endpoint so the domain transitions to verified.

5 · ansible-local — local SSH config

Local playbook that updates ~/.ssh/config with a Host once stanza pointing at the freshly provisioned VM. Running it before the remote stage means the next playbook (and any post-deploy ssh once) goes through the same alias, so host-key prompts and connection details are settled before Ansible's own connection is opened.

6 · ansible — remote host

The big stage. The remote playbook (tools/ansible/main.yml):

  • Installs Docker (idempotent, retried).
  • Installs ONCE (get.once.com).
  • Copies .mailrc and installs s-nail for SMTP smoke-testing.
  • Installs Babashka.
  • Creates a deploy user, grants it NOPASSWD sudo for /usr/local/bin/once *.
  • Installs /usr/local/bin/deploy (Babashka ForceCommand script — see Restricted deploy SSH).
  • Authorizes :deploy-pubkey with restrict,command="/usr/local/bin/deploy" in /home/deploy/.ssh/authorized_keys.
  • Imports once.yml — generated by tools/ansible-once — which reconciles every entry in :once :applications.

Delete order

bb once delete reverses the four Tofu stages in destroy order: tofu-smtp-post → tofu-dns → tofu-smtp → tofu. Ansible stages are not "destroyed" — they were configuration, not resources.

Template rendering

Every stage points big-config.render at a directory under src/resources/io/github/amiorin/once/tools/<stage>/ and a list of transforms. The renderer walks the directory, applies the transforms, and writes the result to .dist/<package>/<stage>/.

Custom delimiters

once overrides the default {{ … }} / { … } Mustache pair to avoid clashing with HCL syntax:

{:tag-open    \<
 :tag-close   \>
 :filter-open \{
 :filter-close \}}
  • <{ … }> — filter expression: substitute a value (e.g. <{ deploy-pubkey }>).
  • {{ provider-compute }} — provider switch: pick the subdir matching the value (e.g. oci, hcloud).

Two-level switching

Each stage's resources directory contains one subdir per provider. The {{ provider-* }} transform tells the renderer which subdir to render. Example layout:

src/resources/io/github/amiorin/once/tools/
├── tofu/
│   ├── digitalocean/main.tf
│   ├── hcloud/main.tf
│   ├── oci/main.tf
│   └── no-infra/main.tf
├── tofu-backend/
│   ├── s3/backend.tf
│   ├── r2/backend.tf
│   └── local/backend.tf
├── tofu-smtp/{resend,no-infra}/main.tf
├── tofu-dns/{cloudflare,no-infra}/main.tf
├── tofu-smtp-post/{resend,no-infra}/main.tf
├── ansible/
│   ├── main.yml
│   ├── ansible.cfg
│   ├── library/
│   └── files/deploy
└── ansible-local/main.yml

Generated files

Some stages produce auxiliary files via :data-fn / render-fn:

  • tofu-dns writes smtp.tf.json from the SMTP records.
  • ansible writes inventory.json (single host group: admin) and once.yml (the ONCE reconcile play).

Parameter flow

The runtime parameter map flows like this:

options/bb              ;; static profile
   │
   ▼
workflow/read-bc-pars   ;; merge BC_PAR_* env vars
   │
   ▼
tofu-smtp-params        ;; merge `tofu output --json` from tofu-smtp/
   │
   ▼
tofu-params             ;; merge `tofu output --json` from tofu/ (provides :ip)
   │
   ▼
::workflow/params       ;; what each stage's templates see

The composition is params/opts-fn:

(def opts-fn (comp tofu-params tofu-smtp-params workflow/read-bc-pars))

Both tofu-params and tofu-smtp-params shell out to tofu output --json in the right .dist/ directory and pluck out [:params :value] via Specter. If the directory or output is missing (e.g. on a fresh checkout), they fall back to placeholder values (:ip "192.168.0.1" or an empty records list) so earlier stages can still render.

Plugin: backend rendering

Every Tofu stage needs a terraform { backend … } block. Rather than hard-coding the backend in every stage's template, once registers a single big-config plugin step that runs after each render and stamps backend.tf into the rendered directory:

(defmethod pluggable/handle-step ::tools/render-tofu-backend
  [_f _step step-fns {:keys [::workflow/name] :as opts}]
  …)

The list of stages is rewritten on the fly so that :render becomes [:render ::render-tofu-backend]:

(defn run-steps-with-plugin
  [plugin-step step-fns opts]
  (-> (update opts ::workflow/steps
              #(reduce (fn [steps step]
                        (into steps (if (= step :render)
                                      [step plugin-step]
                                      [step]))) [] %))
      (->> (workflow/run-steps step-fns))))

The chosen subdir under tofu-backend/ (s3, r2, local) is selected by :provider-backend. R2 piggybacks on Tofu's S3 backend by setting endpoints.s3 and the four skip_* flags.

big-config primitives

A short glossary of the big-config concepts once leans on:

SymbolRole
workflow/->workflow*Builds a step-by-step workflow from a :pipeline.
workflow/run-stepsExecutes a workflow's steps with a list of step-fns.
workflow/parse-argsParses CLI strings like "render tofu:init tofu:apply:-auto-approve" into a step list.
workflow/read-bc-parsMerges environment variables prefixed BC_PAR_ into ::workflow/params.
workflow/prepareSets up a stage's name, prefix, paths, and templates.
workflow/pathReturns the on-disk path of a given stage's .dist output.
render/templatesRenders a list of templates into .dist/.
pluggable/handle-stepMultimethod for plugin steps. The ::render-tofu-backend step is one such impl.
step-fns/->exit-step-fnStep-fn that exits the JVM on ::bc/exit non-zero.
step-fns/->print-error-step-fnStep-fn that prints ::bc/err on failure.

Configuration profiles

Profiles are plain Clojure maps composed with (merge-with merge …). Two layers:

  1. Sub-profiles (^:private): resend, cloudflare, s3, r2, local, oci, hcloud, digitalocean, no-infra-compute, no-infra-smtp, no-infra-dns, deploy.
  2. Application profiles (public): online, space, website, no-infra. Each pins a domain, package, and the :once :applications list.

Application profiles shipped with the repo

ProfileDomainComputeBackendApps
websitebigconfig.websitedigitaloceanr23 (bigconfig site, redirect, forms)
onlinebigconfig.onlineocir21 (bigconfig site)
spacebigconfig.spaceocir21 (Pocketbase)
no-infrano-infra

Anatomy of a profile

(def space (merge-with merge resend cloudflare r2 oci deploy
  {::render/profile "space"
   ::workflow/params {:domain "bigconfig.space"
                      :package "space"
                      :once {:applications
                             [{:host  "marketplace-api.bigconfig.space"
                               :image "ghcr.io/amiorin/once-pocketbase"
                               :env   ["SUPERUSER_PASSWORD=<{ superuser-password }>"]}]}}}))

The trailing map carries the app-specific bits — domain, package name, applications. Everything else is contributed by sub-profiles.

The active profile

(def bb website)  ;; change this line to switch

options/bb is the value bb.edn tasks pass to package/once* and validation/validate*.

Compute providers

oci Oracle Cloud

Free-tier ARM Ampere shapes
  • oci-config-file-profile
  • oci-subnet-id
  • oci-compartment-id
  • oci-availability-domain
  • oci-display-name
  • oci-shape (e.g. VM.Standard.A1.Flex)
  • oci-ocpus, oci-memory-in-gbs
  • oci-boot-volume-size-in-gbs, oci-boot-volume-vpus-per-gb
  • oci-ssh-authorized-keys

hcloud Hetzner

Best price/perf in EU
  • hcloud-name
  • hcloud-image (e.g. ubuntu-24.04)
  • hcloud-server-type (e.g. cx23)
  • hcloud-location
  • hcloud-ssh-keys
  • hcloud-token (secret)

digitalocean DO

Most familiar UX
  • digitalocean-name
  • digitalocean-region
  • digitalocean-size
  • digitalocean-image
  • digitalocean-vpc-uuid
  • digitalocean-ssh-keys (id)
  • do-token (secret)

no-infra BYO server

Skip Tofu compute entirely
  • no-infra-compute-ip
  • no-infra-compute-user
  • no-infra-compute-sudoer
  • no-infra-compute-uid
  • no-infra-compute-name

The chosen provider-compute tag drives both the Tofu template selection (tofu/<provider>/main.tf) and the schema dispatch in validation.

SMTP — Resend

Outgoing mail rides on Resend. The resend sub-profile provides:

{:provider-smtp   "resend"
 :resend-server   "smtp.resend.com"
 :resend-port     587
 :resend-username "resend"}
;; required at runtime via BC_PAR_*:
;; :resend-api-key, :resend-password

Outgoing From address is info@notifications.<domain> (set in tools/ansible-once). The remote host gets a .mailrc wired to Resend, plus s-nail, so you can sanity-check email immediately:

echo hello | mail -s test you@elsewhere.com

The alternative provider is no-infra, which skips Tofu SMTP entirely and lets you supply external credentials via :no-infra-smtp-*.

DNS — Cloudflare

Provider cloudflare ~> 5.0. The cloudflare sub-profile supplies :provider-dns "cloudflare"; you must also provide :cloudflare-api-token via BC_PAR_CLOUDFLARE_API_TOKEN.

The tofu-dns stage creates:

  • An apex A record (@ → :ip), proxied through Cloudflare.
  • A wildcard A record (* → :ip), proxied through Cloudflare.
  • Every record returned by tofu-smtp: SPF (TXT), DKIM (TXT), MX, return-path. These come from the rendered smtp.tf.json, generated by:
(defn render-fn
  [src {:keys [records]}]
  (case src
    :smtp …))

Zone settings bundle

Applied as a fixed bundle (in tofu-dns/cloudflare/main.tf): TLS 1.3 on, minimum TLS 1.2, strict SSL, always-use-HTTPS, brotli, automatic HTTPS rewrites, etc. Edit the template if you need a different posture.

OpenTofu state backend

Three options:

S3

export BC_PAR_PROVIDER_BACKEND="s3"
export BC_PAR_S3_BUCKET="my-tf-state"
export BC_PAR_S3_REGION="eu-west-1"

Cloudflare R2

R2 piggybacks on Tofu's S3 backend with a custom endpoint and the four skip_* flags. Use an Object-scoped R2 token.

export BC_PAR_PROVIDER_BACKEND="r2"
export BC_PAR_R2_BUCKET="my-tf-state"
export BC_PAR_R2_ENDPOINT="https://<acct>.eu.r2.cloudflarestorage.com"
export BC_PAR_R2_ACCESS_KEY_ID="…"
export BC_PAR_R2_SECRET_ACCESS_KEY="…"

validation pings R2 with aws s3api head-bucket and classifies the result into :missing-bucket vs :bad-credentials by stderr pattern matching — list-buckets isn't allowed by R2 Object tokens.

Local

export BC_PAR_PROVIDER_BACKEND="local"

State is written to .dist/<package>/<stage>/terraform.tfstate. Useful for development; do not use for shared environments.

The deploy sub-profile

All four application profiles merge in two SSH public keys:

(def ^:private deploy
  {::workflow/params {:compute-pubkey
                      "ssh-ed25519 AAAAC3… 32617+amiorin@users.noreply.github.com"
                      :deploy-pubkey
                      "ssh-ed25519 AAAAC3… deploy@bigconfig.ai"}})
  • :compute-pubkey — the key whose private half must be loaded in ssh-agent on the machine running bb once create. Ansible uses the agent to reach the freshly provisioned VM, so bb validate rejects the run if it isn't loaded (for cloud compute profiles).
  • :deploy-pubkey — the key authorized on the remote deploy user with ForceCommand. See Restricted deploy SSH.

Override either per-environment:

export BC_PAR_COMPUTE_PUBKEY="ssh-ed25519 …"
export BC_PAR_DEPLOY_PUBKEY="ssh-ed25519 …"

BC_PAR_* environment variables

Any key under ::workflow/params can be overridden at runtime by setting BC_PAR_<UPPERCASE_KEY>. Hyphens become underscores.

Param keyEnv var
:domainBC_PAR_DOMAIN
:provider-backendBC_PAR_PROVIDER_BACKEND
:hcloud-tokenBC_PAR_HCLOUD_TOKEN
:do-tokenBC_PAR_DO_TOKEN
:cloudflare-api-tokenBC_PAR_CLOUDFLARE_API_TOKEN
:resend-api-keyBC_PAR_RESEND_API_KEY
:resend-passwordBC_PAR_RESEND_PASSWORD
:r2-access-key-idBC_PAR_R2_ACCESS_KEY_ID
:r2-secret-access-keyBC_PAR_R2_SECRET_ACCESS_KEY
:compute-pubkeyBC_PAR_COMPUTE_PUBKEY
:deploy-pubkeyBC_PAR_DEPLOY_PUBKEY
:compute-prevent-destroyBC_PAR_COMPUTE_PREVENT_DESTROY

workflow/read-bc-pars performs the merge — its result is (merge ::workflow/params (read env)), so env vars always win over profile defaults.

TipPut non-secret overrides in .envrc and secrets in .envrc.private. direnv sources both automatically.

bb once

bb once create           # full 6-stage pipeline
bb once delete           # reverse 4 Tofu stages
bb once delete create    # clean-slate redeploy

Defined in bb.edn as (package/once* *command-line-args* options/bb). The once* entry parses the args via workflow/parse-args, builds a ::workflow/steps list (:create, :delete, or both), and dispatches to the create / delete workflow defined in package.clj.

Step skipping & partial runs

You can pass tool-level args to override what each stage does. For example, replace the default render tofu:init tofu:apply:-auto-approve sequence by editing package.clj's pipeline — or by invoking individual stage tasks (see bb tofu / ansible).

bb validate

Pre-flight in four phases. Errors from all phases are collected and printed at the end — validate never short-circuits.

  1. Schema (malli): every required key is present and well-typed; every :once :applications :host equals or is a subdomain of :domain; both :compute-pubkey and :deploy-pubkey look like SSH public keys.
  2. Tools: required CLIs are on PATH (base + per-provider).
  3. Credentials: tokens authenticate against their providers.
    • Resend / Hetzner / DigitalOcean: curl bearer-check.
    • Cloudflare: token bearer-check plus a zone lookup that confirms the configured :domain is an active zone on the account.
    • OCI: oci iam region list (with ~/.oci/config existence pre-check).
    • S3 backend: aws sts get-caller-identity.
    • R2 backend: aws s3api head-bucket (classified into :missing-bucket / :bad-credentials / :unknown).
    • Cloud compute (oci/hcloud/digitalocean): SSH_AUTH_SOCK must point to an ssh-agent that has :compute-pubkey loaded (ssh-add -L). Without this, Ansible cannot reach the freshly provisioned VM.
  4. Images: every :image in :once :applications resolves on its registry via skopeo inspect --override-os linux.

Sample failure output:

Validation failed (4 issues):

  Schema:
    - workflow/params → once → applications → 0 → host: must be a valid hostname

  Tools:
    - skopeo not found on PATH. Install: https://github.com/containers/skopeo/blob/main/install.md

  Credentials:
    - Cloudflare zone: example.com not found or not active
    - SSH agent: :compute-pubkey is not loaded in ssh-agent at SSH_AUTH_SOCK=/tmp/ssh-…/agent.123

Programmatic API: (validation/validate options/bb) ; => {:ok? false :errors […]}. Pass an env map as the second arg to inject custom environment values (used by tests).

bb describe

Post-provisioning report. Given the active profile, describe renders the params, queries the live infrastructure, and prints a human-readable summary:

  • Profile: ::render/profile (e.g. website, space).
  • Providers: configured names for compute, backend, SMTP, and DNS.
  • Compute: resolved :ip and SSH user, plus a reachability probe (ssh BatchMode=yes true).
  • Applications: for every host returned by sudo once list, the matching Docker container's status, image, tag, running digest (from docker image inspect's RepoDigests), the live registry digest (via skopeo inspect against the matching OS/arch), and an update-available? flag derived from comparing the two.

Most checks are soft failures (unreachable host, no apps yet, registry hiccup) and don't change the exit code — only a missing remote once command (the server has not been provisioned with ONCE) is fatal.

bb describe

Sample output:

Profile: website

Providers:
  Compute: digitalocean
  Backend: r2
  SMTP: resend
  DNS: cloudflare

Compute:
  IP: 203.0.113.42
  SSH user: root
  Status: running (ssh ok)

Applications:
  - www.bigconfig.website
    status: running
    image: ghcr.io/bigconfig-ai/once-bigconfig:latest
    version: latest
    digest: sha256:1a2b…
    registry digest: sha256:1a2b…
    update available: no

Programmatic API: (describe/describe options/bb) returns the same shape as a map (:profile, :providers, :compute, :applications, :applications-error, :fatal-error?) so tests and tooling can consume it directly. The 3-arg arity (describe opts run-fn once-opts-fn) lets tests inject a stub command runner and stubbed params resolver.

bb tofu / bb ansible (individual stages)

Each stage exposes a Babashka task (-tofu, -tofu-smtp, -tofu-dns, -tofu-smtp-post, -ansible, -ansible-local). The leading hyphen is intentional — they are scoped tasks for granular work, not the primary entry point.

bb -tofu render tofu:init tofu:apply:-auto-approve
bb -tofu-smtp render tofu:init tofu:apply:-auto-approve
bb -tofu-dns render tofu:init tofu:apply:-auto-approve
bb -tofu-smtp-post render tofu:init tofu:apply:-auto-approve
bb -ansible render -- ansible-playbook main.yml
bb -ansible-local render -- ansible-playbook main.yml

Always start with render — it (re)generates .dist/<stage>/ from the templates and the current params map. You can chain Tofu sub-commands as tofu:<cmd>[:<arg>…].

Common one-offs:

# inspect plan only
bb -tofu render tofu:init tofu:plan
# target one resource
bb -tofu-dns render tofu:init tofu:apply:-auto-approve:-target=cloudflare_zone_setting.tls_1_3

bb -tidy

bb -tidy

Runs clojure-lsp clean-ns + clojure-lsp format over the whole repo. Use before committing.

Restricted deploy SSH

The Ansible stage provisions a deploy user that is locked down to a single operation: sudo once update <host>, where <host> must already be present in once list. This makes CI-driven redeploys safe without granting root SSH.

Authorized keys entry

restrict,command="/usr/local/bin/deploy" ssh-ed25519 AAAAC3… github-deploy-key

/usr/local/bin/deploy (Babashka, 47 lines)

  • Reads SSH_ORIGINAL_COMMAND.
  • Splits into 4 tokens; rejects anything that isn't sudo once update <host>.
  • Validates <host> against ^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$.
  • Shells out to sudo once list, strips ANSI escapes, and parses out the registered hosts.
  • If <host> is on the allow-list, runs sudo once update <host>; otherwise die "host not allowed".

The deploy user has NOPASSWD sudo for /usr/local/bin/once * only:

deploy ALL=(ALL) NOPASSWD: /usr/local/bin/once *

Triggering a deploy from CI

ssh deploy@your-host sudo once update marketplace-api.bigconfig.space

Tests

The script is unit-tested in test/clj/io/github/amiorin/once/deploy_test.clj with a mocked once list. Schema and tool/credential validation behaviours are covered in validation_test.clj.

prevent_destroy

Compute resources render with lifecycle { prevent_destroy = true } by default. To delete:

export BC_PAR_COMPUTE_PREVENT_DESTROY=false
bb once delete

The flag is plumbed through tofu's template via the :compute-prevent-destroy key (see tools/tofu in tools.clj).

Troubleshooting

Validation fails on schema

Read the :in path in the error; it walks ::workflow/params → <key>. Most failures are missing BC_PAR_* credentials or a stray :host that isn't a subdomain of :domain.

tofu output --json returns nothing

Run the upstream stage first: tofu-params needs tofu/ applied; tofu-smtp-params needs tofu-smtp/ applied. On a fresh checkout, fallback values (:ip "192.168.0.1", empty records) let earlier stages render — but downstream stages will not match reality until you re-run them.

R2 head-bucket: 404

Bucket missing. once classifies head-bucket failures into :missing-bucket vs :bad-credentials via stderr pattern. If you see :unknown, the err snippet is logged verbatim — paste it into the issue.

Cloudflare provider ~> 5.0 errors

The DNS template uses the v5 API surface (e.g. cloudflare_dns_record instead of cloudflare_record). If tofu init picks an older provider, your ~/.terraformrc may be pinning it — clear ~/.terraform.d/plugin-cache or update the constraint.

SMTP not verifying

Resend's domain status updates lazily. Run bb -tofu-smtp-post render tofu:init tofu:apply:-auto-approve a minute after DNS apply.

Repo layout

once/
├── src/
│   ├── clj/io/github/amiorin/once/
│   │   ├── options.clj      ; profiles + (def bb …)
│   │   ├── package.clj      ; create / delete workflows
│   │   ├── params.clj       ; tofu output → params
│   │   ├── tools.clj        ; stage wrappers + plugin step
│   │   ├── validation.clj   ; malli + tools/creds/images/ssh-agent
│   │   └── describe.clj     ; post-provisioning report
│   └── resources/io/github/amiorin/once/tools/
│       ├── tofu/{do,hcloud,oci,no-infra}/
│       ├── tofu-backend/{s3,r2,local}/
│       ├── tofu-smtp/{resend,no-infra}/
│       ├── tofu-dns/{cloudflare,no-infra}/
│       ├── tofu-smtp-post/{resend,no-infra}/
│       ├── ansible/         ; remote playbook + deploy/library
│       └── ansible-local/   ; local playbook
├── test/clj/io/github/amiorin/once/
│   ├── deploy_test.clj
│   ├── describe_test.clj
│   ├── utils_test.clj
│   └── validation_test.clj
├── env/dev/clj/user.clj     ; REPL dev ns
├── deps.edn
├── bb.edn
├── devenv.nix
└── .envrc

.dist/ is generated output (gitignored). Never edit it by hand.

Namespaces

NamespaceKey entries
options website, online, space, no-infra, bb (active)
package create (workflow), delete (workflow), once, once* (CLI entry)
params tofu-params, tofu-smtp-params, opts-fn, once-opts
tools tofu/tofu*, tofu-smtp/tofu-smtp*, tofu-dns/tofu-dns*, tofu-smtp-post/tofu-smtp-post*, ansible/ansible*, ansible-local/ansible-local*, render-fn, inventory, ansible-once, handle-step ::render-tofu-backend
validation schema:profile, validate, validate*, tool-errors, credential-errors, image-errors
describe describe, describe*, provider-summary, parse-once-list, image->repository+tag, matching-repo-digest, strip-ansi

Functions ending in * are CLI/REPL entry points: they accept a string of args and merge default ::bc/env :shell.

REPL workflow

Every source file ends with a (comment …) block of live evaluation examples — read them as documentation-as-tests. Start a REPL with the :dev alias:

clj -M:dev

Then, for example:

(require '[io.github.amiorin.once.package :as once])
(require '[io.github.amiorin.once.options :as options])

(once/once* "create" options/oci)

The debug macro from big-config.utils wraps a form so its tap>'d values are accessible as tap-values afterwards:

(debug tap-values
  (once/once* "create" options/oci))
(-> tap-values)  ;; inspect

Code conventions

Naming

  • Namespaces: io.github.amiorin.once.*
  • Keywords: fully namespaced (::workflow/params, ::bc/env, ::render/profile)
  • Entry points: functions ending in * (e.g. tofu*, once*)
  • Private defs: marked with ^:private

Data-pipeline pattern

Functions take and return an opts map. Composition is plain comp:

(def opts-fn (comp tofu-params tofu-smtp-params workflow/read-bc-pars))

Profiles compose with merge-with merge

Sub-profiles are tiny maps; application profiles fold them together. Order matters only when keys collide — later wins.

Commit conventions

Conventional Commits: feat:, fix:, refactor:, deps:, docs:, ci:.

Testing

clojure -M:test

Cognitect's test-runner against test/clj/. Four test namespaces:

  • deploy_testForceCommand Babashka script, including allow-list parsing and rejection paths.
  • describe_testonce list parsing, image reference normalization, digest matching, and the end-to-end describe assembly against a stub command runner.
  • utils_test — small utility helpers.
  • validation_test — schema rules (incl. cross-field hosts-match-domain?), tool/credential paths via stub which-fn, the Cloudflare zone check, and the ssh-agent identity check.

What to avoid

  • Don't add error handling for cases that cannot happen. big-config already routes step failure via ::bc/exit and ::bc/err; piling try/catches on top obscures the actual signal.
  • Don't create new namespaces speculatively. The six existing namespaces (options, package, params, tools, validation, describe) map cleanly to their responsibilities. Resist splitting until a real new concern appears.
  • Don't modify .dist/. It is generated output. If a template needs to change, change the source under src/resources/.
  • No credentials in source. Use BC_PAR_* via .envrc.private.

Working against local big-config

To iterate on big-config changes, swap the dep in deps.edn:

;; comment out the pinned git/sha:
io.github.amiorin/big-config {:git/sha "f9508c7b…"}
;; uncomment the local checkout:
io.github.amiorin/big-config {:local/root "../big-config/main"}

The line is already present in deps.edn, just commented out via #_#_.

Programmatic API

package/once*

(once/once* "create" options/oci)
(once/once* "delete create" options/website)
(once/once* "create" (merge options/space {::bc/env :repl}))

First arg is a single string of space-separated step names. Second arg is the active opts map.

tools/tofu* et al.

(tools/tofu* "render tofu:init tofu:apply:-auto-approve"
             (params/once-opts options/website))

params/once-opts attaches the ::workflow/start prefix and runs the opts-fn pipeline.

validation/validate

(validation/validate options/bb)
;; => {:ok? false :errors [{:check :tool :detail "..."} ...]}

Pass an env map as a second arg to inject custom env vars (used by tests).

describe/describe

(describe/describe options/bb)
;; => {:profile "website"
;;     :providers {:compute "digitalocean" :backend "r2" :smtp "resend" :dns "cloudflare"}
;;     :compute {:ip "203.0.113.42" :user "root" :running? true :detail "ssh ok"}
;;     :applications [{:host … :status … :image … :version … :digest …
;;                     :registry-digest … :new-version? false}]
;;     :applications-error nil
;;     :fatal-error? false}

The 3-arg arity (describe opts run-fn once-opts-fn) lets tests inject a stub command runner and a stubbed params resolver. describe* is the CLI wrapper that prints the report and exits non-zero on :fatal-error?.

Parameter table (full)

Base

KeyTypeNotes
:domaindomainCloudflare-managed.
:packagenon-empty stringUsed as the .dist/ subdir name.
:oncemap{:applications [{:host :image :env}…]}
:compute-pubkeySSH public keyPrivate half must be loaded in ssh-agent on the operator's machine for cloud compute profiles.
:deploy-pubkeySSH public keyAuthorized on the remote deploy user.
:provider-smtp"resend" | "no-infra"Dispatch.
:provider-dns"cloudflare" | "no-infra"Dispatch.
:provider-compute"oci" | "hcloud" | "digitalocean" | "no-infra"Dispatch.
:provider-backend"s3" | "r2" | "local"Dispatch.
:compute-prevent-destroyboolDefault true; flip via BC_PAR_.

Resend

KeyDefault
:resend-serversmtp.resend.com
:resend-port587
:resend-usernameresend
:resend-api-key
:resend-password

Cloudflare

KeyDefault
:cloudflare-api-token

S3 backend

KeyDefault
:s3-buckete.g. once-dev-2512…
:s3-regioneu-west-1

R2 backend

KeyNotes
:r2-bucketnon-empty string
:r2-endpointe.g. https://<acct>.eu.r2.cloudflarestorage.com
:r2-access-key-idsecret
:r2-secret-access-keysecret

Compute (provider-specific keys)

See the Compute providers cards above. All listed keys are required per provider; bb validate lists the missing ones.

Validation schema

Defined with malli in validation.clj. Sketch:

schema:profile     ; ::render/profile string + ::workflow/params
└─ schema:params
    ├─ schema:base-params       ; :domain :package :once :compute-pubkey :deploy-pubkey
    ├─ schema:smtp              ; multi on :provider-smtp
    │   ├─ schema:resend
    │   └─ schema:no-infra-smtp
    ├─ schema:dns               ; multi on :provider-dns
    │   ├─ schema:cloudflare
    │   └─ schema:no-infra-dns
    ├─ schema:compute           ; multi on :provider-compute
    │   ├─ schema:oci
    │   ├─ schema:hcloud
    │   ├─ schema:digitalocean
    │   └─ schema:no-infra-compute
    ├─ schema:backend           ; multi on :provider-backend
    │   ├─ schema:s3
    │   ├─ schema:r2
    │   └─ schema:local
    └─ schema:cross-field       ; hosts-match-domain?

Cross-field: hosts-match-domain?

Every :once :applications :host must equal :domain or end in "." + :domain. This catches a category of typos that would otherwise only fail downstream during DNS apply.

Regexes

  • domain-rx = hostname-rx = ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$
  • image-rx = ^[a-z0-9.-]+/[a-z0-9._-]+(/[a-z0-9._-]+)*(:[a-zA-Z0-9._-]+)?$
  • ssh-pubkey-rx = ^ssh-(ed25519|rsa|dss|ecdsa) [A-Za-z0-9+/=]+( .*)?$

License

Copyright © 2026 Alberto Miorin. Distributed under the MIT License.


Built on top of big-config. Targets Basecamp ONCE.