environments.yaml could say where things deploy to, and how a project you have already named is placed once it is there. It could not say which projects exist. "Every project" was a thing the operator remembered — so fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27) was an assumption, since you cannot restore what you cannot enumerate. A new optional top-level block, keyed by the full <org>/<repo> slug: projects: heavy-duty/incubator: environments: [prod, staging] The key IS the repo — no `repo:` field, because a second place to write the same string is a second place for it to be wrong. No bare-<repo> fallback, unlike github_apps and environments.<env>.projects: those carry one because state files in the wild are keyed that way, and this block has none to support. A bare <repo> is unique only within an org, which is why it is not a key (#12, twice learned). Validated in loadBindings, so every verb refuses a registry that lies: - an environment no `environments:` block defines is an error — the project would be registered into an environment no command can visit - every environments.<env>.projects.<slug> binding must be registered for that env, or the two blocks describe two different fleets: a destination or smoke_target real enough for a direct apply, invisible to every fleet run. Only enforced when `projects:` is present, so pre-registry state files keep loading unchanged. Both defend one failure: a silently skipped project reads exactly like a clean one. Errors render multi-line now — zod's own .message is the issue array as JSON, which flattened the refusals into a line of \n escapes. projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no registry. The --all flag that consumes it is #26's, not here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| .github/workflows | ||
| bin | ||
| docs | ||
| reference | ||
| scripts | ||
| src | ||
| test | ||
| .gitignore | ||
| biome.jsonc | ||
| install.sh | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
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, guards — and, per project,
# the destination it deploys onto + its smoke target
# …plus `projects:`, the registry: which projects
# exist, and in which environments
secrets/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
.coolify/<name>.env # …the same, for a NAMED instance (see below)
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 capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env>
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke [<org>/<repo>] --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--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.inventory— what is actually on a box. With no repo it sweeps the instance (every project, every environment, every resource — no manifest involved); with a repo it reconciles, showing resources and env var keys (never values) sorted into on-both / manifest-only / box-only. Needs no store, no age key, and no recipient — it runs before adoption, which is the point of it. A document, read by a person; nothing here is consumed byapply. See Adopting a hand-built instance.capture— the adoption path: reads a hand-built instance's live env and writes the environment's age store from it. See Adopting a hand-built instance below.server add— uploads a server's private key and registers it with Coolify.smoke— contract test against the project'ssmoke_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. Pass the repo whose target you mean; without one, only the deprecated state-file-scopedsmoke_targetcan answer.team— prints the team the configured token acts as. With--env, also checks it against that environment'steam:binding and exits non-zero on a mismatch — the dry run for "wouldapplyrefuse?", 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.
Cloning: cast authenticates, and never prompts
apply, diff and capture clone the product repo (unless --path points at a
local checkout — refused for prod, which always reads the default branch). For a
private repo that needs credentials, and cast resolves them itself:
gh, borrowed as a credential helper for that one invocation — it does not touch your global git config.GITHUB_TOKEN/GH_TOKENfrom the environment (the CI path).- Whatever git's own credential helper does, if you have one.
Being logged into gh is enough. You do not need gh auth setup-git —
that separate act is what wires git's helper, and not running it is exactly how
you end up at git's interactive username/password prompt, which GitHub no longer
accepts. cast sets GIT_TERMINAL_PROMPT=0 on every path, so it can never hang
there or hide a credentials failure behind an error about the repository. With
no credentials at all it says so, and names the fix.
The token is never put in the clone URL or in http.extraheader — both leak it
into ps, and the latter persists it into the clone's git config.
Many Coolifys
--instance <name> reads <state>/.coolify/<name>.env instead of
<state>/.coolify.env. Every verb that reaches Coolify takes it.
cast diff heavy-duty/incubator --env prod --full --instance legacy
An environment can bind one, so --env selects the right control plane with no
flag at all:
environments:
prod:
server: prod-box
team: { id: 1, name: heavy-duty }
instance: prod-cp # → <state>/.coolify/prod-cp.env
An explicit --instance still wins, so a one-off read against a legacy box needs
no edit to that file either. With no flag and no binding, nothing changes —
.coolify.env is read exactly as before.
Two properties, both deliberate:
- An unknown
--instancerefuses, and names the instances that do exist. Falling back to the default is how a diff meant for a legacy box gets run against production. - An instance may declare
COOLIFY_READ_ONLY=true, and thenapply,smokeandserver addrefuse it — before their first call, and even though the token itself would permit the writes. That turns "I pointed the wrong token at the wrong box" from a live incident into an exit code.
Every command that reaches a Coolify now says which one, next to the team assert. It is the most consequential input to any run, and the least visible.
Adopting a hand-built instance
cast is otherwise scoped to the steady state: manifest → Coolify, forever. Adoption is the one way in, and it has two verbs and a fixed order:
inventory → you read it → a manifest PR → capture → apply
Look before you adopt. A box nobody declared does not use your vocabulary:
its project is called whatever someone typed, its environment is Coolify's
default (production, not prod), and its resources are named by whoever
clicked New Resource that afternoon. inventory shows you both sides at once,
so those differences arrive together, as a document — instead of one at a time,
as refusals from a verb that is already halfway through a migration.
First, sweep it — you cannot aim at coordinates you do not have yet:
cast inventory --env prod --instance legacy
sweep — instance legacy (https://coolify.example.com)
Incubator
production (empty)
staging 2 applications, 2 databases, 1 service
application Incubator Stack v2
application Incubator Landing
database Incubator Database v2
…
La Familia Site
production 1 application
application lafamilia-web
Note what that costs you to not have: Coolify auto-creates a production
environment in every project, so the obvious guess is empty and the live system
is somewhere else entirely — under a name someone typed, in a project you may
not have known was there. An environment with zero resources is far more
often the wrong coordinate than an empty one, and inventory says so rather than
quietly reporting that the manifest has five things the box lacks.
Then reconcile, against a target you now know exists:
cast inventory heavy-duty/incubator --env prod --instance legacy \
--project Incubator --environment staging \
--resource core="Incubator Stack v2"
It never reads a value, needs no store and no key, and its output is not desired state. What you do with it is decide, resource by resource and key by key, what the manifest should gain and what is cruft that must not travel — and land that as a manifest PR. Only then:
CAST_CAPTURE_ADMIN_EMAIL=me@example.com \
cast capture heavy-duty/incubator --env prod --instance legacy \
--override ADMIN_EMAIL
It reads the required secret names from the manifest's own env templates (the
${…} refs — the manifest already declares exactly this set), reads the live
values off the instance, and classifies every name:
| captured | found live, value taken |
| generated | the manifest's generated_secrets declares it provider-made → written as the literal pending-coolify-generated, never the live value |
| overridden | supplied by you, for a value that must not be carried over |
| missing | required by a template, absent live → refuses |
Then it prints a plan of names and provenance — never values — and waits for you to type the environment's name.
Before any of that, it checks that the resources the manifest names exist.
An absent resource reads back exactly like one with no env vars set: every name
it declares reports missing, and --override would then have you hand-carry
values that are sitting right there under a different name — writing a perfectly
valid store while the actual finding (the manifest and the box disagree about
what this thing is called) is never discovered. So a resource that isn't there
refuses, and names what is. inventory is how you reconcile it.
Three names that are not yours
A hand-built box names things without asking you, at three levels, and cast takes each as a coordinate to read with — never as a reason to rename anything of yours:
| flag | when |
|---|---|
--project <name> |
the project isn't named after the repo (Incubator, not incubator) |
--environment <name> |
the environment isn't named after --env (Coolify's default is production, not prod) |
--resource <manifest>=<live> |
a resource isn't named after the manifest's (core is Incubator Stack v2 over there). Repeatable |
All three are read-side only — diff, capture, inventory. They are
arguments to a one-off read, never manifest fields: a manifest that recorded a
legacy box's names would carry a dead machine's vocabulary forever. And apply
refuses --resource outright, because it creates resources under the manifest's
own names — an alias there could only mean adopt the existing one instead,
which is a different operation and would otherwise silently create a duplicate
beside the resource you were pointing at.
--env stays ours: it selects the manifest block, the environments.yaml
binding, the age key, the store path. --environment is theirs, on the wire,
and nothing else. Collapsing the two lets a box that is being deleted next week
name the environment of the box that replaces it — apply creates the
environment from that value, so it would be inherited permanently.
The mapping is not mechanical, and that is the whole design. A DATABASE_URL
copied off the source box points at the source box's Postgres: confidently
wrong, entirely plausible, and the target's real URL does not exist until Coolify
creates the resource. So the manifest declares those names, and cast placeholds
them:
environments:
prod:
generated_secrets: [DATABASE_URL_PROD, REDIS_URL_PROD, UMAMI_DATABASE_URL]
It is a manifest property rather than a flag you have to remember, because the
manifest is what knows DATABASE_URL comes from a database it declares. (A
generated_secrets entry no template refers to is a schema error — a guard
standing over nothing is worse than no guard, because it reads like one.
--generated <NAME> covers a manifest that hasn't declared them yet.)
An --override's value is read from $CAST_CAPTURE_<NAME>, never from the
command line: argv is visible in ps to every process on the box. It exists for
values that must not survive the copy — staging and prod sharing a Mailgun
domain means a staging box carrying the real ADMIN_EMAIL can mail real users.
The store is encrypted to the environment's age_recipient (add it to
environments.yaml — it's the public half, safe to commit). Plaintext goes to
age on stdin: it is never a temp file, never on stdout, never in your shell
history. An existing store is not overwritten without --force.
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:
$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.
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.
The registry: which projects exist
environments.yaml says where things deploy to, and how a project you have
already named is placed once it is there. Until the projects: block, nothing in
it said which projects exist at all — "every project" was a thing the
operator remembered:
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
Keyed by the full <org>/<repo> slug, and the key is the repo — there is
no repo: field inside, because a second place to write the same string is a
second place for it to be wrong. Unlike github_apps, a bare <repo> key is
refused rather than resolved: this block is new, so it has no state files in
the wild to keep working, and a bare <repo> is unique only within an org —
which is exactly why it is not a key. environments: lists our environment
names (the values --env takes), never Coolify's.
The block is optional; a state file written before it loads unchanged.
It has to be true, so cast checks that it is — at parse time, for every verb. Two ways it could quietly stop being true, both refused:
- an environment name that no
environments:block defines (a typo). The project is real and its environment imaginary, so a fleet run visits nothing for it, reports nothing, and exits clean. - an
environments.<env>.projects.<repo>binding — a destination, a smoke target — in an environment the registry does not register that project for. The two blocks then describe two different fleets: state real enough for a directcast applyto use, invisible to every fleet run. (Checked only whenprojects:is present.)
Both refusals defend one failure: a silently skipped project reads exactly like a clean one. Silence is the one report that must never be ambiguous.
What it unlocks, neither of which was possible without a list to iterate:
- fleet operations —
cast diff --all/apply --allover every project in an environment. - rebuild-from-state — "restore this Coolify from the state repo" cannot even be attempted without knowing what was on it. The registry is the difference between a documented recovery and an archaeology exercise.
Two projects, one box: destinations
A destination is the Docker network a resource is created on. A server has a default one, and while a server hosts a single project that default is the right answer — which is why cast went so long without naming it.
The moment a server hosts two projects, it stops being: they share one network,
and "isolated" becomes a thing you believe rather than a thing that is true. So
the destination is declared per project, inside the environment — an
environment-scoped key could not express it, because server: is precisely the
thing two projects share:
environments:
prod:
server: shared-box
team: { id: 1, name: heavy-duty }
projects:
heavy-duty/incubator:
destination_uuid: <uuid> # the network THIS project's resources go on
smoke_target: core # the app `cast smoke` writes its canary to
acme/client-site:
destination_uuid: <other>
Keyed by repo, full <org>/<repo> slug first, exactly like github_apps — a
bare <repo> key still resolves, so existing state files keep working. Both
fields are optional, and an environment whose server hosts one project needs
neither.
A UUID and not a name, unlike server: right above it. Coolify 4.1.2 has no
destinations API whatsoever — no list, no read, nothing — so there is no name for
cast to resolve. You read the UUID out of the Coolify UI, the same way you do for
s3_destination.
What cast can and cannot promise here. It sends destination_uuid on create,
for applications, databases and services alike. It can never check it afterwards:
Coolify takes a UUID on write and hands back an integer destination_id on read,
and nothing maps between them. So diff does the one honest thing left — it
groups the live resources by the id Coolify does report, and a project whose
resources do not all share one network is drift:
split placement: these resources sit on 2 different destinations
destination 1: application landing, database postgres
destination 4: application core
a project's resources must share one destination — that is what the isolation IS.
apply never moves a live resource between networks: resolve manually (runbook act).
…and when you declare a destination, every diff says, out loud, that it did not
verify it. That is deliberate. A setting that reads back as absent rather than
wrong is the failure this whole file keeps trying not to be.
One sharp edge worth knowing: on a server with exactly one destination,
Coolify ignores the destination_uuid you send and never validates it — a typo
there is invisible until a second destination exists. On a server with more than
one, a create that omits it is a hard 400, which is why cast could not deploy
onto a shared box at all until it could send this. Details, with citations:
reference/README.md.
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