Clojure / Babashka
package-once-green
Run with ./green. Reads YAML with yamlstar.
Green · Red · Blue · OpenTofu · Ansible
Choose Clojure, TypeScript, or Python to create and operate the same production, single-server Basecamp ONCE deployment from reviewable desired state.
Once provisions a server, configures DNS and outgoing email, installs Docker and Basecamp ONCE, and reconciles declared applications. All three read one colors.yml, found by walking up from the working directory, so switching colours needs no change to desired state. They render byte-identical infrastructure artifacts and manage the same state.
You provide application hostnames and images, provider choices, non-secret settings, and public SSH keys. The hostnames decide the DNS zones and mail domains, so there is no separate domain setting and applications may span domains. Credentials and application secrets stay in your environment. The result is an isolated set of OpenTofu and Ansible files plus a repeatable lifecycle command.
build and create --dry-run. Neither command contacts a cloud provider or changes infrastructure. Run a real create only after reviewing the desired state and dry-run.
Clojure / Babashka
package-once-greenRun with ./green. Reads YAML with yamlstar.
TypeScript / Bun
package-once-redRun with ./red. Reads YAML with Bun.YAML.
Python / uv
package-once-blueRun with ./blue. Reads YAML with PyYAML.
Each skill gathers non-secret inputs, creates its launcher and colors.yml, protects .colors/, and runs build plus dry-run checks. The COLORS_PAR_* namespace is shared by every colour. Switch only between completed commands; never run implementations concurrently against the same state.
| Tool | Needed for | Notes |
|---|---|---|
Node.js / npx | Loading the skill | Only needed to run the Skills CLI. |
Babashka | Green launcher commands | Runs ./green. |
Bun | Red launcher commands | Runs ./red. |
uv and Python 3.11+ | Blue launcher commands | Runs ./blue from PEP 723 metadata. |
OpenTofu | Real create, delete, and state-backed describe | Manages compute, SMTP, DNS, and verification state. |
Ansible | Real create and delete | Configures the remote host and ONCE applications; delete replays the local play to drop the managed SSH block. |
OpenSSH | Create and describe | Private compute key should already be available through ssh-agent. |
skopeo | Describe | Compares the running image digest against the registry. Without it, updates report as unknown. |
Install only the runtime for your chosen implementation. Provider credentials are required only for a real create or delete and only for selected providers. Build and dry-run validate non-secret desired state without checking credentials.
Load one implementation for a one-off agent session:
npx skills use getcolors/once@package-once-green
npx skills use getcolors/once@package-once-red
npx skills use getcolors/once@package-once-blue
Use the same names with npx skills add getcolors/once --skill … for permanent installation.
Tell the agent the profile name, the application hostnames and images, provider choices, and public-key file paths. Hostnames may span domains; each hostname determines its DNS zone and matching mail domain. Give the agent only the names of environment variables that hold application secrets—never secret values.
Create an ONCE project using Green, Red, or Blue.
Deploy ghcr.io/example/site:latest at www.example.com.
Deploy ghcr.io/example/another-site:latest at www.example.net.
Use DigitalOcean, Resend, Cloudflare, and R2.
Map DATABASE_URL to the key :site-database-url.
The skill writes the selected launcher and one colors.yml. Every colour reads the same file. No credential or application secret value belongs in it.
The skill builds generated configuration and prints the execution graph without provisioning anything:
./green build # or: ./red build / ./blue build
./green create --dry-run # or the equivalent ./blue command
Set the required environment variables through your private shell or secret manager, review the dry-run, and explicitly approve the real operation:
./green create # substitute red/blue as selected
./green describe
create or delete merely because initialization succeeded. Those operations require explicit user confirmation.
Each implementation executes the same directed acyclic graph. Independent work runs concurrently; downstream steps receive outputs from their upstream branches.
┌─ tofu-compute ─┐ ┌─ ansible-local
start + validation ─┤ ├─ tofu-dns ─ tofu-smtp-post ─┤
└─ tofu-smtp ────┘ └─ ansible-remote ─ github
Publishing follows the remote stage rather than the local one: the credentials describe a configured host, so a failure on the workstation side does not gate them.
For build, the same graph renders deterministic configuration with safe placeholder outputs but does not invoke OpenTofu or Ansible.
start + validation ─ github ─ ansible-cleanup ─ tofu-smtp-post ─ tofu-dns ─┬─ tofu-smtp
└─ tofu-compute
A real delete first withdraws the published credentials, then replays the local Ansible play to drop the managed ~/.ssh/config block and removes the generated Ansible files, then destroys SMTP verification and DNS, and finally destroys SMTP and compute concurrently. Existing OpenTofu outputs are loaded before teardown so the destroy renders with the values create produced.
Revoking runs before anything is destroyed because the two failure modes are not symmetric: a withdrawn credential against a live host is a loud, recoverable broken deploy, while a live credential against a destroyed host is silent. The step needs no key material, so it also works when the box is already gone.
colors.yml is a flat YAML map except for applications, with kebab-case keys. Every colour reads it under the YAML 1.2 core schema, so no is the string no and 012 is twelve; quote anything that should stay text. Keep credentials, passwords, private keys, and application secret values out of it.
profile, workdir: .colors, providers, and settings when switching colors. Generated paths, resource addresses, and bytes are identical.profile: production
workdir: .colors
once:
applications:
- host: www.example.com
image: ghcr.io/example/site:latest
github: acme/site
env:
DATABASE_URL: site-database-url
- host: www.example.net
image: ghcr.io/example/another-site:latest
provider-compute: digitalocean
provider-smtp: resend
provider-dns: cloudflare
provider-backend: r2
compute-prevent-destroy: true
digitalocean-name: once
digitalocean-region: ams3
digitalocean-size: s-1vcpu-1gb-35gb-intel
digitalocean-image: ubuntu-24-04-x64
digitalocean-ssh-keys: fingerprint-or-id-already-in-the-account
r2-bucket: once-tfstate
r2-endpoint: https://ACCOUNT_ID.r2.cloudflarestorage.com
A COLORS_PAR_* variable overrides a flat key. The launcher strips the prefix, lowercases the name, and changes underscores to hyphens. Existing boolean and integer values retain their types.
COLORS_PAR_DIGITALOCEAN_REGION=fra1 ./green build
COLORS_PAR_DIGITALOCEAN_SIZE=s-2vcpu-2gb ./green create --dry-run
Use this mechanism for temporary deployment settings, for the destruction guard, and for every credential — the same overlay carries secrets, as listed under Secrets and environment.
Every tool gets an isolated directory under <workdir>/<profile>/. With the recommended defaults, a build resembles:
.colors/
└── production/
├── tofu-compute/
│ ├── backend.tf.json
│ └── main.tf
├── tofu-smtp/
│ ├── backend.tf.json
│ └── main.tf
├── tofu-dns/
│ ├── backend.tf.json
│ ├── main.tf
│ ├── apps.tf.json
│ └── smtp.tf.json
├── tofu-smtp-post/
│ ├── backend.tf.json
│ └── main.tf
├── ansible-local/
│ ├── ansible.cfg
│ ├── inventory.ini
│ └── main.yml
└── ansible-remote/
├── ansible.cfg
├── main.yml
├── inventory.json
├── once.yml
├── files/deploy
└── library/once
<profile>/<tool>.tfstate.env uses the map form, which is the one that defers to a COLORS_PAR_* lookup.apps.tf.json and smtp.tf.json are generated for the Cloudflare and Yandex DNS providers; no-infra renders main.tf alone.ansible-local play writes a managed Host <profile> block into ~/.ssh/config, delimited by its own markers. Delete removes that block and leaves the rest of the file untouched.Applications live in once {:applications [...]}. Every application requires a hostname and a container image. Hostnames may span domains. Green derives every DNS zone from the hostname's last two labels, creates a matching Resend sending domain, and assigns each application a From address in its own zone. Each hostname gets its own A record — proxied on Cloudflare, plain on Yandex. No implicit apex or wildcard record is created, so a hostname that is not listed here does not resolve.
once:
applications:
- host: www.example.com
image: ghcr.io/example/site:latest
env:
DATABASE_URL: site-database-url
SECRET_KEY_BASE: site-secret-key-base
auto_update: true
auto_backup: true
backup_path: /var/backups/site
cpus: 2
memory: 2g
env maps a container variable name to the flat colors.yml key that holds its value. It never maps to a secret value. The value is supplied by the COLORS_PAR_* variable named after that key — site-database-url comes from COLORS_PAR_SITE_DATABASE_URL — and the launcher verifies every reference resolves before a real create.
| Application key | Purpose |
|---|---|
host | Required FQDN served by the application. |
image | Required container image reference. |
env | Map of container variable names to the colors.yml keys holding their values. |
auto_update | Enable ONCE automatic image updates. |
auto_backup | Enable ONCE automatic backups. |
backup_path | Set the application backup path. |
disable_tls | Disable ONCE-managed TLS for the application. |
cpus | Set the container CPU allocation. |
memory | Set the container memory allocation. |
| Selection | Required environment variables | When |
|---|---|---|
| Azure | None — uses the native Azure CLI session | — |
| AWS | None — uses the native AWS credential chain | — |
| Google Cloud | None — uses Application Default Credentials | — |
| DigitalOcean | COLORS_PAR_DO_TOKEN or COLORS_PAR_DO_TOKEN | Create and delete |
| Hetzner Cloud | COLORS_PAR_HCLOUD_TOKEN | Create and delete |
| Yandex Cloud | COLORS_PAR_YANDEX_TOKEN | Create and delete |
| Oracle Cloud | None — authenticates through the configured OCI profile | — |
| Resend | COLORS_PAR_RESEND_API_KEY and COLORS_PAR_RESEND_PASSWORD | Create and delete |
| Existing SMTP | COLORS_PAR_NO_INFRA_SMTP_PASSWORD | Create and delete |
| Cloudflare DNS | COLORS_PAR_CLOUDFLARE_API_TOKEN | Create and delete |
| R2 backend | COLORS_PAR_R2_ACCESS_KEY_ID and COLORS_PAR_R2_SECRET_ACCESS_KEY | Create and delete |
| S3 backend | None — OpenTofu resolves its AWS credential chain | Create and delete |
| Application environment | One COLORS_PAR_* variable per mapped key | Create |
One namespace serves every colour: COLORS_PAR_DO_TOKEN fills the same key whether you run green, red or blue. There is no per-colour prefix and no TF_VAR_* alias.
Nothing is written into a rendered file. OpenTofu credentials are passed through the process environment under provider-native names. The byte-identical Ansible expression resolves COLORS_PAR_* at play time. Build and dry-run require none of them.
for name in COLORS_PAR_DO_TOKEN COLORS_PAR_RESEND_API_KEY \
COLORS_PAR_RESEND_PASSWORD COLORS_PAR_CLOUDFLARE_API_TOKEN \
COLORS_PAR_R2_ACCESS_KEY_ID COLORS_PAR_R2_SECRET_ACCESS_KEY; do
if [ -n "${!name:-}" ]; then
printf '%s: set\n' "$name"
else
printf '%s: missing\n' "$name"
fi
done
Adjust the list to your selected providers. This check reports only presence and never prints values.
provider-compute: azure
azure-subscription-id: 00000000-0000-0000-0000-000000000000
azure-location: swedencentral
azure-resource-group: once
azure-name: once
azure-vm-size: Standard_D2pls_v6
azure-image-publisher: Canonical
azure-image-offer: ubuntu-24_04-lts
azure-image-sku: server-arm64
azure-image-version: 24.04.202608020
azure-vnet-cidr: 10.10.0.0/16
azure-subnet-cidr: 10.10.1.0/24
azure-boot-disk-size-gb: 30
azure-ssh-authorized-keys: ~/.ssh/id_ed25519.pub
Creates a dedicated resource group, VNet, subnet, public IP, network security group, NIC, and VM. Authentication uses the native Azure CLI session.
provider-compute: aws
aws-region: eu-west-1
aws-availability-zone: eu-west-1a
aws-name: once
aws-instance-type: t3.small
aws-image-id: ami-...
aws-vpc-cidr: 10.0.0.0/16
aws-subnet-cidr: 10.0.1.0/24
aws-root-volume-size-gb: 30
aws-ssh-authorized-keys: ~/.ssh/id_ed25519.pub
Creates an isolated VPC, public subnet, internet gateway, security group, key pair, and EC2 instance. Authentication uses the native AWS credential chain.
provider-compute: google
google-project: example-project
google-region: europe-west4
google-zone: europe-west4-b
google-name: once
google-machine-type: t2a-standard-1
google-image-project: ubuntu-os-cloud
google-image-family: ubuntu-2404-lts-arm64
google-image-id: projects/ubuntu-os-cloud/global/images/ubuntu-2404-noble-arm64-v20260723
google-subnet-cidr: 10.20.1.0/24
google-boot-disk-size-gb: 30
google-ssh-authorized-keys: ~/.ssh/id_ed25519.pub
Creates a custom VPC, subnet, firewall rule, static address, and Compute Engine instance. Authentication uses Application Default Credentials.
provider-compute: digitalocean
digitalocean-name: once
digitalocean-region: ams3
digitalocean-size: s-1vcpu-1gb-35gb-intel
digitalocean-image: ubuntu-24-04-x64
digitalocean-ssh-keys: fingerprint-or-id-already-in-the-account
# Optional:
digitalocean-vpc-uuid: non-secret-vpc-uuid
The droplet attaches the key named by digitalocean-ssh-keys, which must already exist in the account — give it a fingerprint or key id. Credential: COLORS_PAR_DO_TOKEN.
provider-compute: hcloud
hcloud-name: once
hcloud-image: ubuntu-24.04
hcloud-server-type: cx23
hcloud-location: hel1
hcloud-ssh-keys: key-name-or-id-already-in-the-project
Creates an IPv4 server attached to the key named by hcloud-ssh-keys, which must already exist in the project. Credential: COLORS_PAR_HCLOUD_TOKEN.
provider-compute: yandex
compute-pubkey: ssh-ed25519 AAAA... operator
yandex-cloud-id: b1g...
yandex-folder-id: b1g...
yandex-zone: kz1-a
yandex-image-family: ubuntu-2404-lts
yandex-name: once
yandex-subnet-cidr: 10.0.0.0/24
yandex-platform-id: standard-v3
yandex-cores: 2
yandex-memory-gb: 2
yandex-core-fraction: 100
yandex-disk-size-gb: 20
yandex-static-ip: false
yandex-allow-stopping-for-update: false
yandex-image-id: fd8... # optional; pins the boot image
Creates a network, subnet, NAT-enabled Ubuntu instance, and boot disk. compute-pubkey is installed for the ubuntu user; keep the private half in ssh-agent. Credential: COLORS_PAR_YANDEX_TOKEN, passed to OpenTofu as YC_TOKEN.
yandex-static-ip reserves a yandex_vpc_address and pins the instance's NAT to it. Yandex releases an ephemeral NAT address whenever an instance stops, so without a reserved address a restarted instance comes back on a different IP and anything bound to the old one — a DNS record, a certificate, an ~/.ssh/config entry — silently breaks.
yandex-allow-stopping-for-update lets OpenTofu stop the instance to apply a change Yandex cannot make in place, such as yandex-cores, yandex-memory-gb, or yandex-platform-id. Leave it off for a server carrying traffic — the apply then fails naming the flag rather than taking the instance down mid-deploy — and enable it for the one deploy that resizes the instance (COLORS_PAR_YANDEX_ALLOW_STOPPING_FOR_UPDATE=true). Attaching a reserved address does not need it. Both keys default to false, which renders exactly as before.
yandex-image-id optionally pins the boot image. Unset, the family is resolved for the first create and later image ids are ignored so upstream releases cannot unexpectedly replace the server. Changing the pin deliberately plans a replacement, guarded by compute-prevent-destroy.
provider-compute: oci
oci-config-file-profile: DEFAULT
oci-subnet-id: ocid1.subnet...
oci-compartment-id: ocid1.compartment...
oci-availability-domain: ...
oci-display-name: once
oci-shape: VM.Standard.A1.Flex
oci-image-id: ocid1.image... # optional; pins the boot image
oci-ocpus: 1
oci-memory-in-gbs: 4
oci-boot-volume-size-in-gbs: 50
oci-boot-volume-vpus-per-gb: 30
oci-ssh-authorized-keys: /home/user/.ssh/once.pub
The current default shape is ARM-based. oci-ssh-authorized-keys is a path to a public-key file on the machine running the launcher; its contents are written into instance metadata at plan time. OCI authenticates through the named profile in ~/.oci/config, so no credential variable is required.
oci-image-id is optional and pins the boot image. Left unset, ONCE looks up the newest Canonical Ubuntu 24.04 image compatible with oci-shape — the right behaviour for a first create, and a moving target afterwards. The image id forces replacement on the instance, so once Canonical publishes a newer one, a routine apply proposes destroying the server. Pin it once the stack is real; tofu state show oci_core_instance.ampere_vm reports the running image as source_id. With it set, no image lookup happens at all.
oci-image-id replaces the server
The boot image is not something an existing instance can be moved to in place. A changed value destroys and recreates the VM, taking any data not held elsewhere with it. compute-prevent-destroy: true — the default — turns that into a refused apply rather than a silent rebuild.
provider-compute: no-infra
no-infra-compute-ip: 203.0.113.10
no-infra-compute-user: root
no-infra-compute-sudoer: root
no-infra-compute-uid: 0
No compute resource or API credential is required. The server must be reachable using the configured user and an SSH identity already loaded into ssh-agent. The host is reported under profile, so it needs no name of its own.
provider-smtp: resend
Resend needs no further keys: its relay is the same for every account, so smtp.resend.com, port 587, and the user resend are hard-coded. The SMTP stage creates notifications.<zone> for every distinct application zone and returns each domain's DNS records; the post stage verifies every sending domain after DNS is configured. Each application sends from info@notifications.<its-zone>. Use COLORS_PAR_RESEND_API_KEY for the Resend API and COLORS_PAR_RESEND_PASSWORD for server-side SMTP authentication.
provider-smtp: no-infra
no-infra-smtp-server: smtp.example.net
no-infra-smtp-port: 587
no-infra-smtp-username: smtp-user
No SMTP infrastructure is created. A real create still requires COLORS_PAR_NO_INFRA_SMTP_PASSWORD so the remote ONCE server can send mail.
Managed
cloudflareFinds every zone derived from the application hostnames, creates a proxied A record for each hostname in its zone, and adds each sending domain's unproxied Resend records. Every zone must already exist in the account. Requires COLORS_PAR_CLOUDFLARE_API_TOKEN with zone-read and DNS-edit access to every zone.
Managed
yandexCreates a public Yandex Cloud DNS zone for every domain derived from the application hostnames, an A record for each hostname (records are not proxied), and each sending domain's Resend records. Delegate each domain once at its registrar to ns1.yandexcloud.net and ns2.yandexcloud.net. Requires yandex-cloud-id, yandex-folder-id, and COLORS_PAR_YANDEX_TOKEN — the same token Yandex compute uses.
External
no-infraCreates no DNS resources and requires no DNS credential. You are responsible for application and SMTP records outside Once.
provider-dns: cloudflare # or "yandex", "no-infra"
Backend advice writes a backend configuration before every OpenTofu stage. State is isolated by both profile and tool.
provider-backend: local
Best for experiments. Protect and back up the work directory; losing local state makes safe lifecycle management difficult.
provider-backend: s3
s3-bucket: once-tfstate
s3-region: eu-west-1
The generated backend names only the bucket, key, and region, so OpenTofu resolves credentials through its own AWS chain — environment variables, a shared profile, or an instance role. State keys are <profile>/<tool>.tfstate.
provider-backend: r2
r2-bucket: once-tfstate
r2-endpoint: https://ACCOUNT_ID.r2.cloudflarestorage.com
Uses the S3-compatible backend with region = "auto", taking the R2 access key from COLORS_PAR_R2_ACCESS_KEY_ID and COLORS_PAR_R2_SECRET_ACCESS_KEY. State keys are <profile>/<tool>.tfstate.
Operations
compute-pubkeyRequired by Yandex Cloud, which installs it for the ubuntu user through instance metadata. Other providers use a key already registered with them (digitalocean-ssh-keys, hcloud-ssh-keys) or a local public-key file (oci-ssh-authorized-keys). The matching private half should be loaded into ssh-agent before create so Ansible can reach the new server.
Deployment
githubOptional, per application, as owner/repo. Keys are per repository: ONCE generates one keypair for every repository named in desired state on each create and publishes the private half — with SERVER_IP, SERVER_USER, and SSH_KNOWN_HOSTS — to a GitHub Actions environment named after the profile. Two applications may name the same repository, one image answering for several hosts; they share one key whose forced command names every host it serves. Pinning the host key lets a workflow drop ssh-keyscan, which otherwise trusts whatever answers on that address on every deploy. Requires COLORS_PAR_GITHUB_TOKEN. Deploy keys are never written into colors.yml.
Public keys are not secrets, but private key files are. The skill may read only a public .pub file that you approve; it must never inspect a private SSH key.
ssh-add -l
ssh -T deploy@203.0.113.10 < /dev/null
Usage: ./<color> <build|create|delete|describe> [-f|--file <color-state>] [--dry-run]
Colors: green / red / blue
| Command | Effect | External changes |
|---|---|---|
./COLOR build | Validate and render all stage files. | Writes only the local work directory. |
./COLOR create --dry-run | Validate and print the create DAG. | None. |
./COLOR create | Provision infrastructure and reconcile the server. | Yes. |
./COLOR describe | Read state, probe SSH, and list ONCE applications. | Read-only. |
./COLOR delete --dry-run | Validate and print the delete DAG. | None. |
./COLOR delete | Destroy managed infrastructure in reverse order. | Destructive. |
There is no pin command. Stamping the launcher with the commit its sources come from is a maintenance task inside the Once repository, not something a project ever runs.
./green build
Build validates the selected providers and required non-secret values, then renders all OpenTofu and Ansible files. It does not invoke OpenTofu, Ansible, SSH, or provider APIs. Use it to review exactly what any implementation would generate.
./green create --dry-run
./green create
Dry-run validates desired-state structure and prints planned DAG actions without requiring provider credentials. Real create additionally checks provider and application environment variables, applies the four OpenTofu stages, and configures the host with Ansible.
colors.yml, build, and dry-run first.
./green describe
Describe reads compute and SMTP outputs from their OpenTofu state, reports the selected providers, probes the server over SSH, and runs sudo -n once list when the host is reachable. For each application it compares the running image digest against the registry with skopeo and reports whether an update is available. Nothing it does changes infrastructure.
Compute is reported as one of three states:
| Status | Meaning |
|---|---|
running | OpenTofu holds compute outputs and SSH succeeded. |
unreachable | The server exists in state, but SSH failed — it may be booting, firewalled, or down. The address is shown so you can investigate it. |
absent | The tofu-compute stage has no outputs to read, so the server was never created — or its state is not reachable from here. Run ./green create. |
Under no-infra the host is yours rather than OpenTofu's, so it is never reported as absent; without a configured address it reports unreachable (no host configured).
Describe exits non-zero unless compute is running, and also when the remote once command is missing. Every other live check is a soft failure named in the report.
For a remote backend, make sure its credentials are present so OpenTofu outputs can be read. The compute private key must be available to SSH.
./green delete --dry-run
COLORS_PAR_COMPUTE_PREVENT_DESTROY=false ./green delete
Dry-run prints teardown order and changes nothing. A real delete is refused while compute-prevent-destroy is true, which is the required default. Deliberately override it only for the exact delete you have approved.
colors.yml.
Use -f or --file with every command:
./green build -f ./production.edn
./green create --file ./production.edn --dry-run
./green describe -f ./production.edn
Use distinct profiles for independent stacks. Use the same profile and .colors workdir when changing only the implementation color.
colors.yml free of secrets and commit it for review.<workdir>/ and private environment files ignored by Git.ssh-agent; never copy it into the project../green build../green create --dry-run../green create../green describe after provisioning.For production, prefer S3 or R2 state over local state and protect the bucket with appropriate access controls and retention.
The remote server gets a restricted deploy account intended for CI or controlled update jobs. A deploy is a ping: the client sends no command at all, and the forced command updates the hosts named in that key's own authorized_keys entry. Anything the client does send is ignored — restrict plus the forced command mean it was never able to select anything.
ssh -T deploy@SERVER_IP < /dev/null
Each repository gets its own key, so a key issued for one repository cannot redeploy another's application, and one repository serving several hosts updates all of them from a single ping. The exit status is the deploy result for every host that key owns. Keys are ephemeral — regenerated on every create and never stored — and the previous generation stays authorized until the new one has been published, so a failed publication leaves deploys working. You can also set auto_update true on an application when ONCE should handle updates automatically.
Running ./green create again is appropriate when infrastructure or declared application membership changes: the remote module reconciles by hostname, deploying hosts that are missing and removing hosts no longer present in desired state.
image, env, or resource settings of an application that is already deployed does not redeploy it — the host is still present, so there is nothing to reconcile. Pull a new image by pinging the server from that repository's workflow, or change the settings on the server itself.
Publishing credentials and consuming them are two separate things. create writes four values into a GitHub Actions environment named after the profile, and nothing reads them until a workflow in that repository does. This is that workflow.
| Name | Kind | Value |
|---|---|---|
SSH_PRIVATE_KEY | secret | The deploy key's private half, regenerated on every create and never stored. |
SERVER_IP | variable | The server address. |
SERVER_USER | variable | Always deploy. |
SSH_KNOWN_HOSTS | variable | The server's own host key, read over an authenticated session rather than scanned. |
environment: cannot see them at all. The values resolve to empty rather than erroring, so a workflow missing that block fails deep inside ssh with a useless message. Nothing is published at repository scope.
The address and user are variables rather than secrets deliberately: DNS reveals the address anyway, and masking them only makes CI logs harder to read. The deploy step itself sends no command — the key's forced command already names every host that repository serves — so no hostname appears in the workflow, and adding a host to colors.yml needs no change to it.
The server pulls the tag named in the application's image. With ghcr.io/example/site:latest, the ping only means something once :latest points at the new build, which is why the build and manifest jobs come first.
name: Build and Publish Docker Image
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
# Each architecture pushes by digest under no tag at all. Mutable :arm and
# :amd tags would be shared across runs, so two pushes landing close together
# could interleave and leave :latest with its arm64 half from one commit and
# its amd64 half from another — undetected, and :latest is what the server
# pulls.
build:
strategy:
fail-fast: false
matrix:
include:
- arch: arm
platform: linux/arm64
runner: ubuntu-24.04-arm
- arch: amd
platform: linux/amd64
runner: ubuntu-24.04
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
provenance: false
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
- name: Export digest
run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# One imagetools call builds both tags from the digests just pushed, so the
# two halves of a manifest always come from the same run. It also reads each
# child's platform itself, which is why there are no manual
# `docker manifest annotate --os/--arch` pairs here.
manifest:
runs-on: ubuntu-24.04-arm
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute short sha
id: sha
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Create and push the manifest list
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
--tag ghcr.io/${{ github.repository }}:latest \
--tag ghcr.io/${{ github.repository }}:sha-${{ steps.sha.outputs.short }} \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Show what was published
run: docker buildx imagetools inspect ghcr.io/${{ github.repository }}:latest
deploy:
runs-on: ubuntu-24.04-arm
needs:
- manifest
# The environment ONCE publishes into, named after the colors.yml profile.
# Without this the job cannot see the environment-scoped secret or
# variables at all, and every value below resolves to empty.
environment:
name: PROFILE
url: https://www.example.com
# Serialize rather than cancel: interrupting a deploy midway is worse than
# making the next one wait.
concurrency:
group: deploy-PROFILE
cancel-in-progress: false
steps:
- name: Deploy via SSH
env:
# Only the key is a secret. The address and user are variables, so CI
# logs stay readable — DNS reveals the address anyway.
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_IP: ${{ vars.SERVER_IP }}
SERVER_USER: ${{ vars.SERVER_USER }}
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
run: |
# Create the .ssh directory and start the agent
mkdir -p ~/.ssh
eval $(ssh-agent -s)
# Add the private key to the agent
echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
# Pinned by ONCE at provisioning time, not scanned here. Fail closed:
# falling back to ssh-keyscan would trust whatever answers on that
# address, which is the weakness pinning exists to remove.
if [ -z "$SSH_KNOWN_HOSTS" ]; then
echo "SSH_KNOWN_HOSTS is unset - run 'once create' to publish it" >&2
exit 1
fi
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
# A ping, not a command. This key's authorized_keys entry already
# names every host this repository serves, and its forced command
# updates all of them — so there is no hostname to duplicate here,
# and adding a host to colors.yml needs no change to this file.
#
# Connect to SERVER_IP: ONCE keys the pinned SSH_KNOWN_HOSTS entry on
# the address, so a hostname here would find no match under
# StrictHostKeyChecking=yes.
#
# The exit status is the deploy result for every host this key owns.
ssh -T -o StrictHostKeyChecking=yes "$SERVER_USER@$SERVER_IP" < /dev/null
PROFILE in both places with the profile from colors.yml. Any other name resolves an environment with no variables in it.environment.url is cosmetic — the application's host, so the deployment shows a link in the GitHub UI.image. ghcr.io/${{ github.repository }} expands to ghcr.io/owner/repo, so it matches only when desired state names that.permissions: packages: write is what lets GITHUB_TOKEN push to ghcr.io. Without it the run fails at the push rather than at login.manifest job, and give build-push-action tags: and push: true instead of outputs:.Dockerfile at context: .. This workflow builds the image the application runs; it does not create one.github: owner/repo — which is frequently not the repository holding colors.yml.
Run create before the first workflow run. ONCE creates the environment itself, so nothing has to exist in GitHub beforehand; until then it is empty, and the deploy job stops on the SSH_KNOWN_HOSTS check rather than trusting an unverified host. COLORS_PAR_GITHUB_TOKEN must be set for create to publish, and for delete, which withdraws all four values again.
Three things were renamed, and none of them is picked up automatically. A project created before this change must be migrated by hand.
.colors/. On the local state backend — the default when provider-backend is unset — your OpenTofu state lives inside it. If you run a command before moving it, OpenTofu finds no state, reports that everything is missing, and a create will happily build a second server, a second sending domain, and a second set of DNS records alongside the ones you already have. Run mv .once .colors first. Projects on the s3 or r2 backend keep their state remotely under <profile>/<tool>.tfstate and are not affected.mv .once .colors, and update the ignore rule to .colors/.colors.yml. Green projects must also convert from EDN to YAML: drop the leading colons from keys, unwrap the outer braces, and quote nothing that was already a plain string. Red and Blue projects only need the filename changed.workdir: .colors inside that file.COLORS_PAR_* namespace. The old GREEN_PAR_*, RED_PAR_*, BLUE_PAR_*, and ONCE_PAR_* names are no longer read at all — a stale one is silently ignored, and the run stops with required credential is not set.After migrating, ./<color> build should regenerate an identical tree, and ./<color> describe should report the same running server as before. Confirm both before attempting a create or delete.
npx skills cannot find a packageUse a full skill name: package-once-green, package-once-red, or package-once-blue. The former green-once name was intentionally removed.
Add the missing non-secret field for the selected provider. Remove unused providers' fields rather than filling them with credentials or passwords.
Set the named variable through your private environment loader or secret manager. Check presence only; do not print the value. Application variable names come from each application's env map.
env must map container names...The error fires when env is neither a map nor a list — a bare string or number, say. Use the map form:
env:
DATABASE_URL: site-database-url
env is accepted, and written out literally
A vector of "KEY=value" strings passes validation and is copied verbatim into the rendered Ansible file. That is fine for a non-secret value such as ["TARGET_EMAIL=forms@example.com"], but it is the one form that puts its values on disk. Anything secret belongs in the map form, which renders an environment lookup instead.
This is expected. First run ./green delete --dry-run. After explicit approval, set COLORS_PAR_COMPUTE_PREVENT_DESTROY=false only for the real delete command.
./green describe.ssh-add -l and confirm the compute private key is loaded.Use the same profile, workdir, backend selection, and backend credentials used during create. Do not delete the local work directory when using local state.
Confirm the DNS provider received all Resend records and allow DNS propagation. Rerun create after the records are visible; OpenTofu will continue from existing state.
Every domain derived from the application hostnames must already belong to a zone accessible by COLORS_PAR_CLOUDFLARE_API_TOKEN. The token needs permission to read and edit DNS records in every zone.
Once creates the public zone, but serving traffic requires delegation: set the domain's NS records at its registrar to ns1.yandexcloud.net and ns2.yandexcloud.net, then allow propagation. Resend verification also waits on this delegation.
Verify direct SSH access with no-infra-compute-user, passwordless privilege escalation for no-infra-compute-sudoer, a supported Linux host, and outbound network access for installers.
Green syntax is shown; Red and Blue use the same keys in YAML. This contains only public or non-secret configuration. Replace illustrative identifiers and provide credentials exclusively through environment variables.
profile: production
workdir: .colors
once:
applications:
- host: www.example.com
image: ghcr.io/example/site:latest
github: acme/site
env:
DATABASE_URL: site-database-url
SECRET_KEY_BASE: site-secret-key-base
auto_update: true
auto_backup: true
- host: www.example.net
image: ghcr.io/example/another-site:latest
provider-compute: digitalocean
provider-smtp: resend
provider-dns: cloudflare
provider-backend: r2
compute-prevent-destroy: true
digitalocean-name: once-production
digitalocean-region: ams3
digitalocean-size: s-1vcpu-1gb-35gb-intel
digitalocean-image: ubuntu-24-04-x64
digitalocean-ssh-keys: fingerprint-or-id-already-in-the-account
r2-bucket: once-production-tfstate
r2-endpoint: https://ACCOUNT_ID.r2.cloudflarestorage.com
| Key | Required | Description |
|---|---|---|
profile | Yes | Names generated output, remote state keys, the compute resource, and the SSH host alias. |
workdir | Yes | Shared generated root directory; recommended value is .colors. |
compute-pubkey | Yandex only | Administrative SSH public key installed for Yandex's ubuntu user. |
once | Yes | Map containing a non-empty applications vector. Each application takes host, image, an optional github repository, and an optional env map. |
provider-compute | Yes | azure, aws, google, digitalocean, hcloud, yandex, oci, or no-infra. |
provider-smtp | Yes | resend or no-infra. |
provider-dns | Yes | cloudflare, yandex, or no-infra. |
provider-backend | Yes | local, s3, or r2. |
compute-prevent-destroy | Yes | Boolean destruction guard; keep true in committed state. |
| Provider | Keys |
|---|---|
| Azure | azure-subscription-id, azure-location, azure-resource-group, azure-name, azure-vm-size, azure-image-publisher, azure-image-offer, azure-image-sku, azure-image-version, azure-vnet-cidr, azure-subnet-cidr, azure-boot-disk-size-gb, azure-ssh-authorized-keys |
| AWS | aws-region, aws-availability-zone, aws-name, aws-instance-type, aws-image-id, aws-vpc-cidr, aws-subnet-cidr, aws-root-volume-size-gb, aws-ssh-authorized-keys |
google-project, google-region, google-zone, google-name, google-machine-type, google-image-project, google-image-family, google-image-id, google-subnet-cidr, google-boot-disk-size-gb, google-ssh-authorized-keys | |
| DigitalOcean | digitalocean-name, digitalocean-region, digitalocean-size, digitalocean-image, digitalocean-ssh-keys; optional digitalocean-vpc-uuid |
| Hetzner | hcloud-name, hcloud-image, hcloud-server-type, hcloud-location, hcloud-ssh-keys |
| Yandex | compute-pubkey, yandex-cloud-id, yandex-folder-id, yandex-zone, yandex-image-family, yandex-name, yandex-subnet-cidr, yandex-platform-id, yandex-cores, yandex-memory-gb, yandex-core-fraction, yandex-disk-size-gb |
| OCI | oci-config-file-profile, oci-subnet-id, oci-compartment-id, oci-availability-domain, oci-display-name, oci-shape, oci-ocpus, oci-memory-in-gbs, oci-boot-volume-size-in-gbs, oci-boot-volume-vpus-per-gb, oci-ssh-authorized-keys |
| Existing compute | no-infra-compute-ip, no-infra-compute-user, no-infra-compute-sudoer, no-infra-compute-uid |
| Resend | None — the relay is hard-coded |
| Existing SMTP | no-infra-smtp-server, no-infra-smtp-port, no-infra-smtp-username |
| Yandex DNS | yandex-cloud-id, yandex-folder-id |
| S3 backend | s3-bucket, s3-region |
| R2 backend | r2-bucket, r2-endpoint |
once/
├── green/ # Clojure/Babashka package
├── red/ # TypeScript/Bun package
├── blue/ # Python/uv package
├── skills/
│ ├── package-once-green/
│ ├── package-once-red/
│ └── package-once-blue/
├── scripts/parity.sh # byte-parity gate
├── README.md
└── index.html # this unified manual
An initialized project needs the selected launcher and one colors.yml plus a precise ignore rule for .colors/. Because every colour reads that same file, migrating between implementations changes the command and nothing else.
Run every implementation suite and the cross-language parity gate:
cd green && clojure -M:test
cd red && bun test && bun run typecheck
cd blue && uv run python -m pytest -q
./scripts/parity.sh
The parity gate compares complete build trees and all separately packaged resources. Generated work directories are artifacts and must not be edited as source.
Copyright © 2026 Alberto Miorin. Distributed under the MIT License.