cast can write an env var that silently suppresses Coolify's own injection of it (SOURCE_COMMIT, COOLIFY_*) — and it fails green #50

Closed
opened 2026-07-14 20:54:56 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-14 20:54:56 +00:00 (Migrated from github.com)

The trap

Coolify injects a set of values into every application's runtime environment itself — SOURCE_COMMIT, and the COOLIFY_* family. Reading ApplicationDeploymentJob (v4.1.2) turned up the guard that matters:

Coolify skips its own injection of a name if the application already carries an env var of that name.

So an application env var named SOURCE_COMMIT — even, especially, an empty one — does not merely fail to help. It silently suppresses the value Coolify would otherwise have provided, and it does so on a deploy that is green, whose health checks pass, and whose only symptom is that the one endpoint that reports which commit is running (/version, reading process.env.SOURCE_COMMIT at request time) reports unknown. That is the endpoint the provisioning runbook gates a production cutover on. It fails green. (Written up as D-266 in heavy-duty/incubator#10.)

Why this is cast's problem, and not just a compose-file footgun

Anything that writes env vars can set this trap, and cast is a thing that writes env vars. Three live paths, none of them guarded — grep -rn "SOURCE_COMMIT\|COOLIFY_" src/ returns nothing:

  1. draft will copy it off a live box. isProviderGenerated (draft.ts:113) is the only filter on what a live env var becomes in a drafted manifest, and it recognizes exactly two families: SERVICE_(FQDN|URL|USER|PASSWORD|…) and datastore-connection names. SOURCE_COMMIT splits to [SOURCE, COMMIT] — no datastore word, no connection word, no SERVICE_ prefix → captured verbatim, with its live value. The box we drafted from carries exactly such an orphan SOURCE_COMMIT, empty. So drafting a working box reproduces the trap in the new box's manifest, and the next apply writes it. Same for any COOLIFY_* var: the COOLIFY_MAGIC regex at draft.ts:80 matches SERVICE_ only.

  2. capture will store it if a manifest's env template refers to it — no name is off-limits.

  3. apply will write it. syncEnv (cli.ts:2129) bulk-UPSERTs whatever the resolved template holds.

And diff files it under the wrong heading: a SOURCE_COMMIT sitting on a live app that the manifest does not declare is reported as a remove-candidate orphan var — the category whose documented meaning is "apply never removes these; read them by eye." It gets read as cosmetic residue. It is not residue. It is an active suppression of a platform-provided value, and it is a bug, never cosmetic drift.

Scope, and how this differs from #46

#46 asks whether "Include Source Commit in Build" can be set through the API. That is the build-time half, and it is now settled: the toggle gates only the build arg (if (! $forBuildTime || $settings->include_source_commit_in_build)), the field is ApplicationSetting.include_source_commit_in_build, its only writer in v4.1.2 is the Livewire Advanced tab, it appears in zero API controllers, and PATCH /applications/{uuid} enforces a 68-field allowlist that rejects unknown fields outright. So: not settable via the API, and — for a service that reads the SHA at request time — not needed, because the runtime injection is unconditional.

This issue is the runtime half, which #46 does not cover and which is the one that actually broke a box: not "can cast turn the toggle on" but "cast can write an env var that turns Coolify's own injection off, and nothing in cast knows those names are special."

Suggested

A single reserved-name rule, applied at every place cast touches an env var. Name shape: SOURCE_COMMIT, or anything matching ^COOLIFY_.

  • apply / resolve — refuse. A resolved env template that declares a reserved name fails the run before any write, naming it:

    refusing apply: core declares env var SOURCE_COMMIT, which Coolify injects itself at runtime. An application-level var of that name suppresses Coolify's injection — the deploy stays green and /version reports unknown. Remove it from the env template.

    Presence, not value — exactly the rule forbidden_var_patterns already uses (envtemplate.ts:77: "a forbidden var set to false still refuses the apply"). This is the same mechanism, but it is not a per-environment state setting: it is a property of Coolify, true on every box, and so it belongs in cast's own code rather than in something a manifest change could lower.

  • draft / capture — never copy one. Add the reserved names to the disposition machinery as their own provenance (suppressed, say): excluded from the template, and listed in the uncaptured report with the reason — the report that exists precisely so that what cast declines to carry is stated out loud rather than dropped.

  • diff — promote it out of the orphan list. A reserved name on a live resource is not a remove-candidate; it is a finding. It should print as a warning with the consequence attached, so the operator deletes it in the UI rather than reading past it. (apply never deletes still holds — cast reports it, the human removes it.)

  • smoke writes an env var too; assert it can never pick one of these names.

The value of doing all four together is that the rule is then true of cast, not of one code path: cast will not write a name whose meaning belongs to the platform, and it will tell you when it finds one.

## The trap Coolify injects a set of values into every application's runtime environment itself — `SOURCE_COMMIT`, and the `COOLIFY_*` family. Reading `ApplicationDeploymentJob` (v4.1.2) turned up the guard that matters: **Coolify skips its own injection of a name if the application already carries an env var of that name.** So an application env var named `SOURCE_COMMIT` — even, especially, an **empty** one — does not merely fail to help. It **silently suppresses the value Coolify would otherwise have provided**, and it does so on a deploy that is green, whose health checks pass, and whose only symptom is that the one endpoint that reports which commit is running (`/version`, reading `process.env.SOURCE_COMMIT` at request time) reports `unknown`. That is the endpoint the provisioning runbook gates a production cutover on. It **fails green**. (Written up as D-266 in [heavy-duty/incubator#10](https://github.com/heavy-duty/incubator/pull/10).) ## Why this is cast's problem, and not just a compose-file footgun **Anything that writes env vars can set this trap, and cast is a thing that writes env vars.** Three live paths, none of them guarded — `grep -rn "SOURCE_COMMIT\|COOLIFY_" src/` returns **nothing**: 1. **`draft` will copy it off a live box.** `isProviderGenerated` (`draft.ts:113`) is the only filter on what a live env var becomes in a drafted manifest, and it recognizes exactly two families: `SERVICE_(FQDN|URL|USER|PASSWORD|…)` and datastore-connection names. `SOURCE_COMMIT` splits to `[SOURCE, COMMIT]` — no datastore word, no connection word, no `SERVICE_` prefix → **captured verbatim, with its live value**. The box we drafted from carries exactly such an orphan `SOURCE_COMMIT`, empty. So drafting a working box reproduces the trap in the new box's manifest, and the next `apply` writes it. Same for any `COOLIFY_*` var: the `COOLIFY_MAGIC` regex at `draft.ts:80` matches `SERVICE_` only. 2. **`capture` will store it** if a manifest's env template refers to it — no name is off-limits. 3. **`apply` will write it.** `syncEnv` (`cli.ts:2129`) bulk-UPSERTs whatever the resolved template holds. And **`diff` files it under the wrong heading**: a `SOURCE_COMMIT` sitting on a live app that the manifest does not declare is reported as a `remove-candidate` orphan var — the category whose documented meaning is *"apply never removes these; read them by eye."* It gets read as cosmetic residue. It is not residue. **It is an active suppression of a platform-provided value, and it is a bug, never cosmetic drift.** ## Scope, and how this differs from #46 #46 asks whether *"Include Source Commit in Build"* can be set through the API. That is the **build-time** half, and it is now settled: the toggle gates only the build **arg** (`if (! $forBuildTime || $settings->include_source_commit_in_build)`), the field is `ApplicationSetting.include_source_commit_in_build`, its only writer in v4.1.2 is the Livewire Advanced tab, it appears in **zero** API controllers, and `PATCH /applications/{uuid}` enforces a 68-field allowlist that rejects unknown fields outright. So: **not settable via the API, and — for a service that reads the SHA at request time — not needed**, because the runtime injection is unconditional. This issue is the **runtime** half, which #46 does not cover and which is the one that actually broke a box: not *"can cast turn the toggle on"* but *"cast can write an env var that turns Coolify's own injection off, and nothing in cast knows those names are special."* ## Suggested A single reserved-name rule, applied at every place cast touches an env var. Name shape: `SOURCE_COMMIT`, or anything matching `^COOLIFY_`. - **`apply` / `resolve` — refuse.** A resolved env template that declares a reserved name fails the run before any write, naming it: > refusing apply: `core` declares env var `SOURCE_COMMIT`, which Coolify injects itself at runtime. An application-level var of that name **suppresses Coolify's injection** — the deploy stays green and `/version` reports `unknown`. Remove it from the env template. Presence, not value — exactly the rule `forbidden_var_patterns` already uses (`envtemplate.ts:77`: *"a forbidden var set to `false` still refuses the apply"*). This is the same mechanism, but it is **not** a per-environment state setting: it is a property of Coolify, true on every box, and so it belongs in cast's own code rather than in something a manifest change could lower. - **`draft` / `capture` — never copy one.** Add the reserved names to the disposition machinery as their own provenance (`suppressed`, say): excluded from the template, and listed in the *uncaptured* report with the reason — the report that exists precisely so that what cast declines to carry is stated out loud rather than dropped. - **`diff` — promote it out of the orphan list.** A reserved name on a live resource is not a `remove-candidate`; it is a finding. It should print as a warning with the consequence attached, so the operator deletes it in the UI rather than reading past it. (`apply never deletes` still holds — cast reports it, the human removes it.) - **`smoke`** writes an env var too; assert it can never pick one of these names. The value of doing all four together is that the rule is then true of cast, not of one code path: *cast will not write a name whose meaning belongs to the platform, and it will tell you when it finds one.*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#50
No description provided.