feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
# cast
|
2026-07-11 12:15:25 +00:00
|
|
|
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
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](https://github.com/heavy-duty/rig) and
|
|
|
|
|
[claudebox](https://github.com/heavy-duty/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
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/cast/main/install.sh | bash
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Needs `node` >= 22.12 and [`age`](https://github.com/FiloSottile/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.
|
|
|
|
|
|
2026-07-11 15:12:14 +00:00
|
|
|
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.
|
|
|
|
|
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
## 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
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
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 `--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.
|
|
|
|
|
|
|
|
|
|
`--hostname-overlay` swaps domains for a pre-flight run against temporary
|
|
|
|
|
hostnames; re-applying **without** it is the cutover.
|
|
|
|
|
|
2026-07-11 13:13:19 +00:00
|
|
|
**[docs/semantics.md](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`.
|
|
|
|
|
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
## 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.
|
|
|
|
|
|
|
|
|
|
## Guarding an environment
|
|
|
|
|
|
|
|
|
|
An environment may refuse variables by name pattern:
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
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
|
chore: re-home the control-plane dump to rig
scripts/dump-coolify-db.sh ran `docker exec coolify-db pg_dump` — it only works
ON the control-plane box, as root, under a scheduler. cast is the off-box tool:
its own README says "cast runs on your machine: it is an API client, and a
server should never install it." The script contradicted that invariant, and
cast's src/ never referenced it — it was payload that survived the rig/cast
split without being re-homed.
It now lives in rig as `rig coolify backup install` (heavy-duty/rig#9), which
also installs age + awscli, a systemd timer, and a templated 0600 bindings file
— none of which cast could do from off the box.
Two defects were fixed in the move rather than carried over: the script now
refuses to upload an empty artifact (a failed pg_dump piped into age still
yields a valid, tiny, encrypted file that looks exactly like a working backup),
and the unit defaults the aws-cli >= 2.23 checksum knobs that S3-compatible
backends reject.
The two scripts that remain are genuinely cast's: both drive the Coolify API or
reach a box over SSH, from off the box. README now states that boundary so the
next on-box script does not land here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:18:46 +00:00
|
|
|
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](https://github.com/heavy-duty/rig)'s
|
|
|
|
|
job, not cast's — including the nightly age-encrypted dump of the control-plane
|
|
|
|
|
database, which is now `rig coolify backup install`.
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
|
|
|
|
|
## Development
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
npm ci && npm run build && npm test
|
|
|
|
|
npm run check # biome
|
|
|
|
|
```
|