Derive DATABASE_URL/REDIS_URL from the database resource cast created — delete the two-pass bootstrap instead of automating it #60
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#60
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The idea
The manifest already declares both ends of this edge and never connects them:
generated_secretsis a set of names where what it means is a set of edges:DATABASE_URL_PRODis not merely "a value someone else generates", it is the URL of thepostgresresource declared six lines up.manifest.ts:95says so out loud, in the schema's own comment:The manifest knows. cast doesn't ask.
internal_db_urlappears nowhere in cast's source —cli.ts:2063creates the Postgres/Redis resource and never reads back the one thing Coolify minted while doing it.So: let the manifest name the edge, and let
applyresolve it from the live resource.resolve.tsalready parses${…}refs (templateRefs) and already validates that every declared generated name is actually referenced by some template (resolve.ts:239-258) — the exact dead-reference check this needs, pointed the other way: a${resource:X.url}ref naming a database the manifest does not declare is a hard error at plan time.Why this beats #48
#48 asks for a pass 2 — a verb that captures the generated value back into the age store after
applycreates it. That automates the hand-dance. This deletes it.The store should not hold
DATABASE_URLat all. It 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. Storing it means keeping a copy of a value you do not own, in a second place, that can silently disagree with the first — which is precisely the failure in #47.What derivation deletes, rather than automates:
agere-encrypt against prodsecret DATABASE_URL differson every plan#47 is still worth keeping as a backstop for any residual generated secret that is genuinely not derivable (a service's own generated credential). But its live instance — the one currently holding a loaded gun on
heavy-duty/incubator's prod box — dies with this change, because the two names causing it stop existing in the store.The security argument, which I think is the real one
Today the prod Postgres password:
apply, with the prod key injected from a password manager.Under derivation it goes: minted by Coolify → read by cast over the API → written to the app's env in the same Coolify. It never lands in git, never enters a terminal, never touches disk, and no key needs to exist to read it back. The
agestore shrinks to the things a human actually authored — Mailgun, OpenRouter, Turnstile, the admin credentials — which is what a secret store is for.It also makes rotation work: rotate the DB password in Coolify and the next
applyfollows it. Today the store silently diverges and the next apply reverts prod to the old value.Ordering — this needs #45
Derivation only works if the database exists before the app's env is resolved, so this requires #45 (
applycreates applications before the databases they depend on). #45 is currently justified by "a first apply always deploys against nothing"; this gives it a second, harder reason — without it,${resource:postgres.url}has nothing to resolve against on a from-nothing apply. Land #45 first.Open question (cheap to answer)
Is
internal_db_urlpopulated at create time, before the database has started? The value is known to exist and be readable on a running resource — theheavy-duty/incubatorrunbook reads it withGET /api/v1/databases | jq '.[].internal_db_url', and that is how prod's URLs were obtained. What is unverified is whether it is present on the create response / an immediate GET, which is what a single-pass from-nothing apply needs. Coolify generates the credentials at create time, so it should be — but it should be checked, not assumed. If it turns out to require a started resource,applyneeds a small wait-for-resource step, not a redesign.Scope
Umami stays a UI act, and that is correct (incubator D-253). Coolify 4.1.2's umami service builds its own
DATABASE_URLinside its compose file from magic vars it generates against umami's own bundled Postgres. There is no edge for us to declare there, and a value of ours would never be read. This proposal covers only resources cast itself declares and creates.Suggested shape
${resource:<name>.url}in an env template resolves to the declared database's internal URL (internal, not external: apps are on the Docker network viaconnect_to_docker_network).${resource:…}ref naming an undeclared database is a hard error, with the same "this guards nothing" voice asresolve.ts:250.diffrenders the derived value as derived, and — like every other secret — never prints it.generated_secretsremains for the residual class (non-derivable provider-generated values), andDATABASE_URL_*/REDIS_URL_*leave it.Filed off the
heavy-duty/incubatorprod migration (Task 8 step 5), where the hand-ageprocedure this replaces is currently blocking a secondapplyagainst a live prod box.Resolution must be per-resource, not environment-wide — a note to pin the design.
generated_secretsanddatabasesare declared at the environment level of the manifest, which makes them look project-wide. They are not: env reaches a resource only through that resource's ownenv_template(resolve.tscollect()—if (!template) return). There is no project-wide env layer in cast, and that is a security property, not an accident: incubator'slandingresource has noenv_template, so it carries zero secrets, and a static site has no business holding a database credential.So
${resource:postgres.url}must resolve through the same per-template path, gated by the existing dead-ref check (resolve.ts:239-258): only a resource whose template actually references the database gets the URL. Do not implement this as "inject the derived DB URL into every app in the environment" — that would hand the credential tolanding, which is exactly the blast-radius the current per-resource design avoids. The derived value rides the env_template graph like any other ref; a resource that doesn't ask for it never sees it.