This should have shipped with the extraction PR and did not: the file was written after that commit was already pushed, so it landed nowhere. The infra PR meanwhile removed the same content from that repo's README, so between the two merges these notes existed only in git history. They are the expensive part of the executor — apply's guarantees (never deletes, never recreates a database, fails loudly on un-updatable drift), the dockercompose build pack, hostname-overlay shapes, and each place Coolify 4.1.2 misbehaves, with citations verified against coollabsio/coolify v4.1.2 and the vendored OpenAPI rather than its published docs. Linked from the README so it is found before someone changes apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.4 KiB
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.
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.
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
service_domains:
api: ["https://api.widget.example.com"]
env_template: core.prod.env.template
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
string, not the structured array the write side accepts — parsed
defensively as JSON back into the internal map, degrading to "field omitted"
(not a crash) on anything that doesn't parse as a well-formed array of
{name, domain}. Projecting both is what keeps a matching re-apply a true
no-op instead of a spurious PATCH + stack redeploy every run. This parsing
path is unverified against a real Coolify instance — the read-back is only
confirmed by an overlay apply followed by an overlay-edit re-apply against a
live instance, which has not yet happened. If a live instance turns out not to expose
docker_compose_domains on read, apply's idempotency guarantee for the
domains half breaks and needs the same warn-and-drop treatment as service
domains below — that also removes the cutover mechanism (domains no longer
flip via re-apply), so it would need a runbook amendment, not a silent fix.
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[]:
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;
diffreports the orphan as such until that act happens. - Apply never recreates a database resource under any circumstances.
- On drift in a field the API cannot update in place (
build_pack, a database'stype/version, a service'stype), apply fails loudly naming the field rather than recreating. - Apply ends every mutated resource with a restart/redeploy
(
instant_deployon create,/deployor/restarton 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. - Direction is one-way, manifest → Coolify, always.
- Environment guards:
applyrefuses if a var matching that environment'sforbidden_var_patternsis present in the resolved env at all, regardless of value — "off" means absent, notfalse(see the README).applyalso refuses--pathcombined with--env prod: prod always reads the default branch, so a feature-branch checkout can never reach it.
Known limitations, not defects:
- Backup schedules are create-time only. A manifest database's
backupblock (frequency,retention) is applied only when the database is first created; it is deliberately kept out of the diffedfields(live Coolify state doesn't expose it back, so diffing it would flag spurious drift every run — breaking idempotency). Changing a schedule on an existing database is a runbook act, done by hand in the Coolify UI. - A service's
domainscannot be applied via the API in Coolify 4.1.2, and is deliberately kept out of the diffedfieldsfor the same idempotency reason as backup schedules above. The/servicescreate/update payload takes a structured per-containerurlslist, not the manifest's flatdomains: 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 flatdomainsback either, so if it stayed infieldsevery domain-bearing service would diff as a perpetual update and everyapplywould needlessly restart it —desiredFromManifestdropsdomainsfrom the service'sfieldsand 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. - The redis default image is an unverified extrapolation. Coolify's
"New Resource" wizard drives PostgreSQL version selection through a
verified
postgres:<version>-alpineimage string; Redis has no version picker in that same wizard, so cast'sredis:<version>-alpineguess for a manifest-declaredversionis the same Docker Hub tag convention applied by analogy, not confirmed against a live Coolify instance. Recommendation: leave a manifest database'sversionunset forredisuntil this has been verified once against bootstrap, letting Coolify pick its own default image instead of risking a bad tag.