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 converter —
railway config migrategenerates the new file from the old one, per service or per repo. - A diff before every mutation —
railway config planpreviews 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) | |
|---|---|---|
| File | railway.json or railway.toml | .railway/railway.ts (GA), .railway/railway.py / .railway/railway.go (beta) |
| Scope | One service's deployment | A whole project/environment: services, databases, volumes, buckets, domains, variables, replicas |
| Evaluated | Read from the service repo at deploy time | Evaluated by the Railway CLI via plan / apply; never read during deploys |
| Precedence | Overrides dashboard values for that service | CLI compares the file against the environment and applies only after confirmation |
| Status | Deprecated; unread after 2026-12-01 | Generally 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.
| Property | Railway's mechanism | What your platform should add |
|---|---|---|
| Versioned manifest schema | Implicit (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 converter | config migrate (+ --service, --lang, --apply, --delete-files) | A converter whose output is verified against an old-format deploy, not just against stored state |
| Diff before mutate | config 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 Action | Same — review-the-diff-then-merge ports directly to any git-push platform |
| Dual-read validation | Absent (mutual exclusion forbids it) | A shadow-read window: parse both formats, serve the old, alert on divergence, then flip |
| Staged opt-in | One service at a time; named partials with ownership errors | Same — per-service/per-app cutover with ownership so partial migration is a supported state, not a broken one |
| Rollback window | Effectively 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
- Infrastructure as Code — Railway docs
- railway-py-iac — railwayapp/railway-py-iac on GitHub
- How We're Building Git for Infrastructure — Railway blog (Launch Week)
- Config as Code to GA, Heroku Buildpack Removal, Celebrating 2M Builds — Railway changelog
- Infrastructure-as-Code feedback thread — Railway Central Station
- Railway IaC migration guide — ceobrah/sword-and-banners
- Changelog noting the 2026-08-28 new-project cutoff — exios66/the-mailroom



