Skip to main content

Railway Retires Config as Code on December 1: How to Design a Reversible PaaS Manifest Migration Instead of a Flag Day

11 min readDora NodaDora Noda
Share
On this page

On December 1, 2026, Railway will stop reading railway.json and railway.toml — hard cutoff, no extension documented. Every service whose deploy configuration still lives in the old Config as Code files must be migrated to the new IaC SDK (.railway/railway.ts, with Python and Go authoring in beta) before that date, or its next deploy silently loses the build command, start command, and healthcheck the file used to supply. New services already cannot opt into the old system at all.

That is a vendor migration with a guillotine date — and it is also a free case study in how to change a PaaS deploy contract without turning it into an outage for every git-push customer. Railway built the migration as a converter plus a plan/apply loop instead of a flag day, and mostly got it right. Here is the verdict up front, as the five-property checklist this post defends — each mapped to Railway's concrete mechanism, with the gaps named where Railway left them:

  • An explicit converterrailway config migrate generates the new file from the old one, per service or per repo.
  • A diff before every mutationrailway config plan previews changes with secrets redacted and machine-readable output for CI gates.
  • Plan-artifact pinning — the reviewed plan, not a re-evaluation, is what applies, guarded by an environment etag and the git tree.
  • Staged opt-in — migrate one service at a time, with per-repo partials and ownership errors instead of a coordinated cutover.
  • A rollback story — this is where Railway's design is thinnest, and where your own manifest migration should do better.

The rest of this post earns each line: what Railway actually changed, the machinery piece by piece, where it bites anyway (with real user reports), and the reusable version of the checklist for any self-hosted Render-compatible platform that will one day need to version its own deploy manifest.

What actually changes on December 1

Config as Code and Infrastructure as Code are not two syntaxes for the same thing. They differ in scope, in when they are evaluated, and in who owns the state — which is exactly why the migration is interesting rather than mechanical.

Config as Code (retiring)Infrastructure as Code (replacement)
Filerailway.json or railway.toml.railway/railway.ts (GA), .railway/railway.py / .railway/railway.go (beta)
ScopeOne service's deploymentA whole project/environment: services, databases, volumes, buckets, domains, variables, replicas
EvaluatedRead from the service repo at deploy timeEvaluated by the Railway CLI via plan / apply; never read during deploys
PrecedenceOverrides dashboard values for that serviceCLI compares the file against the environment and applies only after confirmation
StatusDeprecated; unread after 2026-12-01Generally available (TypeScript)

Two dates matter. The first is the hard cutoff the official docs state plainly: existing Config as Code files stop being read on December 1, 2026. The second is the new-project cutoff: new services cannot opt into Config as Code, and at least one team's changelog pins that change to August 28, 2026 — new projects stopped reading the old files from that date. So the migration window is effectively September through November for anyone who discovers the deprecation late.

The sharpest design decision is mutual exclusion: a service cannot be managed by both systems at the same time. If a service is still managed by railway.json, railway config plan stops and names the service that must be migrated first. That kills the two-sources-of-truth failure mode by construction — but, as the gotchas below show, it also removes the gentlest migration path (running both side by side and comparing) at exactly the moment you want it.

The migration machinery, piece by piece

Railway's migration is six mechanisms composed into one loop. Each is worth stealing individually.

1. An explicit converter, not a wiki page. railway config migrate previews the generated authoring file; --apply writes it and clears the Railway Config File settings; --delete-files optionally removes the old CaC files; --service <name> migrates one service; --lang py / --lang go target the beta SDKs. In a monorepo, migrate finds every CaC file and merges them into a single .railway/railway.ts; run per repository, each single-service migrate writes a named partial export, matching how Config as Code was applied per service. The converter is the difference between "here is the new format, good luck" and a migration users can start with one command.

2. A diff before every mutation. railway config plan only reads state and prints what would change ("Plan: 1 to add, 0 to change, 0 to destroy"). Variable values are redacted as «hidden» by default so secrets never land in terminal scrollback or CI logs; --show-values opts into printing them for non-secret review; --json gives machine-readable output; and --detailed-exit-code exits 2 when changes are pending, so CI can gate on drift. The docs set the acceptance bar explicitly: after a clean config pull, the plan should print "Your Railway configuration is already up to date" — a no-op plan is the proof the import preserved meaning.

3. Plan-artifact pinning. CI should not plan again on merge; it should apply the reviewed plan. railway config plan --out railway-plan.json pins the change set, and railway config apply --plan railway-plan.json applies that artifact as-is — failing if the environment's configEtag drifted or the checked-out .railway/ tree is not the planned tree. The official railwayapp/config GitHub Action wires this into pull requests: every PR touching .railway/ gets a plan comment with destructive changes marked, and merging is the approval. Review-the-diff-then-merge is a workflow every git-push customer already understands, which is precisely why it works as a migration vehicle.

4. Staged opt-in with ownership enforcement. Migration happens one service at a time, and multi-repo projects get named partials (export const partial = "api") so each repository owns only its slice. The CLI enforces ownership on every plan and apply: declaring a resource another partial owns fails with Cannot manage service "api": already managed by partial "web", and a file without a partial export fails outright in an environment that already has named partials. A named partial only deletes resources it owns. This is staged rollout done right — no coordinated cutover, no "everyone migrates this weekend" — with the blast radius of each step bounded by construction.

5. Dashboard-owned state gets imported, not guessed. railway config pull imports the linked environment's live configuration into the authoring file, rendering existing variable values as preserve() so they stay on Railway instead of being written into source. --include-variables inlines non-sealed values with an explicit secrets warning; sealed variables stay as preserve(). One community migration guide stresses starting from the linked project and environment precisely so service identity, variables, and volume configuration are captured rather than guessed — the import-then-diff flow exists so that human memory is never the migration's source of truth.

6. Destructive changes need a second key. Every apply runs a fresh plan immediately before applying and commits against the exact state it just read; a concurrent dashboard edit or parallel apply rejects the commit and forces a re-plan. Non-interactive applies additionally require --confirm-destructive before any deletion, so a stray --yes in a script cannot remove resources on its own. Stale-plan rejection plus an explicit destructive flag is the minimum viable safety story for any config applier, and Railway ships both.

Where it bites anyway

A migration design is only as good as its failure modes, and Railway's has documented ones. Three come from real user reports, and they share a theme: the converter is lossy at exactly the boundary between the old deploy-time semantics and the new CLI-evaluated ones.

First, config migrate drops settings it cannot see. One team's commit log records the damage precisely: the generator named the service after the project instead of the service, dropped the restart policy and the builder, and config pull could not see anything railway.json had been supplying at deploy time — because pull reads Railway's stored state, while the old file's whole job was overriding stored state at deploy time. The builder was the load-bearing loss: Railway had RAILPACK stored all along, so a straight pull silently reverted the build to whatever the platform defaulted to. The fix was hand-writing the file. Lesson for your own converter: diff the converter's output against a deploy made from the old file, not against stored state — stored state never contained the overrides, so a clean plan against stored state proves nothing about deploy parity.

Second, mutual exclusion means every service migrates big-bang. Because a service cannot be managed by both systems, there is no shadow mode: you cannot leave CaC authoritative while IaC runs read-only beside it, comparing what it would do deploy after deploy. The migration unit is one service and the cutover is instant (remove the repo file, clear the dashboard path, apply). For a single service that is fine; for a fleet of fifty it means fifty unobserved cutovers. The missing mechanism has a name — dual-read validation, the expand/contract pattern: read both formats, compare, serve the old, alert on divergence — and Railway's hard either/or is the one place its design forces users to skip it.

Third, the new surface is still moving under the migration. Python and Go authoring are beta with helper names and formatting subject to change; even generated TypeScript formatting "may still change in small ways between CLI versions." Bucket regions are immutable after creation, and volume lifecycle is intentionally conservative to avoid accidental unmounts. None of these is a reason to wait — the December 1 cutoff does not move — but they are a reason to pin the CLI version in CI and to re-run plan after every CLI upgrade. A migration target that drifts is a second migration hiding inside the first.

The reusable checklist for your own manifest migration

Strip out Railway specifics and the pattern generalizes to any self-hosted platform that will one day version its deploy contract — a bex.yml v2, a Blueprint-spec revision, a buildpack-to-image migration. Each row is one property, Railway's mechanism, and what to add where Railway left a gap.

PropertyRailway's mechanismWhat your platform should add
Versioned manifest schemaImplicit (file path + SDK import select the format)An explicit version field in the manifest, so parsers can reject unknown futures instead of misreading them
Explicit converterconfig migrate (+ --service, --lang, --apply, --delete-files)A converter whose output is verified against an old-format deploy, not just against stored state
Diff before mutateconfig plan (redacted vars, --json, --detailed-exit-code)Same — plus a documented "clean import shows zero changes" acceptance test
Plan-artifact pinning--out / --plan with configEtag + git-tree check; railwayapp/config ActionSame — review-the-diff-then-merge ports directly to any git-push platform
Dual-read validationAbsent (mutual exclusion forbids it)A shadow-read window: parse both formats, serve the old, alert on divergence, then flip
Staged opt-inOne service at a time; named partials with ownership errorsSame — per-service/per-app cutover with ownership so partial migration is a supported state, not a broken one
Rollback windowEffectively none (re-adding the old file re-arms CaC only until Dec 1)Explicit rollback semantics: keep the old reader until the window closes, and document the one-command revert

The two additions that matter most are the ones Railway lacks. A version field turns every future format change from a flag day into a parser branch: old readers reject new manifests loudly instead of half-applying them. And dual-read validation is what lets you migrate fifty services with observed confidence instead of fifty unobserved cutovers — run both readers, compare on every deploy, promote the new one only when the divergence log stays empty. Railway proves the converter-plus-plan loop carries most of the weight; the shadow window is what carries the rest.

Flag days are a choice

Railway's Config as Code retirement is, on balance, a well-designed migration: a one-command converter, a redacted diff, pinned plan artifacts, per-service staging, and destructive-change gating, all wired into the pull-request flow git-push customers already live in. Its weaknesses — a lossy converter at the deploy-time boundary, no shadow-read mode, a beta target surface, and no real rollback story — are precisely the checklist items a platform team should add when it is their turn to version a deploy contract.

That turn comes for every PaaS eventually. Manifest formats always grow a v2: new workload types, new networking primitives, new policy hooks. The teams that survive it are the ones that treat the migration as part of the format — versioned schemas, converters, diffs, shadow reads, staged opt-in, rollback windows — instead of a blog post announcing a cutoff date. December 1 is Railway's deadline. The checklist is everyone's.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex