Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.6 KiB
cast
Point it at a repo and a state directory; it makes a Coolify instance match what the repo declares. One-way, idempotent, never deletes.
Philosophy (shared with rig and claudebox): public tool, private state. cast holds no hostnames, no bindings, no secrets, nothing about your infrastructure. It reads what you point it at and stores nothing, ever.
rig builds the boxes. cast fills them.
Install
curl -fsSL https://raw.githubusercontent.com/heavy-duty/cast/main/install.sh | bash
Needs node >= 22.12 and age (secrets
are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
which is pure bash so it can run on a bare box — cast runs on your machine:
it is an API client, and a server should never install it.
The two inputs
cast joins a manifest (what to deploy) with state (where, and with what values). Neither knows about the other, which is the whole point: a manifest can live in a product repo without leaking your infrastructure, and your infrastructure can be re-pointed at a new Coolify without touching a product.
1. The product repo's .infra/ — committed, instance-blind:
.infra/
manifest.yaml # applications, databases, services, per environment
env/<app>.<env>.env.template # var NAMES + non-secret values; ${SECRET} placeholders
2. A state directory — private, yours:
environments.yaml # bindings: which server each env deploys onto, the S3
# destination, GitHub App name, smoke target, guards
secrets/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
Pass it with --state <dir>, or set CAST_STATE. Defaults to the cwd.
Commands
cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full]
cast server add <name> --ip <ip> --key <file> [--user root] [--port 22]
cast smoke
apply— idempotent create-or-update of every manifest resource, then redeploy what changed. One-way: it never deletes a resource that Coolify has and the manifest doesn't. Clones the repo's default branch unless--pathpoints at a local checkout (refused with--env prod— prod always reads the default branch).diff— reports drift, manifest → Coolify. Structural by default;--fullalso compares env vars. Exits non-zero when dirty, so CI can gate on it.server add— uploads a server's private key and registers it with Coolify.smoke— contract test againstsmoke_target: proves Coolify's bulk env endpoint still upserts rather than replacing. Run it after every Coolify upgrade —apply's never-delete guarantee rests on that behavior, and the published OpenAPI does not describe it accurately.
--hostname-overlay swaps domains for a pre-flight run against temporary
hostnames; re-applying without it is the cutover.
Secrets, and attended applies
An environment's age identity is resolved in exactly two ways:
$CAST_AGE_KEY_FILE_<ENV>— injected for this invocation~/.config/cast/age-<env>.key— a standing key on this machine
That is the whole mechanism behind attended vs unattended applies: an environment whose key you never leave on disk can only be applied by someone who injects it. Keep a standing key for staging if you like; keep prod's in a password manager and pass it per apply.
The state directory holds ciphertext. It must never hold the identity that opens it.
Guarding an environment
An environment may refuse variables by name pattern:
environments:
prod:
server: prod-box
forbidden_var_patterns: ["^ALLOW_"]
apply then refuses if any such var is present on any resource, regardless
of value. ALLOW_SEED=false still fails: a var that exists can be flipped on
later in the Coolify UI without touching a manifest, so "off" has to mean absent.
This guard lives in your private state deliberately — not in the product's manifest. A product-side change must not be able to lower its own guard.
Scripts
Operational helpers, all argument-driven (scripts/): register a GitHub App with
Coolify, dump the Coolify control-plane database age-encrypted to S3, restore a
database backup into a target container.
Development
npm ci && npm run build && npm test
npm run check # biome