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.
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-agentwith:compute-pubkeyloaded — 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 restricteddeployuser, and your applications running. - An entry in
~/.ssh/configso you canssh onceimmediately.
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
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.
| Tool | Required for | Install hint |
|---|---|---|
tofu | all profiles | opentofu.org |
ansible-playbook | all profiles | pipx install ansible |
ssh | all profiles | distro openssh-client |
curl | all profiles | distro curl |
skopeo | image checks | containers/skopeo |
oci | oci compute | pip install oci-cli |
hcloud | hcloud compute | hetznercloud/cli |
doctl | digitalocean compute | DO docs |
aws | s3 or r2 backend | AWS CLI v2 |
clojure + bb | building & running | clojure.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.
| Namespace | Responsibility |
|---|---|
options | Profile maps and the (def bb …) selector. |
package | The create / delete workflow definitions. |
params | Reading Tofu outputs back into the workflow's ::workflow/params map. |
tools | The Tofu and Ansible tool wrappers, plus the backend-render plugin step. |
validation | Malli schema, tool / credential / image / ssh-agent pre-flight checks. |
describe | Post-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
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
.mailrcand installss-nailfor SMTP smoke-testing. - Installs Babashka.
- Creates a
deployuser, grants itNOPASSWDsudo for/usr/local/bin/once *. - Installs
/usr/local/bin/deploy(BabashkaForceCommandscript — see Restricted deploy SSH). - Authorizes
:deploy-pubkeywithrestrict,command="/usr/local/bin/deploy"in/home/deploy/.ssh/authorized_keys. - Imports
once.yml— generated bytools/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-dnswritessmtp.tf.jsonfrom the SMTP records.ansiblewritesinventory.json(single host group:admin) andonce.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:
| Symbol | Role |
|---|---|
workflow/->workflow* | Builds a step-by-step workflow from a :pipeline. |
workflow/run-steps | Executes a workflow's steps with a list of step-fns. |
workflow/parse-args | Parses CLI strings like "render tofu:init tofu:apply:-auto-approve" into a step list. |
workflow/read-bc-pars | Merges environment variables prefixed BC_PAR_ into ::workflow/params. |
workflow/prepare | Sets up a stage's name, prefix, paths, and templates. |
workflow/path | Returns the on-disk path of a given stage's .dist output. |
render/templates | Renders a list of templates into .dist/. |
pluggable/handle-step | Multimethod for plugin steps. The ::render-tofu-backend step is one such impl. |
step-fns/->exit-step-fn | Step-fn that exits the JVM on ::bc/exit non-zero. |
step-fns/->print-error-step-fn | Step-fn that prints ::bc/err on failure. |
Configuration profiles
Profiles are plain Clojure maps composed with (merge-with merge …). Two layers:
- Sub-profiles (
^:private):resend,cloudflare,s3,r2,local,oci,hcloud,digitalocean,no-infra-compute,no-infra-smtp,no-infra-dns,deploy. - Application profiles (public):
online,space,website,no-infra. Each pins a domain, package, and the:once :applicationslist.
Application profiles shipped with the repo
| Profile | Domain | Compute | Backend | Apps |
|---|---|---|---|---|
website | bigconfig.website | digitalocean | r2 | 3 (bigconfig site, redirect, forms) |
online | bigconfig.online | oci | r2 | 1 (bigconfig site) |
space | bigconfig.space | oci | r2 | 1 (Pocketbase) |
no-infra | — | no-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
oci-config-file-profileoci-subnet-idoci-compartment-idoci-availability-domainoci-display-nameoci-shape(e.g.VM.Standard.A1.Flex)oci-ocpus,oci-memory-in-gbsoci-boot-volume-size-in-gbs,oci-boot-volume-vpus-per-gboci-ssh-authorized-keys
hcloud Hetzner
hcloud-namehcloud-image(e.g.ubuntu-24.04)hcloud-server-type(e.g.cx23)hcloud-locationhcloud-ssh-keyshcloud-token(secret)
digitalocean DO
digitalocean-namedigitalocean-regiondigitalocean-sizedigitalocean-imagedigitalocean-vpc-uuiddigitalocean-ssh-keys(id)do-token(secret)
no-infra BYO server
no-infra-compute-ipno-infra-compute-userno-infra-compute-sudoerno-infra-compute-uidno-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
Arecord (@ → :ip), proxied through Cloudflare. - A wildcard
Arecord (* → :ip), proxied through Cloudflare. - Every record returned by
tofu-smtp: SPF (TXT), DKIM (TXT), MX, return-path. These come from the renderedsmtp.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 inssh-agenton the machine runningbb once create. Ansible uses the agent to reach the freshly provisioned VM, sobb validaterejects the run if it isn't loaded (for cloud compute profiles).:deploy-pubkey— the key authorized on the remotedeployuser withForceCommand. 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 key | Env var |
|---|---|
:domain | BC_PAR_DOMAIN |
:provider-backend | BC_PAR_PROVIDER_BACKEND |
:hcloud-token | BC_PAR_HCLOUD_TOKEN |
:do-token | BC_PAR_DO_TOKEN |
:cloudflare-api-token | BC_PAR_CLOUDFLARE_API_TOKEN |
:resend-api-key | BC_PAR_RESEND_API_KEY |
:resend-password | BC_PAR_RESEND_PASSWORD |
:r2-access-key-id | BC_PAR_R2_ACCESS_KEY_ID |
:r2-secret-access-key | BC_PAR_R2_SECRET_ACCESS_KEY |
:compute-pubkey | BC_PAR_COMPUTE_PUBKEY |
:deploy-pubkey | BC_PAR_DEPLOY_PUBKEY |
:compute-prevent-destroy | BC_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.
.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.
- Schema (
malli): every required key is present and well-typed; every:once :applications :hostequals or is a subdomain of:domain; both:compute-pubkeyand:deploy-pubkeylook like SSH public keys. - Tools: required CLIs are on
PATH(base + per-provider). -
Credentials: tokens authenticate against their providers.
- Resend / Hetzner / DigitalOcean: curl bearer-check.
- Cloudflare: token bearer-check plus a zone lookup that confirms the configured
:domainis an active zone on the account. - OCI:
oci iam region list(with~/.oci/configexistence 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_SOCKmust point to anssh-agentthat has:compute-pubkeyloaded (ssh-add -L). Without this, Ansible cannot reach the freshly provisioned VM.
- Images: every
:imagein:once :applicationsresolves on its registry viaskopeo 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
:ipand 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 (fromdocker image inspect'sRepoDigests), the live registry digest (viaskopeo inspectagainst 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, runssudo once update <host>; otherwisedie "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
| Namespace | Key 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_test—ForceCommandBabashka script, including allow-list parsing and rejection paths.describe_test—once listparsing, image reference normalization, digest matching, and the end-to-enddescribeassembly against a stub command runner.utils_test— small utility helpers.validation_test— schema rules (incl. cross-fieldhosts-match-domain?), tool/credential paths via stubwhich-fn, the Cloudflare zone check, and thessh-agentidentity check.
What to avoid
- Don't add error handling for cases that cannot happen.
big-configalready routes step failure via::bc/exitand::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 undersrc/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
| Key | Type | Notes |
|---|---|---|
:domain | domain | Cloudflare-managed. |
:package | non-empty string | Used as the .dist/ subdir name. |
:once | map | {:applications [{:host :image :env}…]} |
:compute-pubkey | SSH public key | Private half must be loaded in ssh-agent on the operator's machine for cloud compute profiles. |
:deploy-pubkey | SSH public key | Authorized 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-destroy | bool | Default true; flip via BC_PAR_. |
Resend
| Key | Default |
|---|---|
:resend-server | smtp.resend.com |
:resend-port | 587 |
:resend-username | resend |
:resend-api-key | — |
:resend-password | — |
Cloudflare
| Key | Default |
|---|---|
:cloudflare-api-token | — |
S3 backend
| Key | Default |
|---|---|
:s3-bucket | e.g. once-dev-2512… |
:s3-region | eu-west-1 |
R2 backend
| Key | Notes |
|---|---|
:r2-bucket | non-empty string |
:r2-endpoint | e.g. https://<acct>.eu.r2.cloudflarestorage.com |
:r2-access-key-id | secret |
:r2-secret-access-key | secret |
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.