Skip to content

feat(deploy): one-config Railway setup with Go IaC (deploy/platform.yaml) - #39

Merged
jayleaton merged 3 commits into
mainfrom
feat/railway-setup
Oct 22, 2026
Merged

jayleaton merged 3 commits into
mainfrom
feat/railway-setup

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

One file decides the Railway project. make railway-apply makes Railway match it using Railway Infrastructure as Code with Go authoring (beta). This replaces the legacy railway.json, which Railway stops reading on 2026-12-01.

deploy/                          own Go module: railway-go-sdk + yaml never enter the app go.mod
├── platform.yaml                admin: false · environments: production | staging,production | staging,qa,production · previews: false
├── platform/                    pure: YAML → validated Config → IaC graph per environment (tested, 95% cov)
├── .railway/railway.go          40-line shim the Railway CLI evaluates (railway config plan|apply --environment X)
└── cmd/railway-setup            what IaC can't do: project, empty environments, generated domains,
                                 sealed bootstrap secrets (AUTH_SECRET, DOPPLER_TOKEN placeholder,
                                 SECRETS_PROVIDER), PR-deploy toggle, railway up, /healthz verify
docs/railway-go-iac-issues.md    14 ready-to-file Go-beta / CLI issue drafts (not filed)

Per environment, the graph contains:

  • web + postgres; with admin: true, also admin + its own admin-postgres;
  • Dockerfile builds from the repo root, with monorepo watch paths;
  • /server migrate (or /admin migrate) as the pre-deploy command, and /healthz gating deploys;
  • DATABASE_URL / ADMIN_DATABASE_URL as reference variables;
  • CPU and memory caps, and a pinned replica count.

Make targets: railway-plan, railway-apply (CONFIRM_DESTRUCTIVE=1 to allow deletes), railway-secrets (Doppler tokens, sealed), railway-up (upload mode, repo: ""), railway-verify. make ci now runs deploy-check, and the CI Go job vets, lints and tests deploy/.

apps/admin/railway.json is removed: IaC carries its Dockerfile path, watch paths, pre-deploy and healthcheck. apps/admin/Dockerfile drops RUN --mount=type=cache, the same fix as the root Dockerfile (Railway's builder rejected it).

Secrets layout (README "Where secrets live, and rotating after an incident"):

Secrets use packages/secrets' exact names: SECRETS_PROVIDER (env until a real token exists, then doppler) and DOPPLER_TOKEN (sealed, one per service and environment; DOPPLER_CONFIG/DOPPLER_API_URL are optional and not set). /healthz is only Railway's deploy gate, never a liveness probe: it answers 503 when Doppler secrets go stale, and restarts happen only on process exit. This is documented in graph.go and the README.

Evidence

Real test in JJ's Railway account, project ai-patterns-go-iac-test-20261022 (workspace "JJ Eaton's Projects", Pro), CLI 5.71.0, SDK v0.6.0:

Step platform.yaml Result
1 environments: production project + web + postgres created; AUTH_SECRET/RESEND_API_KEY sealed, DOPPLER_TOKEN placeholder, SECRETS_PROVIDER=env; re-plan: ✓ Your Railway configuration is already up to date.
2 staging,production staging created empty and fully provisioned; production untouched (no changes)
3 staging,qa,production + admin: true qa created; admin + admin-postgres added to all 3; 6 databases running; re-plan up to date in all 3
guard admin: false without CONFIRM_DESTRUCTIVE=1 apply refused (Destructive Railway configuration changes require explicit confirmation), all 4 services still there
previews previews: true {"prDeploys":true,"baseEnvironment":{"name":"staging"}}, re-plan up to date
secrets DOPPLER_TOKEN_WEB_QA=… make railway-secrets qa/web DOPPLER_TOKEN sealed, SECRETS_PROVIDER=doppler; next apply keeps it

Deploys (upload mode, make railway-up). The Railway GitHub app has no access to this private repo, see below. make railway-verify:

staging     web    https://web-staging-8e89.up.railway.app/healthz -> 200 {"data":{"status":"ok"},"error":null}
qa          web    https://web-qa-128f.up.railway.app/healthz -> 200 {"data":{"status":"ok"},"error":null}
production  web    https://web-production-5fb90.up.railway.app/healthz -> 200 {"data":{"status":"ok"},"error":null}
staging/qa/production admin -> 404 (graph, domain and secrets created; no deploy: apps/admin is not on main yet)

The pre-deploy migration ran (production deploy log): [INFO] migrations applied at 10:21:12, then listening addr=0.0.0.0:3000 public_url=https://web-production-5fb90.up.railway.app env=production. qa and staging logged the same.

Idle footprint (Railway metrics) and estimated cost at $10/GB-month memory, $20/vCPU-month, $0.15/GB-month volume:

Service Memory CPU
web ≈ 75 MB < 0.001 vCPU
postgres / admin-postgres 55–80 MB (+0.9 GB volume) ≈ 0.0003 vCPU
Profile Est. idle $/month
production ≈ 1.55
staging,production ≈ 3.10
staging,qa,production ≈ 4.65
+ admin ≈ 3.00 / 6.00 / 9.00 (admin ≈ web: 70 MiB measured locally)

The whole test, roughly 2.5 h of 2–12 services, cost $0.0134. The project (47eaa79e-bccb-4be2-b08f-30ab7c881022) was deleted afterwards; Railway schedules project deletion.

Railway bugs found in the live test (handled here, drafted in docs/railway-go-iac-issues.md):

  • Critical: a database added to a second environment gets the literal text secret(32, "abc…") as its Postgres password. That is a public string, and the database is reachable through its public TCP proxy. railway-apply detects it and rotates the password over railway ssh; qa's migration then succeeded with the new password.
  • restartPolicyType: ON_FAILURE and watchPatterns: [] drift forever.
  • Watch paths make railway up deploys SKIPPED.
  • RAILWAY_ENV=staging silently switches the CLI to Railway's own staging API.
  • Railway's builder rejects RUN --mount=type=cache without a service-specific id. The Dockerfile on main could not build on Railway at all; this PR drops the cache mounts.

Checks on the rebased branch: make ci passes in 2m21s. Every coverage floor is met, including secrets 98.3%, admin services/api 100% and adminrepo 81.9%. deploy-check reports 0 issues and the deploy/platform tests pass. actionlint and gofmt are clean.

Not verified:

  • GitHub-source mode: repo: jayleaton/ai-patterns-go failed with "You don't have access to the GitHub repo … install the Railway GitHub App". It needs the app installed on the repo.
  • Admin deploy on Railway: the admin graph, domains and secrets were created in all 3 environments. The deploy itself wasn't run, because admin was not on main during the live test, and no new project is allowed during Railway's incident. Instead I ran the merged main images locally with exactly the variables setup provisions: server migrate → migrations applied, admin migrate → admin migrations applied, /healthz 200 on both, admin idle ≈ 70 MiB. Its Dockerfile now has the cache-mount fix.
  • make railway-rotate-db and the README incident steps (not run against Railway).
  • Real Doppler token (feat/secrets).

Merge Danger

Door: two-way. Nothing applies to Railway on merge; make railway-apply is run by a human. Reverting restores railway.json. Projects already deployed with the old railway.json keep their dashboard settings.

Blast Radius: deployment. It affects anyone deploying the template to Railway. The app module and the binary are unchanged. The Dockerfile only drops build-cache mounts, so local make docker rebuilds compile from scratch and the image contents are the same. deploy/ is in .dockerignore.

🤖 Generated with Claude Code

@jayleaton
jayleaton marked this pull request as ready for review October 22, 2026 11:22
…aml)

deploy/platform.yaml decides the Railway project (admin, environments
production | staging,production | staging,qa,production, previews,
region, sizes); make railway-plan / railway-apply converge Railway to it.

- deploy/ is its own Go module (railway-go-sdk + yaml stay out of the
  app go.mod); deploy/platform builds the IaC graph per environment
  (web + postgres, plus admin + admin-postgres), tested
- deploy/.railway/railway.go is the shim the Railway CLI evaluates
- cmd/railway-setup does what IaC cannot: project, empty environments,
  generated domains, sealed bootstrap secrets (AUTH_SECRET,
  ADMIN_AUTH_SECRET, DOPPLER_TOKEN placeholder, SECRETS_PROVIDER),
  PR-deploy toggle, railway up (upload mode), /healthz verify; it also
  repairs databases Railway gives an unevaluated secret() password
- replaces the legacy root railway.json (Config as Code ends 2026-12-01)
- Dockerfile drops RUN --mount=type=cache (Railway's builder rejects it)
- CI: a deploy job vets, lints and tests deploy/; make ci runs deploy-check
- docs: README "Deploy to Railway" (profiles, cost, Doppler, previews) and
  docs/railway-go-iac-issues.md (ready-to-file Railway issue drafts)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton and others added 2 commits October 22, 2026 18:43
It answers 503 when Doppler secrets go stale; a restart probe would turn
a Doppler outage into a restart loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cident runbook

- apps/admin/Dockerfile drops RUN --mount=type=cache (Railway's builder
  rejects it); apps/admin/railway.json removed (IaC owns the service)
- generated AUTH_SECRET / ADMIN_AUTH_SECRET are no longer sealed: a
  rotation needs the old value in *_PREVIOUS / *_REVOKED, and a sealed
  value can never be read back (the next boot could not unwrap the 2FA
  data keys)
- *_PREVIOUS / *_REVOKED are ignored by IaC so a rotation in progress is
  never planned for deletion; services on Doppler get no Railway copies
- make railway-rotate-db: new superuser password per Postgres over
  railway ssh, then redeploy services that read DATABASE_URL from Railway
- README: keep app secrets in Doppler, only DOPPLER_TOKEN in Railway
  (sealed), and how to rotate everything after a provider incident

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton merged commit fb2e28c into main Oct 22, 2026
6 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