Manifest-driven Coolify reconciler - rig builds the boxes, cast fills them
Find a file
claude-hdb e457261436 feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.

That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.

- Named instances: <state>/.coolify/<name>.env, each with its own base URL
  and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
  --env selects the right control plane with no flag and no file edit at all.
  An explicit --instance still wins, so a one-off read against a legacy box
  needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
  do exist, in the same spirit as the absent-target refusal (#12/D-237).
  Falling back to the default here is exactly how a diff meant for a legacy
  box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
  then refuse it before their first call, even though the token itself would
  permit the writes. "I pointed the wrong token at the wrong box" becomes an
  exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
  assert. It is the most consequential input and the least visible one.

With no --instance and no binding, behavior is byte-for-byte what it was.

The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
.github/workflows feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
bin feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
docs feat: assert the token's team before touching Coolify (fail-closed) 2026-07-12 20:55:04 +00:00
reference feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
scripts chore: re-home the control-plane dump to rig 2026-07-12 19:18:46 +00:00
src feat: select the Coolify instance by name instead of editing .coolify.env (#14) 2026-07-13 16:42:45 +00:00
test feat: select the Coolify instance by name instead of editing .coolify.env (#14) 2026-07-13 16:42:45 +00:00
.gitignore feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
biome.jsonc feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
install.sh fix(install): wire BINDIR onto PATH in the shell profile, not a warning 2026-07-11 15:12:14 +00:00
package-lock.json feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
package.json feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
README.md feat: assert the token's team before touching Coolify (fail-closed) 2026-07-12 20:55:04 +00:00
tsconfig.json feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00
vitest.config.ts feat: cast — the Coolify executor, extracted from the infra state repo 2026-07-11 12:25:44 +00:00

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 installer symlinks cast into ~/.local/bin (or /usr/local/bin as root) and, if that directory is not already on your PATH, appends it to your shell profile — .zshrc, .bashrc/.bash_profile, or config.fish, whichever your $SHELL reads — marked # added by cast-install and written only once. The shell you ran the installer from does not inherit it (a curl | bash pipeline is a subshell), so open a new shell or source the profile it names. Set CAST_NO_MODIFY_PATH=1 to be left alone and wire PATH yourself.

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: the team each env's token must belong to,
                                #   which server it 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> --env <env> [--user root] [--port 22]
cast smoke --env <env>
cast team [--env <env>]
  • 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 --path points at a local checkout (refused with --env prod — prod always reads the default branch).
  • diff — reports drift, manifest → Coolify. Structural by default; --full also 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 against smoke_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.
  • team — prints the team the configured token acts as. With --env, also checks it against that environment's team: binding and exits non-zero on a mismatch — the dry run for "would apply refuse?", answered without touching anything.

Every command that reaches a live Coolify takes an --env, because every one of them first asserts the token's team (below).

--hostname-overlay swaps domains for a pre-flight run against temporary hostnames; re-applying without it is the cutover.

docs/semantics.md is the contract behind those commands: what apply guarantees (never deletes, never recreates a database, fails loudly rather than recreating on un-updatable drift), the dockercompose build pack, the hostname-overlay shapes, and the places Coolify 4.1.2 does not cooperate — each citation verified against coollabsio/coolify v4.1.2 and the vendored OpenAPI in reference/. Read it before changing apply.

Secrets, and attended applies

An environment's age identity is resolved in exactly two ways:

  1. $CAST_AGE_KEY_FILE_<ENV> — injected for this invocation
  2. ~/.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.

Teams: the one assert cast makes before it touches anything

Coolify API tokens are team-scoped, and a token pointed at another team's resources does not error. The API resolves what the token cannot see to null — and to a tool like cast, null is indistinguishable from "this resource does not exist yet", which is an invitation to create it. An apply run with a wrong-team token would not fail; it would silently provision a duplicate set of resources into the wrong team, on whatever server that team owns. Silent, mutating, discovered late.

So every environment declares the team its token must belong to, and cast refuses to do anything at all until it has checked:

environments:
  prod:
    server: prod-box
    team: { id: 1, name: heavy-duty }

Give id, name, or both — both are compared when both are given. id is the true identity (names can be renamed); name is what makes the file readable. Run cast team to print the values for the token you currently have configured.

The check is fail-closed: an environment with no team: is one whose token cannot be verified, so it is a schema error, not a warning. It runs before the first read, not merely before the first write — an unasserted diff against the wrong team would report "everything is absent", which is precisely the lie that an apply would then act on.

Nothing below the team scopes a token. A Coolify environment has no team of its own (it hangs off a project) and no API path scopes by one: Coolify environments are an organizational construct, not an auth boundary. The team is the only boundary there is, so it is the one cast asserts.

Guarding an environment

An environment may refuse variables by name pattern:

environments:
  prod:
    server: prod-box
    team: { id: 1, name: heavy-duty }
    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, restore a database backup into a target container.

They run where cast runs — off the box. They drive the Coolify API, or reach a box over SSH; none of them expects to be executing on a server. Anything that belongs on a box, as root, under a scheduler is rig's job, not cast's — including the nightly age-encrypted dump of the control-plane database, which is now rig coolify backup install.

Development

npm ci && npm run build && npm test
npm run check          # biome