Green · Red · Blue · OpenTofu · Ansible

Once

Choose Clojure, TypeScript, or Python to create and operate the same production, single-server Basecamp ONCE deployment from reviewable desired state.

  • Single server
  • Agent guided
  • Dry-run first
  • MIT licensed

#Introduction

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.

The safe default Start with 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.

#Choose an implementation

Clojure / Babashka

package-once-green

Run with ./green. Reads YAML with yamlstar.

TypeScript / Bun

package-once-red

Run with ./red. Reads YAML with Bun.YAML.

Python / uv

package-once-blue

Run 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.

#Requirements

ToolNeeded forNotes
Node.js / npxLoading the skillOnly needed to run the Skills CLI.
BabashkaGreen launcher commandsRuns ./green.
BunRed launcher commandsRuns ./red.
uv and Python 3.11+Blue launcher commandsRuns ./blue from PEP 723 metadata.
OpenTofuReal create, delete, and state-backed describeManages compute, SMTP, DNS, and verification state.
AnsibleReal create and deleteConfigures the remote host and ONCE applications; delete replays the local play to drop the managed SSH block.
OpenSSHCreate and describePrivate compute key should already be available through ssh-agent.
skopeoDescribeCompares 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.

#Quick start

  1. Choose and use a skill

    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.

  2. Describe the deployment

    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.
  3. Review generated project files

    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.

  4. Run safe checks

    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
  5. Provision explicitly

    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
Approval boundary The skill must not start a real create or delete merely because initialization succeeded. Those operations require explicit user confirmation.

#Workflow architecture

Each implementation executes the same directed acyclic graph. Independent work runs concurrently; downstream steps receive outputs from their upstream branches.

Create and build

                    ┌─ tofu-compute ─┐                             ┌─ ansible-local
start + validation ─┤                ├─ tofu-dns ─ tofu-smtp-post ─┤
                    └─ tofu-smtp ────┘                             └─ ansible-remote ─ github
1 · Compute + SMTPProvisioned concurrently.
2 · DNSJoins the server IP and SMTP records.
3 · SMTP postVerifies the sending domain after DNS.
4 · Local setupWrites the managed SSH config block.
5 · Remote setupInstalls ONCE and reconciles applications.
6 · PublishPublishes the deploy credentials to each repository.

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.

Delete

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.

#Desired state

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.

Interchangeable stateUse the same 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

Non-secret overrides

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.

#Generated files

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
  • The work directory is generated output and should be ignored by Git.
  • Local OpenTofu state remains in each tool directory. S3 and R2 use remote keys of the form <profile>/<tool>.tfstate.
  • Generated Ansible files contain environment lookups, not application or SMTP secret values — provided each application's 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.
  • The 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

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

Environment indirection

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 keyPurpose
hostRequired FQDN served by the application.
imageRequired container image reference.
envMap of container variable names to the colors.yml keys holding their values.
auto_updateEnable ONCE automatic image updates.
auto_backupEnable ONCE automatic backups.
backup_pathSet the application backup path.
disable_tlsDisable ONCE-managed TLS for the application.
cpusSet the container CPU allocation.
memorySet the container memory allocation.

#Secrets and environment

Never put secret values in project files or chat Do not place API tokens, passwords, private keys, access keys, or application secrets in any desired-state file, launcher, prompt, generated example, log, or shell history. Use a private environment loader or secret manager.
SelectionRequired environment variablesWhen
AzureNone — uses the native Azure CLI session
AWSNone — uses the native AWS credential chain
Google CloudNone — uses Application Default Credentials
DigitalOceanCOLORS_PAR_DO_TOKEN or COLORS_PAR_DO_TOKENCreate and delete
Hetzner CloudCOLORS_PAR_HCLOUD_TOKENCreate and delete
Yandex CloudCOLORS_PAR_YANDEX_TOKENCreate and delete
Oracle CloudNone — authenticates through the configured OCI profile
ResendCOLORS_PAR_RESEND_API_KEY and COLORS_PAR_RESEND_PASSWORDCreate and delete
Existing SMTPCOLORS_PAR_NO_INFRA_SMTP_PASSWORDCreate and delete
Cloudflare DNSCOLORS_PAR_CLOUDFLARE_API_TOKENCreate and delete
R2 backendCOLORS_PAR_R2_ACCESS_KEY_ID and COLORS_PAR_R2_SECRET_ACCESS_KEYCreate and delete
S3 backendNone — OpenTofu resolves its AWS credential chainCreate and delete
Application environmentOne COLORS_PAR_* variable per mapped keyCreate

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.

Check presence without printing values

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.

#Compute providers

Azure

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.

AWS

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.

Google Cloud

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.

DigitalOcean

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.

Hetzner Cloud

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.

Yandex Cloud

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.

Oracle Cloud Infrastructure

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.

Changing 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.

Existing server

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.

#SMTP providers

Resend

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.

Existing SMTP

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.

#DNS providers

Managed

cloudflare

Finds 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

yandex

Creates 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-infra

Creates 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"

#State backends

Backend advice writes a backend configuration before every OpenTofu stage. State is isolated by both profile and tool.

Local

provider-backend: local

Best for experiments. Protect and back up the work directory; losing local state makes safe lifecycle management difficult.

Amazon S3

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.

Cloudflare R2

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.

#SSH configuration

Operations

compute-pubkey

Required 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

github

Optional, 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

#Command overview

Usage: ./<color> <build|create|delete|describe> [-f|--file <color-state>] [--dry-run]
Colors: green / red / blue
CommandEffectExternal changes
./COLOR buildValidate and render all stage files.Writes only the local work directory.
./COLOR create --dry-runValidate and print the create DAG.None.
./COLOR createProvision infrastructure and reconcile the server.Yes.
./COLOR describeRead state, probe SSH, and list ONCE applications.Read-only.
./COLOR delete --dry-runValidate and print the delete DAG.None.
./COLOR deleteDestroy 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.

#Build

./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.

#Create and dry-run

./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.

Real infrastructure operation A real create can incur cloud costs, create public DNS records, and deploy internet-facing applications. Review colors.yml, build, and dry-run first.

#Describe

./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:

StatusMeaning
runningOpenTofu holds compute outputs and SSH succeeded.
unreachableThe server exists in state, but SSH failed — it may be booting, firewalled, or down. The address is shown so you can investigate it.
absentThe 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.

#Delete and dry-run

./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.

Destructive operation Delete removes managed DNS, SMTP, and compute resources. Verify backups and state access first. The protection override should be temporary rather than committed to colors.yml.

#Using another desired-state file

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.

#Safe lifecycle

  1. Keep colors.yml free of secrets and commit it for review.
  2. Keep <workdir>/ and private environment files ignored by Git.
  3. Load the compute private key into ssh-agent; never copy it into the project.
  4. Load required credentials through a private environment or secret manager.
  5. Run ./green build.
  6. Run ./green create --dry-run.
  7. Explicitly approve and run ./green create.
  8. Run ./green describe after provisioning.
  9. Back up application data and OpenTofu state before teardown.
  10. Dry-run and explicitly approve any real delete.

For production, prefer S3 or R2 state over local state and protect the bucket with appropriate access controls and retention.

#Application updates

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.

Reconciliation is by hostname Changing the 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.

#GitHub Actions deployment

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.

NameKindValue
SSH_PRIVATE_KEYsecretThe deploy key's private half, regenerated on every create and never stored.
SERVER_IPvariableThe server address.
SERVER_USERvariableAlways deploy.
SSH_KNOWN_HOSTSvariableThe server's own host key, read over an authenticated session rather than scanned.
All four are environment-scoped A job that does not declare 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

Adapting it

  • Replace 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.
  • The image the manifest publishes must be exactly the application's 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.
  • For one architecture only, drop the unwanted matrix entry and the whole manifest job, and give build-push-action tags: and push: true instead of outputs:.
  • The repository needs its own Dockerfile at context: .. This workflow builds the image the application runs; it does not create one.
Where the file goes The workflow belongs in the application's repository — the one named by 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.

#Upgrading an existing project

Three things were renamed, and none of them is picked up automatically. A project created before this change must be migrated by hand.

Move the work directory before running anythingThe work directory is now .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.
  1. Rename the work directory: mv .once .colors, and update the ignore rule to .colors/.
  2. Rename desired state to 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.
  3. Set workdir: .colors inside that file.
  4. Rename every credential variable to the 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.
  5. Re-run the skill installation so the launcher is replaced. An old launcher refuses to run against the new library rather than rendering from a stale contract.

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.

#Troubleshooting

npx skills cannot find a package

Use a full skill name: package-once-green, package-once-red, or package-once-blue. The former green-once name was intentionally removed.

A desired-state key is required

Add the missing non-secret field for the selected provider. Remove unused providers' fields rather than filling them with credentials or passwords.

A required environment variable is not set

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
A list 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.

Compute destruction is protected

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.

SSH is not reachable

  • Check the address reported by ./green describe.
  • Run ssh-add -l and confirm the compute private key is loaded.
  • Check provider firewalls and the configured existing-server user.
  • For a fresh cloud server, allow startup and SSH initialization to finish.

OpenTofu output or state is missing

Use the same profile, workdir, backend selection, and backend credentials used during create. Do not delete the local work directory when using local state.

Resend verification is delayed

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.

Cloudflare cannot find the zone

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.

A Yandex zone exists but the domain does not resolve

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.

An existing server fails during create

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.

#Complete desired-state example

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

#Parameter reference

Base

KeyRequiredDescription
profileYesNames generated output, remote state keys, the compute resource, and the SSH host alias.
workdirYesShared generated root directory; recommended value is .colors.
compute-pubkeyYandex onlyAdministrative SSH public key installed for Yandex's ubuntu user.
onceYesMap containing a non-empty applications vector. Each application takes host, image, an optional github repository, and an optional env map.
provider-computeYesazure, aws, google, digitalocean, hcloud, yandex, oci, or no-infra.
provider-smtpYesresend or no-infra.
provider-dnsYescloudflare, yandex, or no-infra.
provider-backendYeslocal, s3, or r2.
compute-prevent-destroyYesBoolean destruction guard; keep true in committed state.

Provider-specific non-secret keys

ProviderKeys
Azureazure-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
AWSaws-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
Googlegoogle-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
DigitalOceandigitalocean-name, digitalocean-region, digitalocean-size, digitalocean-image, digitalocean-ssh-keys; optional digitalocean-vpc-uuid
Hetznerhcloud-name, hcloud-image, hcloud-server-type, hcloud-location, hcloud-ssh-keys
Yandexcompute-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
OCIoci-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 computeno-infra-compute-ip, no-infra-compute-user, no-infra-compute-sudoer, no-infra-compute-uid
ResendNone — the relay is hard-coded
Existing SMTPno-infra-smtp-server, no-infra-smtp-port, no-infra-smtp-username
Yandex DNSyandex-cloud-id, yandex-folder-id
S3 backends3-bucket, s3-region
R2 backendr2-bucket, r2-endpoint

#Repository layout

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.

#Development and testing

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.

#License

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