Skip to content

fix(deploy): railway-apply never deletes or overwrites hand-set variables - #104

Merged
jayleaton merged 1 commit into
mainfrom
fix/railway-apply-keeps-hand-set-vars
Oct 23, 2026
Merged

jayleaton merged 1 commit into
mainfrom
fix/railway-apply-keeps-hand-set-vars

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Bug

The Railway project's variables are managed (variables: {managed: true, ignore: [...]}), so make railway-apply deletes every variable the IaC graph neither sets nor ignores, and resets every variable it does set. On main:

  • The template's own optional settings (TRUSTED_ORIGINS, INTERNAL_URL, APP_NAME, AUTH_SECRET_GRACE, SECRETS_REFRESH_INTERVAL/SECRETS_MAX_STALENESS, ADMIN_TRUSTED_ORIGINS, ADMIN_REQUIRE_2FA, ...) are neither set nor ignored, so a value set in the dashboard is deleted on the next apply.
  • PUBLIC_URL/ADMIN_PUBLIC_URL are always the Railway domain and FROM_EMAIL is always email.from, so a custom domain or a per-environment sender gets reset by every apply.

A derived project found this before its first production apply.

Reproduction

The new offline checker, run against main's graph code (TODO handling stubbed out, since main has no per_environment):

production: web sets 6, backup sets 10; 8 ignore patterns; left unset (TODO): []
production (admin and backups on): web sets 6, admin sets 7, backup sets 11; 14 ignore patterns; left unset (TODO): []
check-vars: production: web/APP_NAME is neither set by the graph nor ignored
check-vars: production: web/AUTH_SECRET_GRACE is neither set by the graph nor ignored
check-vars: production: web/INTERNAL_URL is neither set by the graph nor ignored
check-vars: production: web/SECRETS_MAX_STALENESS is neither set by the graph nor ignored
check-vars: production: web/SECRETS_REFRESH_INTERVAL is neither set by the graph nor ignored
check-vars: production: web/TRUSTED_ORIGINS is neither set by the graph nor ignored
check-vars: production: backup/ADMIN_DATABASE_URL is neither set by the graph nor ignored
check-vars: production: backup/BACKUP_S3_PATH_STYLE is neither set by the graph nor ignored
check-vars: production: admin/ADMIN_APP_NAME is neither set by the graph nor ignored
check-vars: production: admin/ADMIN_AUDIT_RECONCILE_AFTER is neither set by the graph nor ignored
check-vars: production: admin/ADMIN_BOOTSTRAP_EMAIL is neither set by the graph nor ignored
check-vars: production: admin/ADMIN_REQUIRE_2FA is neither set by the graph nor ignored
check-vars: production: admin/ADMIN_TRUSTED_ORIGINS is neither set by the graph nor ignored
check-vars: production: admin/SECRETS_MAX_STALENESS is neither set by the graph nor ignored
check-vars: production: admin/SECRETS_REFRESH_INTERVAL is neither set by the graph nor ignored
check-vars: 15 problems: apply could delete or overwrite a hand-set variable (list it in platform.yaml unmanaged_variables)
exit status 1

Fix

  • Defaults: unmanaged_variables in deploy/platform.yaml now lists the template's optional settings. Without admin_bootstrap_email, admin/ADMIN_BOOTSTRAP_EMAIL is ignored, and so is backup/ADMIN_DATABASE_URL when the graph doesn't wire it.
  • Per-environment values: per_environment.<env>.public_url and admin_public_url take railway (the default) or an https origin, and from_email defaults to email.from. A value starting with TODO is left out of the graph and added to variables.ignore, so apply neither sets nor resets it. Plan and apply print left as it is (TODO ...). Bad values fail validation.
  • make deploy-vars (deploy/cmd/check-vars, a command, not a test) builds every environment's graph exactly as the CLI receives it, once as configured and once with admin and backups on. It fails when a .env.example variable that a service reads (per-service -reads in the Makefile) is neither set nor ignored, or is both set and ignored, or when a TODO would be set. make deploy-check, make check-fast and CI's deploy task run it, and .env.example and Makefile changes now trigger the deploy checks.
  • README "Deploy to Railway" and AGENTS.md explain the rule: a new setting goes in the graph or in unmanaged_variables.

Not included (per-project, not template): any project's own variable list.

Verified

$ make deploy-vars
production: web sets 6, backup sets 10; 21 ignore patterns; left unset (TODO): []
production (admin and backups on): web sets 6, admin sets 7, backup sets 11; 27 ignore patterns; left unset (TODO): []
check-vars: ok: every one of 33 app variables is set by IaC or ignored, never both, in every environment
  • With per_environment.production.public_url/from_email set to TODO values: web sets 4 (no PUBLIC_URL/FROM_EMAIL), both are listed as left unset and ignored, and the check is ok.
  • Negative cases: http://…/path as public_url, from_email: nope, and an environment that isn't configured are all rejected. web/PUBLIC_URL in unmanaged_variables fails with "set by the graph and also ignored".
  • make check-fast (full run, since the Makefile changed): ok. make generate lint test deploy-check: 0 issues, -race tests on Postgres pass, coverage floors met. make actionlint: ok.
  • No Railway or Doppler project was touched; everything here is offline.

Also: refuse the example sender before deploying (second commit)

deploy/platform.yaml defaults email.from to noreply@example.com, and every Railway service runs with APP_ENV=production. With #103 (production refuses an unset or reserved-domain FROM_EMAIL), a fresh railway-apply would therefore deploy services that refuse to boot. Now:

  • railway-plan and railway-apply fail before any Railway call when an environment's effective sender (per_environment.<env>.from_email, else email.from) is on a reserved domain. This uses the same rule as fix(config): production refuses an unset or example FROM_EMAIL #103: example.com/.net/.org, .example, .test, .invalid, .localhost and their subdomains, in a@b or Name <a@b> form. A TODO from_email isn't checked, because apply leaves that variable alone.
  • railway-doctor reports it as a failing email.from check, with the fix, ahead of the login check.

Proof, using a fake railway CLI (RAILWAY_BIN) that logs every call:

$ railway-setup plan   (default platform.yaml)
railway-setup: FROM_EMAIL would be an example sender (production: noreply@example.com), which production refuses at boot: set email.from in deploy/platform.yaml to a sender on your verified email domain (per environment: per_environment.<env>.from_email; "TODO" keeps the FROM_EMAIL the environment holds)
exit=1 calls=[]                      # same for apply
"Acme <noreply@mail.example.org>"  -> refused, calls=[]
noreply@acme.io                    -> passes the check, continues to the login check
example email.from + production from_email: "TODO: ..." -> passes the check
doctor --json -> {"name":"email.from","status":"fail",...,"fix":"set email.from ..."}, exitCode 4

make check-fast and make deploy-check (including deploy-vars): ok.

Risk

The first railway-apply after merging plans nothing new for a default config: the same variables are set, and the ignore list only grows. Someone who relied on apply deleting one of the newly ignored names would have to delete it by hand now. A project that adds settings must list them; make deploy-vars tells them which ones.

🤖 Generated with Claude Code

…bles

Rebased onto main, which already has per_environment manual values and
make deploy-vars (#111). What was still missing:

- check-fast and CI ran the deploy/ vet, lint and names but not
  deploy-vars, and a change to .env.example or the Makefile (its inputs)
  did not trigger the deploy checks. Both now do.
- That check fails on main: sign-in with Google/Apple and object storage
  added names an apply would delete. Their secrets are web live secrets,
  so they join web's Doppler-owned names (seeded only when set on
  Railway); the graph now ignores every Doppler-owned copy; the
  non-secret halves (GOOGLE_CLIENT_ID, APPLE_CLIENT_ID, APPLE_TEAM_ID,
  STORAGE_S3_REGION, STORAGE_S3_PATH_STYLE) are unmanaged_variables.

Dropped from the original: per_environment TODO values (main has manual)
and the example-sender guard (main does not refuse an example FROM_EMAIL
at boot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton force-pushed the fix/railway-apply-keeps-hand-set-vars branch from f86ff05 to a73a73a Compare October 23, 2026 12:05
@jayleaton

Copy link
Copy Markdown
Owner Author

Rebased onto main (00206f3) and cut down to what main is still missing. Most of this PR shipped in #111 (b728c98): per_environment values (as manual, not TODO), the default unmanaged_variables, ADMIN_BOOTSTRAP_EMAIL ignored while unset, and deploy/cmd/check-vars / make deploy-vars.

Kept (new head a73a73a):

  • check-fast and CI now run make deploy-vars in the deploy step. A change to .env.example or the Makefile triggers the deploy checks, both in check-fast and in the ci.yml paths filter.
  • Wiring it in showed that main currently fails deploy-vars (12 problems). Sign-in with Google/Apple and object storage added GOOGLE_*, APPLE_* and STORAGE_S3_* names that the next apply deletes if they are set on Railway. Their secrets are web live secrets, so they are now web's Doppler-owned names (seeded only when set on Railway), and the graph ignores every Doppler-owned copy. The non-secret halves are unmanaged_variables. The result: check-vars: ok: 44 app variables (production, and staging,production + admin).
  • Proof: I removed TRUSTED_ORIGINS from unmanaged_variables as a planted defect. main's check-fast said ok; this branch's says FAIL (deploy).

Dropped: the TODO values (main has manual) and the example-sender guard (main's apps don't refuse an example FROM_EMAIL at boot, so the premise doesn't hold here).

@jayleaton
jayleaton merged commit 0553a2b into main Oct 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant