2026-07-11 13:13:19 +00:00
# Behavior
The guarantees `cast apply` makes, the shapes it accepts, and the places
Coolify 4.1.2 does not cooperate. Extracted from the substrate repo this tool
was born in; the Coolify-source citations were verified against
`coollabsio/coolify` v4.1.2 and the vendored OpenAPI in `reference/` .
The command surface itself is in the README; this file is the behavior behind it.
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
## Team scoping
**A Coolify API token is scoped to exactly one team, and nothing below the team
scopes it.** `User::createToken` overrides Sanctum's and stamps the session's
team onto the token (`'team_id' => session('currentTeam')->id`); the API then
resolves every request through it — `getResourceByUuid($uuid,
getTeamIdFromToken())`, which walks `resource → environment → project →
team_id`.
The consequence that matters: **a wrong-team token does not error.**
`getResourceByUuid` returns `null` on a team mismatch, and `null` is
indistinguishable from *"this resource does not exist yet"* — which, to `apply` ,
is an invitation to **create** it. An apply run with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns. That is
why the team assert is a correctness guarantee and not a hardening nicety, and
why it is **fail-closed** :
- Every environment in `environments.yaml` **must** declare `team:` (`id`,
`name` , or both). A missing team is a schema error — an environment whose
token cannot be verified is exactly the failure the binding exists to prevent.
- Every command that reaches a live Coolify (`apply`, `diff` , `server add` ,
`smoke` ) resolves `GET /teams/current` — the only endpoint that answers *"what
team does this token act as?"*, resolved from the token itself
(`TeamController@current_team` → `getTeamIdFromToken()` ) — and compares it to
the binding **before its first read** , aborting on mismatch. Before the first
*read* , not merely the first write: a wrong-team `diff` reports "everything is
absent", which is the very lie an `apply` would then act on.
- An unreadable or unauthorized answer aborts too. It is not "no team"; it is an
unknown answer to the one question cast must not guess at.
**An Environment is not an auth boundary.** It has no `team_id` of its own (it
belongs to a project) and no API path scopes by it — Coolify environments are an
organizational construct. The team is the only boundary there is.
**A server belongs to exactly one team.** There is no pivot table and — unlike
`GithubApp` — no `is_system_wide` escape hatch; upstream confirms teams cannot
share a server and defers it to v5 (coollabsio/coolify#1820, #3235 ). Registering
a server under the wrong team is not fixable with a PATCH, which is why
`server add` takes `--env` and inherits the same assert.
**GitHub Apps, unlike servers, *can* be shared across teams** —
`is_system_wide` is the supported mechanism, on both the read and write side:
```php
// GithubController@list_github_apps (backs GET /github-apps)
$githubApps = GithubApp::where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
// …
```
`POST /github-apps` validates and accepts `is_system_wide` (boolean), stamping
`team_id` from the token. So **one App flagged system-wide is visible and usable
from every team, and per-team App duplication is unnecessary.** Note the
corollary for cast: because `GET /github-apps` deliberately includes other
teams' system-wide Apps, **resolving a GitHub App by name is not a proxy for
being in the right team** — which is the second reason the team assert has to be
explicit.
2026-07-11 13:13:19 +00:00
**`dockercompose` build pack** (compose apps, box-B parity): a manifest
application whose `build.pack` is `dockercompose` declares
`build.compose_file` (path to the compose file in the checkout) and
`service_domains` (map of compose service name → `string[]` of URLs) instead
of the plain-app `port` /`healthcheck`/`domains` trio — those three live in the
compose file itself and are rejected by the manifest schema on a compose app;
conversely `service_domains` /`compose_file` are rejected on a non-compose app.
`domains` is schema-optional at the top level for this reason, but still
required (via a `superRefine` ) for every non-compose app — no existing
manifest needs to change.
```yaml
core:
source: { repo: acme/widget, branch: main }
2026-07-14 22:20:27 +00:00
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
2026-07-11 13:13:19 +00:00
service_domains:
api: ["https://api.widget.example.com"]
env_template: core.prod.env.template
```
2026-07-14 22:20:27 +00:00
**Checkout paths are absolute.** `build.compose_file` ,
`build.base_directory` and `build.publish_directory` are all paths *inside the
repo checkout*, and all three must begin with `/` — the manifest schema refuses
them otherwise. This is Coolify's own rule, not cast's taste: on create it
validates `docker_compose_location` against
`ValidationPatterns::FILE_PATH_PATTERN` and `base_directory` /`publish_directory`
against `DIRECTORY_PATH_PATTERN` (both anchored on a leading slash; only the
directory pattern also admits the bare `/` root), and a `docker-compose.yaml`
with no slash comes back as a bare 422 — *after* `apply` has already created
the project and the environment. cast refuses at parse time instead, on every
verb, at zero API cost. It refuses rather than normalizes: the manifest is the
artifact under review, so the value is fixed in the file, once, not repaired in
memory on every run.
2026-07-11 13:13:19 +00:00
Internally cast keeps the map vocabulary (`docker_compose_domains:
{service: string[]}`) all the way through `resolve.ts` /`apply.ts`/diffing;
only `cli.ts` 's wire-translation layer (`applicationApiFields`) flattens it to
the Coolify request shape — an array of `{name, domain}` where `domain` is
that service's URLs comma-joined (verified against the
`/applications/private-github-app` and `PATCH /applications/{uuid}` request
schemas in `reference/coolify-openapi-4.1.2.json` ). A compose app's create
payload also sets `connect_to_docker_network: true` — without it the stack
cannot reach the environment's managed Postgres/Redis resources at all, which
fails at runtime rather than at apply time.
Live-state projection (`projectLiveFields`) reads both fields back for a
compose app: `docker_compose_location` (a plain string on the GET model) and
`docker_compose_domains` , which the GET model documents as a nullable
2026-07-15 15:46:50 +00:00
**string**. A live Coolify 4.1.2 probe (cast#68) pinned the ACTUAL decoded
shape of that string: it is a service-**keyed object**, `{ "< service > ":
{ "domain": "< comma-joined > " }, … }` — **not** the `[{name, domain}]` array
the write side sends and the OpenAPI implies. `parseDockerComposeDomains`
therefore decodes both shapes into the internal `{service: string[]}` map (the
object shape from the read, the array shape from the write-side round-trip and
legacy data), degrading to "field omitted" (not a crash) on anything that is
neither. This was the first of #68 's two idempotency breaks: the object bailed
to `undefined` , so cast diffed the desired map against nothing and re-PATCHed +
redeployed the stack on every apply. Projecting both fields — decoded
correctly — is what keeps a matching re-apply a true no-op.
2026-07-11 13:13:19 +00:00
**Hostname overlay, compose apps:** `--hostname-overlay <file>` accepts a
per-service map value for a compose app's entry instead of the plain-app
`string[]` :
```yaml
core:
api: ["http://api.< PROD-IP > .sslip.io"]
landing: ["http://landing.< PROD-IP > .sslip.io"]
```
Only the named services' domain lists are replaced; other services in the
same app keep their manifest values. A map value naming an unknown service
errors, listing the app's known services; a map value against a non-compose
app errors (`hostname overlay gave a service map for non-compose app < name > `)
— the plain `string[]` shape keeps working unchanged for non-compose apps.
**Structural vs. full diff** are tied to what the configured token can do,
not a flag alone: **structural** diff needs only a standing read-only token
and never compares env values (output says so explicitly); **full** diff
(and `apply` , which always requires full) needs a session token with
`read:sensitive` and compares secret values too — but the output only ever
says `secret FOO differs` , never the value on either side.
**Apply semantics** (verbatim from the spec's Global Constraints — never
softened by an implementation detail):
- Apply never deletes. Resource removal or rename is a manual runbook act;
`diff` reports the orphan as such until that act happens.
- Apply never recreates a database resource under any circumstances.
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
- Apply creates the **project** and its **environment** when they are absent —
the two things a resource create has to name before it can name anything
else. Coolify hands a project it has just created its OWN default environment
(`production`), never ours, so without this the first apply against a
from-nothing project 404s on its first resource — *"Environment not found"* —
and leaves the project behind, created and empty (#38). Read-before-write, so
an environment that already exists is never written to: adoption keeps working
exactly as it did, and this cannot regress an apply that works today.
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
- That default environment is then **removed** — the one delete cast performs,
and the only exception to *apply never deletes* (#40). What that rule protects
is things cast did not make; this is a byproduct of cast's own `POST /projects`
seconds earlier, holding nothing and having never held anything. Leaving it
meant every project cast created from nothing carried a permanently-empty
`production` beside the environment everything actually lives in — precisely
the shape that makes a box unreadable later (on the box being migrated away
from, `production` is empty and everything runs in `staging` , and *"the obvious
guess is the wrong one"* is a note we had to write for ourselves). All three
conditions hold jointly or nothing is touched: **cast created the project in
this run** (never a project someone built by hand, whatever it carries), the
environment is **empty** (asked of Coolify — the details route is the only one
that eager-loads resources — not assumed from the first condition), and its
name is **not ours** (an `--environment production` keeps its `production` ).
Best-effort: a delete that fails is reported and never fails the apply.
2026-07-11 13:13:19 +00:00
- On drift in a field the API cannot update in place (`build_pack`, a
database's `type` /`version`, a service's `type` ), apply **fails loudly
naming the field** rather than recreating.
- Apply ends every mutated resource with a restart/redeploy
(`instant_deploy` on create, `/deploy` or `/restart` on update) — API
mutations land in Coolify's DB, not in running containers, so a create or
update that skipped this would silently not take effect.
2026-07-14 22:22:30 +00:00
- **Apply acts in dependency order, not manifest order** — `database` →
`service` → `application` (#45), for creates *and* updates: a redeploy is a
redeploy, and an application restarted against a database whose own pending
change has not landed is the same failure one apply later. The order cannot be
computed — nothing in a manifest declares that `core` needs `postgres` , no
resource names another, so there is no graph to walk — so it is a fixed
kind-order (`KIND_ORDER` in `apply.ts` ). The direction between the three kinds
is not in question, and three is few enough to legislate. `cast destroy` tears
down in its exact reverse: things come up in the order their dependencies
allow and go down in the reverse. Only the *acting* order changes; the diff
report still reads in manifest order (a resource is read where its author
wrote it), and nothing about clean/orphans/placement moves with it.
What that buys, and what it does not: an application is no longer created and
deployed against databases that **do not exist** , which made a first apply's
deploy fail by construction — a full build, a red deployment, and an operator
told to ignore it. It is **not** a readiness barrier.
`DeployController@deploy_resource` (v4.1.2) *queues* :
`queue_application_deployment(...)` for an application and
`StartDatabase::dispatch($resource)` for a database (only a service starts
synchronously, `StartService::run` ). So cast orders its **requests** , and
Coolify runs them on its own queues. An app whose first boot must find a
*listening* database still races it; apply guarantees the database exists and
was asked to start first, not that it is up.
2026-07-11 13:13:19 +00:00
- Direction is one-way, manifest → Coolify, always.
- **Environment guards:** `apply` refuses if a var matching that environment's
`forbidden_var_patterns` is present in the resolved env **at all, regardless
of value** — "off" means absent, not `false` (see the README). `apply` also
refuses `--path` combined with `--env prod` : prod always reads the default
branch, so a feature-branch checkout can never reach it.
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
- **Reserved names:** cast never writes `SOURCE_COMMIT` or a `COOLIFY_*` var,
under any manifest, in any environment. See below.
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
## Build settings (`install_command`, `build_command`, `start_command`, `static`)
A manifest application's `build` block carries, beyond `pack` and the checkout
paths, four optional settings Coolify accepts on both the create
(`POST /applications/private-github-app`) and update (`PATCH /applications/{uuid}`)
routes:
```yaml
landing:
source: { repo: acme/widget, branch: main }
build:
pack: nixpacks
base_directory: /
install_command: npm ci
build_command: npm run build -w apps/landing-site
publish_directory: /apps/landing-site/dist
static: true
domains: ["https://widget.example.com"]
```
- `install_command` / `build_command` / `start_command` are free-form strings,
passed through verbatim — cast does not parse the shell in them. They exist so
a **workspace monorepo** can scope the build to one app (`npm run build -w
apps/landing-site`) instead of letting nixpacks auto-detect from the repo-root
`package.json` , whose scripts may build and run a *different* workspace.
- `static: true` maps to Coolify's `is_static` : Coolify then **serves
`publish_directory` and runs no start command**. Without it a static site in a
monorepo gets built and then *run* as its root `package.json` — for the
incubator's `landing` that meant Coolify ran `npm run start -w apps/core` , the
API server, which crash-looped on a missing `DATABASE_URL` (#63).
**All four are rejected on a `dockercompose` app** — a compose app builds and
runs from its compose file, so Coolify never consults them — and `static: true`
is rejected without a `publish_directory` (nothing to serve). Both are parse-time
refusals, like the checkout-path rules above.
**Managing `is_static` is opt-in.** cast emits it only when the manifest declares
`static:` — like the three commands, not on every app. Emitting `is_static:false`
by default would make the first apply after this ships PATCH `is_static=false`
onto any static/SPA app configured in the UI whose manifest has not yet been
migrated — silently disabling static serving and re-creating the very crash, now
caused by cast; a `pack: static` app that Coolify couples to `is_static=true`
would drift-and-revert forever. So: declare `static: true` to serve, `static:
false` to actively guard against a UI flip to `true` , or omit it to leave the
field alone (Coolify keeps `pack` and `is_static` independent, which is why this
is an explicit field, not inferred from `pack` ). The three commands are likewise
conditional (an unset command means "let the build pack decide"), diffed only
2026-07-15 15:46:50 +00:00
when declared. `projectLiveFields` reads `is_static` back so it is there to
compare when a manifest does declare it — **but only when Coolify actually
returns it, and 4.1.2 never does.** `is_static` is not an `applications` column;
it lives on the `ApplicationSetting` relation (`Application::settings()`), which
Coolify 4.1.2 serializes on **no** read endpoint — the model has no `$with` , and
neither `GET /applications` , the by-uuid GET, nor `@environment_details`
(`ProjectController`) load `settings` . So the field is simply absent from every
read (source-verified + a live probe, cast#68); the UI reads it in-process off
the model (Livewire), never via the API. Projecting `false` from that absence was
#68's second idempotency break: cast diffed `false → true` and re-PATCHed +
redeployed on every apply.
So when the live value is unreadable (`null`/absent), `projectLiveFields` omits
`is_static` , `fetchLive` flags the application `staticNotCompared` , and
`computeDiff` skips the comparison (a once-per-run warn says so) — ** `is_static`
degrades to a create-time-only setting.** The create path still sends it
(`applicationApiFields`), so a fresh static app is stood up correctly; what is
lost is drift detection and in-place repair of a later UI flip, which the API
does not permit reading back. If a future Coolify returns a real boolean, it is
projected and diffed normally again. `draft` emits all four when the
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
live box carries them (and `static` only alongside a `publish_directory` , so the
draft always loads) — they used to sit in its `NO_HOME` list of settings a
rebuild silently dropped, and `is_static` was not even there, which is exactly
how a rebuilt static site came back wrong.
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`)
Coolify injects a set of values into an application's runtime environment
itself — `SOURCE_COMMIT` , and the `COOLIFY_*` family (`COOLIFY_URL`,
`COOLIFY_FQDN` , `COOLIFY_BRANCH` , `COOLIFY_RESOURCE_UUID` ,
`COOLIFY_CONTAINER_NAME` ) — and it does so behind one guard
(`app/Jobs/ApplicationDeploymentJob.php`, v4.1.2):
```php
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
}
```
**Coolify skips its own injection of a name when the resource already carries an
env var of that name.** So a resource-level `SOURCE_COMMIT` does not merely fail
to help — it **suppresses** the value Coolify would otherwise have provided. An
**empty** one suppresses it exactly as completely: `isEmpty()` is asked of the
*collection of vars*, never of the value. Presence, not value — the same rule
`forbidden_var_patterns` holds to, for the same reason.
And it **fails green** . The deploy succeeds, the health check passes, the
container runs, and the only symptom is that `/version` — which reads
`process.env.SOURCE_COMMIT` at request time, and which a production cutover is
gated on — reports `unknown` .
Anything that writes env vars can set that trap, and cast is a thing that writes
env vars. So the rule is applied at **every** place cast touches one:
| verb | behavior |
| --- | --- |
| `apply` / `diff` / `capture` / `inventory` | **refuse.** Any manifest read whose env template declares a reserved name fails the run, before any write, naming the var and the consequence. |
| `inventory --emit-draft` | **never copies one.** A reserved name read off a live box is dispositioned `suppressed` : kept out of the emitted template *and* out of the age store, its live value read into no artifact, and named in `UNCAPTURED.md` with the reason. |
| `diff` | **a finding, not an orphan var.** A reserved name on a live resource is *not* filed under `remove-candidate` ("apply never removes these; read them by eye"). It is not cosmetic residue — it is an active suppression — so it prints as a `FINDING` , with the consequence, and the report is **not clean** . |
| `smoke` | its probe vars are asserted to be outside the reserved space. |
Two things this rule is **not** :
- It is **not** `forbidden_var_patterns` . That one is *policy* — an environment's
own choice about its own vars — and it lives in the operator's private state
precisely so that a product-side change cannot lower its own guard. This one is
a **fact about Coolify** : true on every box, in every environment, for every
project. There is no environment in which declaring `SOURCE_COMMIT` is correct,
so there is no file in which it can be permitted. It lives in cast's code.
- It is **not** a deletion. `apply never deletes` holds unchanged: on a live box
that already carries one, cast **reports** it and a human removes it in the
Coolify UI.
Nothing here applies to cast's own config vars (`COOLIFY_BASE_URL`,
`COOLIFY_ACCESS_TOKEN` , `COOLIFY_READ_ONLY` ): those are read from the operator's
local instance file and are never written to a resource.
2026-07-11 13:13:19 +00:00
feat: a project registry — the list of what exists (#25)
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>
2026-07-13 20:03:35 +00:00
## The registry (`projects:`)
The top-level `projects:` block is the list of **which projects exist** , and in
which environments. It is the only place that says so: `environments:` says where
things deploy to, `environments.<env>.projects.<repo>` says how an already-named
project is placed, `github_apps` says how to clone one you have already named.
```yaml
projects:
heavy-duty/incubator:
environments: [prod, staging]
```
- **Keyed by the full `<org>/<repo>` slug, with no bare-`< repo > ` fallback.** The
key is the repo; there is no `repo:` field. `github_apps` and
`environments.<env>.projects` accept a bare key because state files in the wild
are written that way; this block is new and has none, so it requires the slug —
a bare `<repo>` is unique only *within* an org.
- **`environments:` names OUR environments** — the keys of the `environments:`
block, the values `--env` takes — never Coolify's. Non-empty.
- **Optional.** A state file with no `projects:` block loads unchanged, and
`projectsIn` reports `[]` for every environment.
**Validated at parse time, so every verb refuses a registry that lies.** Two
refusals, both defending the same failure — *a silently skipped project reads
exactly like a clean one*, which makes silence, the most common report there is,
ambiguous:
1. **An environment that does not exist** (a typo in `projects.<slug>.environments` )
is an error naming the unknown environment and listing the known ones. Left
alone, the project would be registered into an environment no command can
visit: a fleet run skips it, reports nothing, exits clean.
2. **A binding the registry does not register.** Every
`environments.<env>.projects.<slug>` key must be a project the registry
registers *for that environment* . Otherwise the two blocks describe two
different fleets: a `destination_uuid` or `smoke_target` real enough for a
direct `cast apply <repo> --env <env>` to act on, and invisible to every
fleet run over that environment. Enforced **only when `projects:` is
present**, so pre-registry state files keep loading.
The registry is what makes two things possible, neither of which can be attempted
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
without a list to iterate: **fleet operations** (`--all`, below), and
feat: a project registry — the list of what exists (#25)
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>
2026-07-13 20:03:35 +00:00
**rebuild-from-state** — restoring a Coolify from the state repo, which is
otherwise an assumption, since you cannot restore what you cannot enumerate.
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
## Fleet runs (`--all`)
`cast diff --env <env> --all` and `cast apply --env <env> --all` act on **every
project the registry lists for that environment**, in place of the `<org>/<repo>`
positional. The projects are visited in the registry's sorted order
(`projectsIn`) — a fleet report a human reads top to bottom, and CI diffs, must
not reshuffle because someone appended a project.
**One implementation.** `--all` loops the *same* per-project path the single-repo
form runs (checkout → secrets → desired → bindings → team-asserted client → live
read → diff → optionally apply). There is deliberately no second, parallel fleet
code path: two implementations of "what a project run is" would drift, and drift
is the thing this tool exists to catch.
**The instance and the team are asserted once, before the first project's first
read.** One `--env` means one instance and one team for the whole run, so the
gate lands where it always did — strictly before the first live read, which is
already the lie a wrong-team token tells (see *Team scoping* ).
### Fails closed on the aggregate
**A registered project cast cannot reach is an ERROR, never a skip.** "Cannot
reach" is every way a project can fail to answer: the clone failing, the manifest
carrying no block for this environment, the secret store being absent or
undecryptable, the Coolify project or environment being absent
(`LiveLookup.found === false`), and any HTTP error. They collapse into one
outcome because only one thing about them matters downstream — **this project was
not read** — and a silently skipped project reads exactly like a clean one. That
is the failure of #12/#18/#22 at fleet scale, and it would make *silence* , the
most common report there is, the least trustworthy one.
So the aggregate reports **coverage** (registered / read / clean / drifted /
unreachable), and the exit code ranks an unread project above a drifted one:
| verb | exit | meaning |
|---|---|---|
| `diff --all` | `0` | every registered project was **read** , and every one is clean |
| `diff --all` | `1` | every one was read, and at least one has drift |
| `diff --all` | `2` | a project could not be read. **Outranks drift** : an unreadable project is not a diff result, it is the absence of one |
| `apply --all` | `0` | every registered project applied |
| `apply --all` | `≠0` | anything else |
`fleetExitCode` defaults to `2` on any coverage shape it does not recognize — an
exit code is the only part of the report CI reads, so an unrecognized shape must
fail rather than pass.
### Opposite dispositions on failure, both deliberate
- **`diff --all` runs every project to completion.** A read that stops early hides
the drift in the projects it never reached; a read that continues costs nothing.
- **`apply --all` stops at the first failure**, and reports which projects were
applied and which were **not touched** . A write that continues costs everything:
the next project's apply would be a guess about whether the last one broke
something it depends on. `apply` is idempotent, so re-running after the fix is a
no-op over the projects that already applied.
`apply` keeps its usual position on an absent Coolify project — it *creates* it,
exactly as a single-project `apply` does (see *the read side* , and `LiveLookup` ).
Only `diff` treats absence as unreachable, because `diff` may only ever describe a
target that already exists.
### Two refusals
- **An empty or absent registry refuses** (exit 2). `projectsIn` answers `[]` both
for a state file with no `projects:` block and for one whose registry names
nothing in this environment; to a fleet run they are the same thing — *nothing
to iterate* — and "0 projects, clean" is precisely the sentence this feature
exists to make impossible. The refusal names what was looked for, distinguishes
an unmigrated state file from a registry pointed elsewhere, and prints the YAML
to write.
- **`--all` is mutually exclusive** with the repo positional and with every
single-project coordinate: `--path` , `--project` , `--environment` , `--resource` ,
`--hostname-overlay` . Each names ONE project's checkout, ONE project's Coolify
name, ONE box's resource names — none is true of the project beside it.
Fleet-wide they are meaningless at best and dangerous at worst: `--project X`
across a registry points every project at the same Coolify project, which on
`diff` is a false report and on `apply` is every manifest in the fleet written
into one project. The refusal names the offending flag and says what applying it
fleet-wide would have done.
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
## Placement (destinations)
A **destination** is the Docker network a resource is created on. It is declared
per project — `environments.<env>.projects.<repo>.destination_uuid` — because a
destination is scoped project × environment, and the environment block above it
says `server:` , which is exactly what two projects share.
**Enforced once, at create.** `apply` sends `destination_uuid` on every create
(applications, databases, services — Coolify runs identical destination logic in
all three controllers). It is never sent on update, and `apply` never moves a
live resource between networks.
**Not comparable, and therefore reported rather than compared.** Coolify 4.1.2
accepts a `destination_uuid` on write and returns a `destination_id` (an integer
primary key) on read, exposes no endpoint mapping one to the other, and in fact
has no destinations API at all (zero routes at `v4.1.2` ). So the declared UUID
**cannot be verified against the live resource it was sent for** — by cast or by
anything else. Two consequences, both deliberate:
- `diff` never diffs the destination as a field. Doing so would compare a UUID
against an int and report drift that could never be resolved — a phantom
"update" on every run.
- `diff` instead groups live resources by the `destination_id` Coolify *does*
report. That int is opaque, but it is comparable **to itself** , which catches
the thing worth catching: a project whose resources do not all sit on one
network is a project whose isolation is broken. That is `split placement` , and
it is drift — non-clean, reported, and **not repaired** (same disposition as an
orphan).
- Whenever a destination is declared, `diff` says explicitly that it was *not*
compared. Silence would make an unverified setting read as a verified one —
the failure shape this document exists to avoid.
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
- Whenever one is **not** declared, `diff` says *that* , too:
`placement: server's default destination (none declared)` . Declaring nothing is
not the absence of a placement decision — it is one (cast sends no
`destination_uuid` , Coolify picks), and it was the only placement decision made
in silence until #41 . It is reported even on a clean run that creates nothing,
because the trap is set precisely for projects that are already built: the day
their server gains a second destination, every project on it that declared no
destination stops being able to create at all.
**The multi-destination 400 is translated, not passed through** (#41). A create
against a server with more than one destination that names none is rejected with
*"Server has multiple destinations and you do not set destination_uuid."* — a
message that names neither the remedy nor the file the remedy goes in, and that
arrives at the **first create** , after `apply` has already made the project and
the environment. cast **cannot** pre-flight the condition: 4.1.2 serves no
destinations API at all, and `GET /servers/{uuid}` does not carry them either, so
a server's destination count is unknowable until a create has been attempted.
What cast can do is answer the question the 400 raises, and it does: the failing
resource, the server by the name the operator wrote (not its UUID), the exact
state-file path the UUID goes in
(`environments.< env > .projects.< org > /< repo > .destination_uuid`), the create-time
warning, and Coolify's own words kept verbatim. A run interrupted this way is
safe to re-run once the UUID is declared — the project and environment it already
made are adopted, not remade.
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
**Coolify's create-time behavior** (`ApplicationsController` ~L1003,
`DatabasesController` ~L1700, `ServicesController` ~L378 @ v4.1.2): a server with
**one** destination uses it and *ignores* any `destination_uuid` sent, never
validating it — so a typo is invisible there. A server with **more than one**
rejects a create that omits it (`400`), and rejects a UUID that belongs to
another server (`422`). The second case is why cast could not apply to a shared
box at all before this field existed. Citations: `reference/README.md` .
fix(apply): pre-flight domain uniqueness, and translate Coolify's 409 (#44)
Coolify enforces domain uniqueness across the whole instance; cast plans
inside one project + one environment. So apply could produce a plan that
was internally consistent, correct against everything cast can observe,
and still be refused — by a resource in a project cast never queries,
arriving as a raw 409 mid-apply, after the project and the environment
had already been created.
- Pre-flight the create plan: before the first write (project and
environment are created lazily, by the first create), check the domains
the plan is about to claim against GET /applications. A conflict is now
a refusal that costs nothing, not a half-applied run. One GET, and only
on a plan that creates an application with a domain — a first apply.
Covers both live shapes: fqdn, and per-service docker_compose_domains.
- Translate the 409 when one gets through anyway (a conflict with a
service fqdn or the instance fqdn is not visible in GET /applications,
so the pre-flight is a strict subset of Coolify's check). Names the
domain, the resource, its uuid — and whether it is outside the applied
project, which is the part the operator cannot get from Coolify.
- Never send force_domain_override=true. Coolify suggests it in the error
text; two resources on one domain is a routing coin-flip, and Coolify
says so in the same response.
Closes #44.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:30:34 +00:00
## Domains (uniqueness is instance-wide)
**Coolify enforces domain uniqueness across the whole instance; cast plans inside one
project and one environment.** That gap is structural, not a bug: a plan can be
internally consistent, correct against everything cast can observe, and still be
refused — by a resource in a project cast never queries. The check is
`checkIfDomainIsAlreadyUsedViaAPI` (`bootstrap/helpers/domains.php` @ v4.1.2) and it
walks every application of the *team* (its `fqdn` , and for `dockercompose` apps its
per-service `docker_compose_domains` ), every service application's `fqdn` , and the
instance's own `fqdn` . Only applications can claim a domain through cast: databases
have none, and cast's service creates send no domains at all.
**The create plan is pre-flighted** (#44). Before `apply` writes anything — the
project and the environment are created lazily, by the first create, so this is the
last moment a refusal is free — cast reads `GET /applications` and checks the domains
the plan is about to claim against every one already held. A conflict is a **refusal**
(nothing created), not a failed apply. It costs one GET, and only on a plan that
creates an application with a domain: a first apply, and nothing else. *N+1 is not
needed:* the list is serialized by the same `removeSensitiveData()` as the per-app
`GET` (`ApplicationsController.php` :38, called at :130 and :1980), so it already
carries `fqdn` , `docker_compose_domains` and `build_pack` — none of which the vendored
OpenAPI documents on that route.
**The 409 is translated when one gets through anyway.** The pre-flight is a *subset*
of Coolify's check — service `fqdn` s and the instance `fqdn` appear in no list cast
can read — so a create can still be refused mid-apply, with a raw
*"Domain conflicts detected. Use force_domain_override=true to proceed."* Both the
refusal and the translation say the same three things, the last of which is the one
the operator cannot get from Coolify: the domain, the resource holding it (name +
uuid, and the compose service if it is held per-service), and **whether that resource
is inside the applied project or outside it**. Outside is the usual case, and it has a
usual cause worth naming: *residue from an earlier run cleaned up by deleting a
Coolify project.* Deleting a project does **not** delete its resources — they survive,
invisible to cast, still holding the domain instance-wide. (The scope claim is checked
against the live resources cast read, never assumed: a conflict with something in the
plan's own project — a renamed resource — is a different fix, and would be a lie
otherwise.)
**cast never sends `force_domain_override=true` .** Coolify offers it in the error text
and it is the wrong answer: two resources on one domain is a routing coin-flip, and
Coolify says so in the same response (*"can cause routing conflicts and unpredictable
behavior"*). Nothing in cast can send that flag, and no retry may ever set it — if it
is ever wanted, it is an explicit operator act in the UI, not a tool's decision.
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
## Backup schedules
A manifest database's `backup` block (`frequency`, `retention` ) is a **diffed
field like any other**: compared on every run, written on create *and* on
update. Declaring `backup:` on a database that already exists starts backing it
up — which is what every reader of that manifest already assumed it did.
It did not always work that way, and the reason is worth keeping: the schedule
used to be write-only, applied inside `apply` 's create branch and then never
looked at again, because *"live Coolify state doesn't expose it back"* . That was
false. A schedule is not on the database's own `GET` — but it was never supposed
to be. It has **its own route** , which cast had been POSTing to all along and had
simply never read:
```
GET /databases/{uuid}/backups ← list a database's schedules
POST /databases/{uuid}/backups ← create
PATCH /databases/{uuid}/backups/{scheduled_backup_uuid} ← update
```
The cost of not looking was exact: a database created before its `backup:` block
was declared never got one, a schedule deleted in the UI was invisible, and the
`--full` diff that gates a production cutover passed with an unbacked-up
production database (#51).
**What the route returns.** The vendored OpenAPI documents the body as *"Content
is very complex. Will be implemented later."*, so the shape comes from the source:
`DatabasesController@database_backup_details_uuid` (v4.1.2) returns
`ScheduledDatabaseBackup::…->with('executions')->where('database_id', …)->get()`
straight to `response()->json()` — a **JSON array of raw Eloquent rows** (no API
resource, no `removeSensitiveData` ), whose columns are the model's `$fillable` :
`uuid` , `enabled` , `save_s3` , `frequency` ,
`database_backup_retention_amount_locally` , … plus an eager-loaded `executions`
array cast ignores. `retention` is
`database_backup_retention_amount_locally` — the same field cast has always sent
on create.
**`frequency` round-trips verbatim**, which is what makes it diffable at all: the
controller *validates* it (`validate_cron_expression`, which only returns a bool)
and then stores `$request->only($backupConfigFields)` unchanged, with no mutator
on the model. `"0 3 * * *"` reads back as `"0 3 * * *"` ; the preset words
(`daily`, `weekly` , …) read back as themselves. The old "diffing it would flag
spurious drift every run" fear was a guess about a read nobody had performed.
**A disabled schedule is not a backup.** A row with `enabled: false` exists but
backs nothing up, so it is neither clean (it diffs against a declared block) nor
absent (`apply` PATCHes it, rather than adding a second schedule). Every cast
write asserts `enabled: true` .
**What is still NOT compared**, and says so on screen when it applies:
- **An unreadable answer.** If the route is unreachable, or answers a shape cast
does not recognize, cast reports `backup schedule for database < name >
declared, NOT compared — verify in the Coolify UI` and treats it as neither
drift nor clean. An absence of evidence is not evidence of drift: cast will not
invent a change it cannot see, nor certify a database it could not read. On the
write side the same read failure **raises** rather than guessing — POSTing blind
would duplicate a schedule that may already exist, and skipping is the silent
no-op this whole section exists to kill.
- **More than one schedule.** A manifest declares one; a database carrying
several is outside that vocabulary, and choosing one to compare against would
be a coin toss reported as a fact. Reported, not compared, not written.
- **The S3 target.** Coolify returns the storage as `s3_storage_id` (an int) and
takes it as a UUID, the same unmappable pair as `destination_id` (see
Placement). cast **asserts** `save_s3: true` + the environment's
`s3_destination` on every write and can never verify it afterwards.
- **An undeclared schedule.** A live schedule on a database whose manifest says
nothing about backups is left alone, unremarked — `apply` never removes.
**A backup change redeploys the database.** `apply` redeploys any resource it
mutates, and a schedule change is a mutation of the database, so changing
`frequency` restarts the container. Consistent with every other field, and worth
knowing before you edit a schedule on a live production database.
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
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 `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
## Instance selection
**The Coolify a command talks to is an explicit, named value** — not a property
of whatever `<state>/.coolify.env` happens to contain at the moment. Resolution
order, highest first:
1. `--instance <name>` → `<state>/.coolify/<name>.env`
2. the environment's `instance:` binding in `environments.yaml`
3. `<state>/.coolify.env` (the default; unchanged when neither of the above is
used)
Two refusals, both fail-closed:
- **An unknown `--instance` aborts**, naming the instances that do exist. It
does *not* fall back to the default — that fallback is how a `--full` diff
meant for a legacy box gets run against production.
- **`COOLIFY_READ_ONLY=true` in an instance file makes it read-only**, and
`apply` / `smoke` / `server add` refuse it *before their first call* . The
guard is the **declaration** , not the token's scope: an instance configured
for inspection must not be writable even when the token it holds would permit
the writes. `diff` , `team` and `capture` still work against it — they read.
Every command that reaches a live Coolify prints which one, next to the team
assert.
## Adoption (`capture`)
`capture` is the only verb that writes *into* the state directory rather than
into Coolify, and the only one that reads a hand-built instance as a **source**
rather than as a target. It exists because cast is otherwise scoped to the
steady state and has no bootstrap path for a box that predates its manifest.
**The required set comes from the manifest, not from the box.** The names are
the `${…}` refs in that environment's env templates, read by the same parser
`apply` uses to demand them (`parseTemplate`, shared by `resolveTemplate` and
`templateRefs` — deliberately one grammar, because a drift between the two
would mean `capture` collects a different set than `apply` will later require,
which is the "a name silently missed" failure it exists to remove). So the store
it writes contains **exactly** the names the manifest requires: a live var
nobody asked for is not the store's business, and a template literal
(`NODE_ENV=production`) is not a secret.
**The mapping is not mechanical, and must not be.** Some entries encode
migration decisions rather than facts about the source box:
- A `DATABASE_URL` / `REDIS_URL` read off the source points at the **source
box's** Postgres/Redis. Copying it is confidently wrong in a way that looks
entirely plausible, and the target's real URL does not exist until Coolify
creates the resource. These are declared `generated_secrets:` in the manifest
environment and written as the literal `pending-coolify-generated` .
- staging's `ADMIN_EMAIL` must be the operator, not the source's value: staging
and prod share a Mailgun domain, so a staging box carrying the real address
can mail real users. That is `--override` .
A "capture everything" verb would therefore be silently wrong in a handful of
entries out of seventeen — worse than being wrong in all of them. So every
required name is **forced into a disposition** , and two of the four stop the
run:
| disposition | source | outcome |
| --- | --- | --- |
| captured | found live | value taken |
| generated | manifest `generated_secrets` (or `--generated` ) | `pending-coolify-generated` |
| overridden | `$CAST_CAPTURE_<NAME>` | operator's value |
| **missing** | required by a template, absent live | **refuses** |
| **conflict** | one name, different live values on two resources | **refuses** |
`generated_secrets` is a **manifest** property, not a flag: the manifest is what
knows `DATABASE_URL` comes from a database it declares. An entry naming
something no template refs is a hard error — dead config here is not untidy but
dangerous, because it reads like a guard standing over a name while standing
over nothing, and the likeliest cause is a typo whose real name is then
*captured* from the source box instead of placeheld.
fix(apply): refuse to write the generated-secret placeholder over a live value
The bootstrap is two-pass and only the first pass was ever safe to repeat.
The store holds `pending-coolify-generated` for a provider-generated secret;
the first apply sends it, Coolify creates the Postgres/Redis and replaces it
with the real URL. From that moment the store is known-wrong — and `diff` and
`apply` had never heard of the literal cast itself invented to say so.
`diff` printed `secret DATABASE_URL differs`, which is word for word what a
legitimate rotation prints, and `apply` stood ready to PATCH the placeholder
back over the live URL and redeploy every consumer onto it. Coolify's bulk env
endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found
and its value overwritten), so nothing on the far side stopped it either.
- diffEnv gives the placeholder its own state, `placeholder-conflict`, when the
store holds it and the live resource holds anything else. Live-also-
placeholder, absent live, and the create path are unchanged.
- renderDiff says it in words no rotation prints, and counts it in the summary.
- applyPlan REFUSES on it, before any resource is touched — same fail-closed
shape as the not-updatable refusal. The message names the key and the
resource, never the live value, and points at the remedy (#48).
Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that
list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var
key (DATABASE_URL). Matching the list against these keys would have sailed past
the very case that motivated the issue.
Closes #47.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
### The placeholder is a promise, and `apply` refuses to write it over a value
The bootstrap is **two-pass** , and only the first pass is safe to repeat.
`pending-coolify-generated` means *"no real value exists yet — Coolify will make
one"*. The first `apply` sends it, Coolify creates the Postgres/Redis and
replaces it with the real URL. **From that moment the store is known-wrong** , and
a second `apply` would PATCH the placeholder back over the live, working value
and redeploy every consumer onto it — Coolify's bulk env endpoint is a plain
upsert (`create_bulk_envs`, v4.1.2: an existing key is found and its value
overwritten), so nothing on the far side stops it either.
So `diff` and `apply` both know the literal:
- `diff` gives it its own state and its own words — `secret DATABASE_URL: store
holds the generated-secret PLACEHOLDER, live holds a real value — apply would
OVERWRITE it`, plus a count in the summary line. The old report said `secret
DATABASE_URL differs`, which is what a legitimate **rotation** of the same
secret prints: the one signal there was could not be told from routine.
- `apply` **refuses** — it does not warn. Data-loss write, same fail-closed
family as the team assert and the absent-project gate. Refused before any
resource is touched, and the refusal names the key and the resource, **never
the live value**.
The rule, exactly:
| store value | live value | disposition |
| --- | --- | --- |
| placeholder | a real value | **refuse** (an already-generated secret) |
| placeholder | placeholder | proceed (nothing differs) |
| placeholder | absent | proceed (`add` — this is the first pass) |
| placeholder | *resource does not exist* | proceed (`create` — Coolify replaces it) |
| a real value | anything | proceed (an ordinary rotation) |
It is keyed on the **store's value** , not on the manifest's `generated_secrets:`
list — that list names store *refs* (`DATABASE_URL_PROD`) while an env diff is
keyed by env var *key* (`DATABASE_URL`), and the template maps one to the other.
The value is the same fact, carried to where it is needed. It is also the
stricter reading: a name dropped from `generated_secrets:` while the store still
holds the placeholder is still a write of a promise over a value.
The other half of this hole is that nothing can yet **fill** the store after the
first apply — `capture` placeholds a generated secret by design. Until it can,
the refusal is the guard and filling the store is a manual act.
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
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 `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
**Secret hygiene**, all enforced by tests against real values:
- The plan prints **names and provenance, never values** . (The one value-shaped
thing it prints is the `pending-coolify-generated` literal, which carries no
information about the source.)
- An `--override` 's value is read from `$CAST_CAPTURE_<NAME>` , **never from
argv** — a command-line value is visible in `ps` to every process on the box.
- Plaintext is piped to `age` on **stdin** : never a temp file, never stdout,
never shell history. The hand-run recipe this replaces wrote
`/dev/shm/prod.env` and relied on remembering to `shred -u` it.
- An existing store is **not overwritten** without `--force` : it may hold the
only copy of values the source box no longer has. Same disposition as apply's
never-delete.
**`capture` takes `diff` 's position on an absent target** (see `LiveLookup` ),
and refuses one: against a project or environment that isn't there it would read
back zero live values and report every required secret as *missing* — an
alarming, meaningless report about the wrong box. It also inherits the team
assert (a wrong-team token reads back `null` for everything, producing the same
lie) and the `--path` -with-`--env prod` refusal (a feature-branch manifest must
not decide which names land in the prod store).
The final gate is a **typed confirmation** — the environment's own name, after
the plan. There is no `--yes` : a store written without someone reading the
provenance column is the outcome the verb exists to prevent. A closed stdin
aborts rather than hanging.
feat(capture): --generated-only, the bootstrap's missing pass 2
A manifest that declares `generated_secrets:` bootstraps in two passes by
construction: pass 1 `capture` placeholds those names (their values do not
exist yet), `apply` creates the database and Coolify generates the real URL —
and nothing then taught the store that value. The operator did it by hand:
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
age recipient, against production, holding the prod key.
`capture --generated-only` inverts capture's disposition rule and changes
nothing else — it fills the generated names and leaves every other name in the
store exactly as it is, byte for byte. Same verb, same ceremony, same
store-writing code path.
- reads the value from the resource that OWNS it (`internal_db_url` on the
database), never from a consuming app's env, where a generated URL never
appears — the app's env holds the placeholder itself at this point.
- resolves the database inside the project+environment via
GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which
lists other projects' databases and umami's bundled Postgres — #29's bug in
another hat). The scoping is structural, not a filter.
- refuses to guess which database a name comes from: nothing in the manifest,
the templates or the box carries that edge, so it infers only when it cannot
be wrong (one name, one database) and otherwise hands back `--from`.
- refuses to overwrite a generated name holding a real value without --force
(a silent credential rotation), a name absent from the store, and a
placeholder nothing fills.
- asserts the postcondition against the ciphertext on disk: zero
pending-coolify-generated remain, and the name count is unchanged. That
assertion was a line in a human runbook.
`apply` deliberately does NOT do this after a create — it would make the verb
that mutates Coolify also mutate the encrypted store, and hence the git repo.
Closes #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
### Pass 2 (`capture --generated-only`)
An environment that declares `generated_secrets:` **bootstraps in two passes, by
construction** — the value does not exist until Coolify makes it:
capture → apply → capture --generated-only
(placeholds) (Coolify generates) (the store learns the real value)
Without pass 2 the store's value for `DATABASE_URL` stays a placeholder while the
live value is real — which is exactly the state in which the next routine `apply`
overwrites a working secret (#47). **The absence of pass 2 is what leaves that gun
loaded**, and it is on the DR path: *rebuild the control plane from state* means
apply-from-nothing, which means every generated secret in every store is a
placeholder again. This used to be a hand `age` re-encrypt against production —
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
recipient, holding the prod key, with a `jq` filter that must not pick the wrong row.
`--generated-only` **inverts** capture's disposition rule and changes nothing else:
the names in `generated_secrets` are the ones it *fills* , and every other name is
left **exactly as the store has it, byte for byte** — never re-read from the box,
which is what makes it safe to run against an environment whose other secrets have
since been rotated by hand. Same verb, same store-writing code path, same typed
confirmation.
| | |
| --- | --- |
| **fill** | a generated name holding the placeholder → the value from the database that owns it |
| **keep** | every other name → carried over from the store, untouched |
| **UNMAPPED** | cast cannot attribute the name to exactly one database → **refuses** |
| **OCCUPIED** | a generated name already holding a real value → **refuses** (without `--force` ) |
| **ABSENT** | a generated name the store does not carry at all → **refuses** |
| **PENDING** | a placeholder in a name nothing here fills → **refuses** |
**The value is read from the resource that OWNS it.** A generated URL never appears
on the consuming application's env — the app's env holds whatever the template
resolved to, which at this point in the bootstrap is *the placeholder itself* .
Reading the app back would faithfully capture the lie pass 2 exists to correct. It
lives on the **database** , as `internal_db_url` .
**Resolved inside the project + environment, never instance-wide.** cast reads
`GET /projects/{uuid}/{env}` , whose `postgresqls` / `redis` relations are *this*
environment's and nothing else's. It never calls `GET /databases` , which lists
every database on the box — other projects', and umami's own bundled Postgres —
where picking ours out means matching by name across a list in which a collision
is both possible and silent (#29 in another hat, and the reason the hand-run `jq`
carried a comment about not taking the third row). The scoping is **structural**
rather than a filter cast has to remember to get right.
**cast will not guess which database a name comes from.** Nothing in the system
carries that edge: `generated_secrets:` is a flat list of *names* , the env template
knows only `DATABASE_URL=${DATABASE_URL}` , and the box does not say. So the
inference is made **only when it cannot be wrong** — one generated name, one
database, no other candidate — and otherwise the run refuses and hands back the
flag: `--from DATABASE_URL=incubator-db` . Reading the type out of the *name*
(`REDIS_URL` → the redis one) is precisely the bug this must not have: a
name-directed pick is wrong **silently** , and what it writes is a perfectly
well-formed URL to somebody else's database.
**And it asserts the postcondition it exists for** — against the ciphertext now on
disk, decrypted back, not trusted from memory. Zero `pending-coolify-generated`
remain, and the name count is unchanged. Both claims, because they fail in opposite
directions: a store that still holds a placeholder is still a lie, and a store that
*lost* a name on the way through re-encrypts perfectly, reads back perfectly, and
surfaces at the next `apply` as a missing secret in an environment whose plaintext
nobody has any more. That assertion used to be a line in a human runbook — which is
to say, a step that could be skipped.
`apply` deliberately does **not** do this automatically after a create. It would
close the window entirely, but it would make the verb that mutates Coolify also
mutate the encrypted store and hence the git repo — a much bigger blast radius for
a verb people run on a schedule. A separate, explicit, operator-run verb is the
right first step.
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60)
Add a ${resource:<name>.url} env-template ref that resolves to the internal URL
of a database the same manifest declares, read back from the live resource's
internal_db_url — never stored in the age store, never decrypted, never printed.
This deletes the two-pass generated-secret bootstrap for a database's own URL
rather than automating it: no placeholder, no stored copy to drift or overwrite,
and a rotated password is simply followed on the next apply.
Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff
time against databases already on the box (so a matching app shows no drift —
killing the "secret DATABASE_URL differs" noise that ran on every plan), and in
the executor at apply time against a database created earlier in the same run
(the from-nothing case; apply acts databases-before-applications, #45). The
unresolved sentinel is never written — the executor refuses, rather than write a
blank that boots the app pointed at nothing, and re-running once the database is
up resolves it as an ordinary update.
A ${resource:X.url} naming a database the manifest does not declare, or an
attribute other than .url, is a hard plan-time error refused by every verb that
opens a template (apply, diff, capture). generated_secrets and the two-pass
bootstrap remain for the residual class — a provider-generated value that
genuinely is not derivable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
## Derived resource URLs (`${resource:<name>.url}`)
The two-pass bootstrap above **automates** a hand-dance. Derivation **deletes**
it. A `DATABASE_URL` is not a secret anybody authored — it is a fact about a
resource cast itself created, readable from the API that created it, at any time,
for free. So a template can say so directly:
DATABASE_URL=${resource:postgres.url}
REDIS_URL=${resource:redis.url}
`${resource:<name>.url}` resolves to the **internal** URL of a database the same
manifest environment declares (`connect_to_docker_network` puts apps on the
Docker network, so it is the internal URL, not the external one). The value is
read back from the live resource's `internal_db_url` — the same field, on the
same `GET /projects/{uuid}/{env}` route, that `capture --generated-only` reads —
**never** stored in the age store, **never** decrypted, **never** printed.
**It is not a secret ref.** `parseTemplate` classifies `${resource:…}` as a
distinct *derived* var, so `capture` never goes looking for a store name called
`resource:postgres.url` , and `templateRefs` (the required-secret set) never lists
it. The age store shrinks to the things a human actually authored.
**Resolved in two places, one function.** `fillDerivedEnv` is the only code that
turns a ref into a value, and it runs twice against two different URL maps:
- **at diff/apply time**, against the databases that **already exist** on the
box. On a re-apply this is the whole story: the app's live `DATABASE_URL`
already equals its database's URL, so the derived var shows **no drift** —
which is what deletes the `secret DATABASE_URL differs` line the store-copy
approach printed on every plan. And if the two have diverged (a password
rotated in Coolify), the diff shows it and `apply` **follows** the live
database, rather than reverting it to a stale stored copy.
- **at apply time in the executor**, against a database this same run just
created. On a from-nothing apply nothing existed to resolve against at plan
time, so the ref rides through the diff unresolved (rendered `DATABASE_URL:
derived from database postgres — apply will set it`) and `syncEnv` fills it
after the create. `apply` acts databases-before-applications (see *Apply acts
in dependency order*), so the database exists by the time the app's env is
written.
**The unresolved sentinel is never written.** Until it resolves, a derived var
carries a sentinel that is not a legal value; the executor **refuses** to write
one that never resolved, rather than writing a blank — an empty `DATABASE_URL`
boots every consumer pointed at nothing. Coolify mints a database's credentials
at create time and `internal_db_url` is a model accessor built from them (not
from a running container), so the URL is expected the moment the create returns;
if a given Coolify only publishes it once the container is up, the refusal names
the app and the database and says to re-run once it is up — and the second run
resolves it as an ordinary update, because by then the database is live and the
diff fills it. It is graceful either way, and single-pass in the expected one.
**Validation is at plan time, in the same voice as the dead-`generated_secrets`
check.** A `${resource:X.url}` naming a database the manifest does not declare, or
an attribute other than `.url` , is a hard error before any write — refused by
every verb that opens a template (`apply`, `diff` , `capture` ), because a ref that
resolves against nothing is broken for all of them, not just the one about to
write.
**Scope.** This covers only databases cast itself declares and creates. It does
not touch a service that builds its own URL internally from magic vars against
its *own* bundled database (Coolify's umami is the example): there is no edge for
the manifest to declare there, and a value of cast's would never be read.
`generated_secrets:` and the two-pass bootstrap above **remain** for the residual
class — a provider-generated value that is genuinely not derivable (a service's
own generated credential). What leaves is `DATABASE_URL` / `REDIS_URL` : they stop
being store names at all, so the placeholder they held, and the `apply` refusal
that guarded it, simply cease to exist for them — there is nothing in the store
to overwrite, and nothing to re-encrypt on a rebuild-from-nothing.
feat(resolve): derive base-URL env vars from manifest domains via ${domain:...} (#66)
A public base URL an app reads (LANDING_BASE_URL, ADMIN_WEB_BASE_URL) is a
fact the manifest already states in `domains`/`service_domains` — the same
fields cast parses to reconcile Coolify domains. Hand-transcribing it into an
env template is a second copy that drifts (incubator's prod LANDING_BASE_URL
silently kept a pre-apex host). So a template can now say it directly:
LANDING_BASE_URL=${domain:landing}
ADMIN_WEB_BASE_URL=${domain:core.admin}
- ${domain:<app>} -> applications.<app>.domains[0]
- ${domain:<app>.<service>} -> applications.<app>.service_domains.<service>[0]
Symmetric with ${resource:...} (#60) — parse -> sentinel -> validate -> fill —
but a domain is PURE MANIFEST DATA, known at plan time, so it resolves fully in
desiredFromManifest against a map built from the manifest: no live read, no
executor deferral, no unresolved-at-write path. Domains are PUBLIC, so they
resolve to secret:false (printed in diffs) and read as plain literals
downstream (no diff.ts change). Not secrets: excluded from templateRefs, never
captured. assertDomainRefs is the single validation gate (apply/diff/capture),
refusing an undeclared app/service, a wrong-shape ref, or an empty/blank domain
list before the sentinel can escape. Applications only (Coolify 4.1.2 can't set
service domains). REPORTING_TZ-style operator literals stay literal.
Closes #66.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:02:33 +00:00
## Derived domains (`${domain:<app>[.<service>]}`)
Symmetric with `${resource:…}` , and for the same reason: a base URL an app is
told to call itself at is not a secret anybody authored — it is a domain the
manifest *already declares* , on the very resource cast is about to create. Hand-
transcribing it into an env template (incubator's `LANDING_BASE_URL` ,
`ADMIN_WEB_BASE_URL` ) makes a second copy that drifts from the `domains` /
`service_domains` cast parses anyway. So a template can name the domain
directly:
LANDING_BASE_URL=${domain:landing}
ADMIN_WEB_BASE_URL=${domain:core.admin}
- `${domain:<app>}` resolves to `applications.<app>.domains[0]` — the app's
**primary** domain.
- `${domain:<app>.<service>}` resolves to
`applications.<app>.service_domains.<service>[0]` — a compose app's primary
domain for that service.
The value is the domain string **verbatim** , scheme and all
(`https://new.heavyduty.builders`). **Applications only:** cast's service creates
send no domains, and Coolify 4.1.2 cannot set service domains at all (see
*Hostname overlay* and the service loop), so there is no service domain to
derive.
**The one way it differs from a derived resource URL — and it is the whole
design — is that a domain is not read back from a live box; it is PURE MANIFEST
DATA.** A `${resource:…}` URL needs the database Coolify made, so it defers: it
rides through the diff unresolved and the executor fills it after the create. A
domain is known the instant the manifest is parsed, so it resolves at **plan
time**, inside `desiredFromManifest` , against a map built straight from the
manifest — and it **always** resolves there. There is no deferral, no executor
step, no unresolved-sentinel that a diff or an apply can legitimately carry:
`fillDomainEnv` replaces the transient sentinel in the same plan, drops the
derived-domain marker, and what remains is a plain resolved value
indistinguishable from a literal. So the diff needs no domain-awareness, and
apply writes it like any other env var.
**It is public, not a secret.** Unlike a resolved resource URL (secret, never
printed), a derived domain resolves to `secret: false` — it prints in a diff like
any literal, because a public hostname is not a thing to hide. It is not a store
ref either: `capture` never goes looking for a store name called `domain:landing` ,
and it never enters `required` / `generated` .
**Validation is at plan time, in the same voice as the other ref checks** — and
refused by every verb that opens a template (`apply`, `diff` , `capture` ), because
a ref that resolves against nothing is broken for all of them. A `${domain:…}` is
a hard error, named before any write, when it points at an application the
manifest does not declare, when it omits a service on a compose app whose domains
live per service (it lists them), when it names a service on an app that declares
a plain `domains` list, when it names a service the app's `service_domains` does
not declare, or when the selected list is declared but empty. The sentinel never
escapes: the assert throws before `desiredFromManifest` returns.
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
## Drafts (`inventory --emit-draft`)
`inventory` with no repo sweeps an instance. `--emit-draft <dir>` writes that
sweep down as a draft of cast's **own inputs** — a manifest per project, env
templates, an `environments.yaml` carrying the `projects:` registry, an age store
per project, and `UNCAPTURED.md` .
**A draft is a PROPOSAL. It is never desired state, and `apply` never reads it.**
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL. Every other rule in this section follows
from that one, and each is enforced rather than merely stated:
| refusal | why |
| --- | --- |
| a **non-empty** target directory | emitted over a repo that has a manifest, a draft would overwrite a reviewed spec with a live box's accumulated cruft — the one direction nobody reviews. **Adoption is one-way.** |
| an **existing manifest** at the path it would write | the same invariant, once more at the file (`assertNoExistingManifest`). For a declared project the manifest *is* the truth; `cast inventory <org>/<repo>` reconciles it instead. |
| `--emit-draft` with a **repo positional** | with a repo, inventory reconciles against a manifest that already exists — exactly the case where a draft must not be written. |
| **no age recipient** (and no `--no-secrets` ) | a draft whose store was silently skipped *looks complete* : a manifest, templates full of `${REF}` s, and not one value anywhere. You would find out when `apply` refused, some time after the box those values were on stopped existing. |
| a project with **two populated environments** | a draft carries one environment per project. Picking would emit a blueprint of *half a box* that says nothing about the other half. `--environment` breaks the tie — as a **tiebreak, not a filter** : a project with one populated environment is drafted from it either way, or filtering by name would drop whole projects (each client site sits alone in Coolify's default `production` ) out of a blueprint that claims to describe the box. |
**Provider-generated names are placeheld, never copied.** This is `capture` 's
discipline (see above), applied to a verb that has no manifest to tell it which
names are generated — so it decides **by name** , in two families:
1. Coolify's per-instance magic vars — `SERVICE_FQDN_*` , `SERVICE_URL_*` ,
`SERVICE_PASSWORD_*` , `SERVICE_USER_*` , `SERVICE_BASE64_*` .
2. Any name carrying a **datastore** word (`DATABASE`, `DB` , `POSTGRES` , `PG` ,
`REDIS` , `MONGO` , …) *and* a **connection** word (`URL`, `URI` , `DSN` , `HOST` ,
`PORT` , `PASSWORD` , `USER` , …) as underscore-delimited segments —
`DATABASE_URL` , `UMAMI_DATABASE_URL` , `REDIS_URL_PROD` , `DB_HOST` .
Each such name is written as the literal `pending-coolify-generated` , listed in
the run's disposition table, and declared under the emitted manifest's
`generated_secrets:` — so a later `capture` placeholds it again with no flag to
remember. **Its live value is not written into any artifact.**
The rule errs **wide** , deliberately, because the two errors are not symmetric:
- over-match a real secret → it is placeheld, reported, and you put the value
back. Noisy, recoverable, **loud** .
- under-match a generated one → it is copied, and a box rebuilt from the draft
comes up **working** , reading and writing the *source box's* database, until
the day that box is deleted. Silent, unrecoverable, **quiet** .
It is a name-pattern rule, not a promise: a var that points at the source box
under a name cast does not recognize **will** be copied. The disposition table
(names and provenance, **never values** — same contract as the capture plan) is
what a reviewer reads to catch it.
**Every other live var becomes a `${REF}` **, with its value in the age store —
never a template literal. cast cannot know which of a box's vars are secret
(nobody wrote it down, which is why the verb exists), and the two mistakes are
again asymmetric: a non-secret in an encrypted store is untidy, a live API key
written as a literal into a manifest is a key in a git repo. One name carrying
**different values** on two resources is not a conflict cast resolves (one store
holds one value per name — see `capture` 's CONFLICT refusal): both are kept,
under `<RESOURCE>_<KEY>` refs, and the split is reported.
**`UNCAPTURED.md` is a first-class output, emitted on every run.** cast cannot
express everything a Coolify holds, and a blueprint that omits those things
without saying so is worse than no blueprint — in a disaster you would trust it
and rebuild a *different box* . Per resource, it names what was seen and could not
be written: `destination_id` (which Docker network — no destinations API in 4.1.2
to resolve it to the UUID `destination_uuid:` wants, #21 ), service hostnames (no
flat `domains` on a Coolify 4.1.2 service), Basic Auth / custom Traefik labels,
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
build and deploy command overrides, backup schedules (**a rebuild has no backups
until you declare them** — *not* because they cannot be read, which is what this
line used to say and #51 disproved, but because `inventory --emit-draft` has not
yet been taught to read them: `GET /databases/{uuid}/backups` answers, and `diff`
and `apply` now use it. Until the draft path does too, a blueprint still omits
them and still says so), database kinds cast
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
does not model (MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse — named,
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
never silently dropped), env var names a cast template cannot express, names
**reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*` — suppressed, never
copied; see [Reserved env var names ](#reserved-env-var-names-source_commit-coolify_ )),
and applications whose build pack the manifest has no vocabulary for (left *out*
of the manifest rather than fabricated into the nearest pack).
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
It also carries the table below, because that is the file someone will be reading
at the worst possible moment.
### What a blueprint still cannot restore
| | |
| --- | --- |
| control plane | `rig coolify install` ✅ |
| structure | draft → manifest PR → `apply` ✅ |
| secret **values** | the age store + your key ✅ |
| **data** | Coolify's DB backups → S3 ✅ (a separate path) |
| **the GitHub App private key** | ❌ re-create by hand |
| **S3 access keys** | ❌ re-mint by hand |
The last two are not in the state repo — correctly, it holds no live credentials
— and cannot be regenerated from it. A DR runbook that does not say so is not a
runbook.
**What the box cannot tell you, and cast therefore does not invent:** the
`<org>/<repo>` slug comes from an application's git remote (the only place a live
box knows it), so a project with **no application** — a lone service — has no repo
on the box at all. cast writes the bare project name as the registry key, and the
registry's own parse-time refusal (*"a registry key has no meaning without its
org"*) then stops the file being used until a human supplies it. That refusal is
the design: the alternatives are inventing an org, or leaving the project out of
the registry — and a project missing from the registry is one every fleet run
skips **in silence** . Likewise `github_apps` : nothing Coolify returns about an
application says which App cloned it, so cast binds every repo to the instance's
only GitHub App when there is exactly one (there is no other it could be), and
writes a `REVIEW-…` marker when there is not.
feat(destroy): a scoped teardown verb, gated in state (#43)
`apply` fails closed on an immutable field with "resolve manually" — which
meant a hand deletion in the Coolify UI, unscoped and unconfirmed, against an
instance whose token can see every project on it. That is how the wrong project
gets deleted.
`cast destroy <org>/<repo> --env <env> [--with-project]` is that act, scoped:
- MANIFEST-SCOPED. It deletes the resources the manifest declares in that
project and that environment, in reverse dependency order (applications →
services → databases). Anything else it finds is reported and LEFT STANDING —
that report is how a resource created outside cast gets discovered, and the
boxes in this fleet are multi-project by design.
- Not a flag on apply. `apply never deletes` is the invariant that makes it safe
to run on a schedule; apply.ts and diff.ts are untouched.
- REFUSES rather than no-ops: --all (always), a read-only instance, an absent
project (D-237 — an absent target must never read as a clean empty plan), a
manifest that declares nothing this environment holds, and --with-project
while anything undeclared is still in the project.
- The prod interlock lives in STATE, not argv: environments.<env>.destroy_allowed
in environments.yaml, absent = refuse. A flag is a thing you type without
reading; this is a line a human edits, commits and merges.
- The plan says what the delete COSTS: every database line carries its backup
schedule and when the last backup landed. A backups route cast cannot read
prints UNKNOWN and is treated as unrecoverable — it never rounds down to NONE.
- Last gate: the environment's name, typed (capture's ceremony).
Coolify's DELETE query params are sent explicitly (all four default to true):
delete_volumes, delete_connected_networks, delete_configurations — and
docker_cleanup=FALSE, because that one prunes the whole SERVER, and these boxes
host other people's production.
2026-07-14 22:33:46 +00:00
## Teardown (`cast destroy`)
`apply` never deletes. `destroy` is the one verb that does, and everything below
is what stands between it and the hand deletion in the Coolify UI it replaces.
### What a Coolify DELETE actually removes
`DELETE /applications|databases|services/{uuid}` takes four query parameters, and
**every one of them defaults to `true` **
(`{Applications,Databases,Services}Controller@delete_by_uuid`, v4.1.2 — each reads
`$request->boolean('delete_volumes', true)` and hands the four to
`DeleteResourceJob` ). cast sends all four **explicitly** : a default is a thing the
vendor gets to change, and three of these decide whether an operator's data still
exists afterwards.
| parameter | cast sends | what it does (`app/Jobs/DeleteResourceJob.php`, v4.1.2) |
|---|---|---|
| `delete_volumes` | `true` | `Application::deleteVolumes` → `docker volume rm -f <storage>` per persistent storage (`docker compose down -v` for a compose app), then deletes the persistent-storage rows. **This is what makes a database delete unrecoverable** — its data volume goes with it. |
| `delete_connected_networks` | `true` | `docker network disconnect <uuid> coolify-proxy` and `docker network rm <uuid>` (`Application::deleteConnectedNetworks`). The network is named for the **resource's own uuid** — it is *not* the shared destination network the rest of the box hangs off, so a multi-project server keeps its network and the other projects on it keep running. Left `false` , every delete would leak a dead network. |
| `delete_configurations` | `true` | removes the resource's configuration directory on the server. |
| `docker_cleanup` | ** `false` ** | It is not scoped to the resource at all: it dispatches `CleanupDocker` against the **server** — `docker container prune` , an image prune, `docker builder prune -af` (`app/Actions/Server/CleanupDocker.php`). The boxes in this fleet are multi-project by design and one of them hosts third-party production. A teardown of *our* project does not get to prune somebody else's build cache. Coolify runs its own scheduled cleanup. |
Independently of all four, the job also deletes the resource's **env vars** , file
storages, and — for a database — its SSL certificates and its **scheduled-backup
configurations** (`scheduledBackups()->delete()`). Backups already written to S3
are not touched by any of this; local backup files live under the storage the
delete removes.
**The delete is asynchronous.** The controller dispatches `DeleteResourceJob` onto
the `high` queue and answers `200 {"message": "…deletion request queued."}` . A 2xx
means *Coolify accepted the deletion* , not *the resource is gone* — which is why
`--with-project` polls `GET /projects/{uuid}/{env}` until the environment actually
reads back empty before it removes anything else, rather than racing the queue into
a `400` .
### Scope, order, and what is left standing
destroy deletes **the resources the manifest declares** , in this project and this
environment, in **reverse dependency order** (applications → services → databases —
`DESTROY_ORDER` in `src/destroy.ts` ; a database removed while an app still points at
it does not fail quietly, it fails as a restart loop). Anything else it finds is
**reported and left standing**: that report is how a resource created outside cast
gets discovered, and deleting it would make this an environment wipe.
It takes **no `--project`, no `--environment`, no `--resource`** . Those coordinates
exist to point cast at names somebody else chose in a UI — which is exactly the box
a delete must never be aimed at.
### The gates
- **`--all` is refused, always.** `apply --all` is safe to iterate because it is
idempotent and never deletes; `diff --all` because it only reads. A loop over a
delete is neither.
- **A read-only instance is refused** (`assertWritable`), like `apply` /`smoke`/
`server add` .
- **An absent project is refused** and names what *is* there — the D-237 family, and
doubly so here: an absent target reads back exactly like an empty one, and an empty
one gives this verb a plan that deletes nothing, which renders as a clean teardown
of an environment that is still standing. The same refusal fires when the manifest
declares nothing this environment actually holds.
- **`environments.< env > .destroy_allowed: true` is required, and absent means refuse.**
A `--yes` flag is not a gate; it is a thing you type without reading. The gate lives
in the private state repo — a line a human edits, commits and merges — for the same
reason `forbidden_var_patterns` does: *a change on one side must not be able to lower
its own guard.* It is `true` on an environment that is empty and being battle-tested,
and the cutover checklist **deletes it** the moment that environment carries real data.
- **The plan, then the environment's name, typed** — the ceremony `capture` uses. Names
and kinds, never values.
- **`--with-project` is refused up front** when anything cast did not declare is still
in the environment, or when another environment of the project holds resources.
Coolify refuses those deletes too (`400 Project has resources, so it cannot be
deleted.` / `400 Environment has resources…` — `ProjectController@delete_project` /
`@delete_environment` , both guarded by `isEmpty()` ), but it refuses them *after* the
declared resources are already gone.
### What a database line says
`GET /databases/{uuid}/backups` returns the backup configurations with their
executions eager-loaded (`ScheduledDatabaseBackup::…->with('executions')->get()` —
`DatabasesController@database_backup_details_uuid` ), so one call answers both halves
of the only question that matters at the prompt: *is this database backed up, and did
a backup ever actually land?* The vendored OpenAPI documents that response as the
literal string *"Content is very complex. Will be implemented later."* , so cast parses
the source's shape and **refuses to guess** : an envelope it does not recognize, or a
route that errors, prints `backup schedule: UNKNOWN` with the reason and is treated as
unrecoverable. It never rounds down to `NONE` — a database that *is* backed up must
never read as one that is not, and the reverse must never happen either.
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
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 `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
## Cloning a private manifest
`resolveCheckout` resolves git credentials **inside cast** , in a fixed order —
`gh` borrowed as a per-invocation credential helper, then
`GITHUB_TOKEN` /`GH_TOKEN`, then the ambient helper — rather than leaving it to
whatever the workstation's git config happens to do.
It matters because `gh auth login` **does not** wire git's credential helper
(that is `gh auth setup-git` , a separate act most people never run), so a
perfectly logged-in operator still fell through to git's interactive
username/password prompt — which GitHub no longer accepts — and got an error
about *the repository* rather than about the missing credentials. There is no
routing around it for prod: `--path` is refused there, so the clone is the only
path and its auth is mandatory.
`GIT_TERMINAL_PROMPT=0` is set on every path, so cast can never hang on or fall
into that prompt. The token is never placed in the clone URL or in
`http.extraheader` — both leak it into `ps` , and the latter persists it into the
clone's `.git/config` ; the helper reads it from the environment at run time, so
what lands in argv is the literal text `$CAST_GIT_TOKEN` . Note that the empty
`credential.helper=` reset clears **URL-scoped** helpers
(`credential.https://github.com.helper`, which is what `gh auth setup-git`
writes) as well as generic ones, so cast's chosen credential is genuinely the
one used — verified against a live private clone.
2026-07-11 13:13:19 +00:00
**Known limitations, not defects:**
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
- **~~Backup schedules are create-time only.~~** **Corrected (#51).** This entry
used to claim that a database's `backup` block was create-time only and kept
out of the diffed `fields` because *"live Coolify state doesn't expose it
back"*. The parenthesis was load-bearing and it was false — `GET
/databases/{uuid}/backups` is a route, and cast had been POSTing to it all
along without ever reading it. Backup schedules are now compared on every run
and written on create *and* update; see **Backup schedules** above for what
is still not compared (an unreadable answer, several schedules, the S3
target) and how each says so out loud. Kept here, struck through, because
this entry is *why nobody looked* : a limitation filed as a defect gets fixed,
and a defect filed as a limitation does not.
2026-07-11 13:13:19 +00:00
- **A service's `domains` cannot be applied via the API in Coolify 4.1.2,
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
and is deliberately kept out of the diffed `fields` for idempotency.** (This
used to cite backup schedules as its precedent; it can't any more — that
reasoning was disproved above. This one was re-checked and holds: Coolify
4.1.2 exposes no flat `domains` on a service, on any route. If that is ever
disproved the same way, `domains` belongs in `fields` too.) The `/services`
2026-07-11 13:13:19 +00:00
create/update payload takes a structured per-container `urls` list, not
the manifest's flat `domains: string[]` , and the manifest has no
per-container name to build that list correctly from — so cast
drops it rather than send a malformed payload. Live Coolify service state
doesn't expose a flat `domains` back either, so if it stayed in `fields`
every domain-bearing service would diff as a perpetual update and every
`apply` would needlessly restart it — `desiredFromManifest` drops
`domains` from the service's `fields` and **warns**
(`service < name > declares domains (...), but apply cannot set them on
Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`)
once per run for every service that declared any. Set service hostnames
in the Coolify UI by hand.
2026-07-14 22:24:10 +00:00
- **"Include Source Commit in Build" cannot be enabled via the API in Coolify
4.1.2 — `apply` warns instead.** A dockercompose application whose build
consumes `SOURCE_COMMIT` as a **build arg** only receives it if the
per-application setting *Include Source Commit in Build* is on; Coolify
withholds it by default to preserve build cache. That setting
(`ApplicationSetting.include_source_commit_in_build`, default `false` ) has no
API surface in 4.1.2: it appears in **zero** API controllers, and both the
create and PATCH allowlists in `ApplicationsController.php` (l.914, l.2368)
reject unrecognized keys outright (`"This field is not allowed."`), so cast
cannot smuggle it through — sending it would fail the whole request. Its only
writer is the Livewire *Advanced* tab
(`app/Livewire/Project/Application/Advanced.php:128`), i.e. a human in the UI.
Contrast `connect_to_docker_network` , which *is* in both allowlists and which
`apply` therefore does set on create. So `desiredFromManifest` **warns** once
per dockercompose application (`application < name > builds with dockercompose,
but apply cannot enable "Include Source Commit in Build" on Coolify 4.1.2 …`)
rather than pretending it is desired state. Enable it in the Coolify UI and
redeploy if your image bakes the SHA in at build time.
**This toggle gates the build-time arg only.** Coolify's **runtime** injection
of `SOURCE_COMMIT` is unconditional with respect to it
(`ApplicationDeploymentJob.php:2949` — `if (! $forBuildTime || …)`
short-circuits true at runtime), so a service that reads
`process.env.SOURCE_COMMIT` per request does **not** need the toggle at all.
What silently suppresses *that* value is an application-level env var of the
same name (`ApplicationDeploymentJob.php:2950`) — a distinct trap, and the
actual cause of a live box reporting `{"sha":"unknown"}` .
2026-07-11 13:13:19 +00:00
- **The redis default image is an unverified extrapolation.** Coolify's
"New Resource" wizard drives PostgreSQL version selection through a
verified `postgres:<version>-alpine` image string; Redis has no version
picker in that same wizard, so cast's `redis:<version>-alpine`
guess for a manifest-declared `version` is the same Docker Hub tag
convention applied by analogy, **not confirmed against a live Coolify
instance**. Recommendation: leave a manifest database's `version` unset
for `redis` until this has been verified once against bootstrap, letting
Coolify pick its own default image instead of risking a bad tag.