Skip to main content

railway.json vs render.yaml vs fly.toml: What Each PaaS Config File Actually Declares

11 min readDora NodaDora Noda
Share
On this page

Every PaaS asks you to describe your app in a file. Only one of the three most-imitated files actually describes your whole system.

Railway's railway.json configures a single service's build and run settings. Fly.io's fly.toml configures a single app's machines, networking, and disks. Render's render.yaml — a Blueprint — declares web services, workers, cron jobs, Postgres databases, Key Value stores, environment groups, and preview-environment policy in one document. If you are building or choosing a Render-compatible platform, that difference is the whole game: honoring render.yaml means honoring a multi-resource contract, while the other two files leave databases, volumes, and environments to dashboards and CLIs.

The three contracts in one table

Here is the clause-by-clause comparison up front. Each cell answers one question: can a migrator's repo declare this in the file, or does it live somewhere else?

Capabilityrailway.json / railway.tomlrender.yaml (Blueprint)fly.toml
Scope of one fileOne serviceWhole project: services, databases, env groups, preview policyOne app (one image, N machines)
Web serviceNative ([deploy] + networking defaults)Native (type: web)Native ([http_service])
Background workerNative (service with no public port)Native (type: worker)Native ([processes] group)
Cron / scheduled jobsNative (cronSchedule in [deploy])Native (type: cron + quoted schedule)Absent — scheduled Machines via CLI with fuzzy intervals, or supercronic baked into the image
Private / internal serviceNative (unexposed service)Native (type: pserv)Native (app with no public [http_service]/[[services]])
Static siteVia builder output, no dedicated typeNative (type: web + runtime: static, staticPublishPath)Via [[statics]] for asset offload, not a site type
Managed databaseAbsent — provisioned as a service in dashboardNative (databases: for Postgres, type: keyvalue for Redis-compatible)Absent — Postgres runs as a separate Fly app or externally
Volumes / disksAbsent — railway volume create/attach via CLINative (disk: with mount path and size)Half — [[mounts]] references a named volume, but the volume itself is created via flyctl
Env vars + generated secretsPartial — build/start commands and vars; secrets via dashboardNative (envVars with generateValue, sync: false, shared envVarGroups)Native ([env]; secrets via fly secrets, referenced by name)
Health checksNative (healthcheckPath, healthcheckTimeout)Native (healthCheckPath)Native ([[http_service.checks]], machine checks)
Scaling / replicasnumReplicas, restart policynumInstances, minInstances/maxInstances autoscalingMachine count/size, concurrency limits, autostart/autostop
RegionsDashboard-selected per environmentNative (region per resource)Native (primary_region)
Build configNative (builder, buildCommand, dockerfilePath)Native (runtime, buildCommand, dockerfilePath, dockerContext)Native ([build] with Dockerfile, builder, or image)
Preview environmentsAbsent — PR environments are a dashboard toggleNative (previews: generation policy + per-resource previewPlan)Absent — per-PR apps are a CI pattern, not a platform primitive
Cron expression syntaxStandard five-field cronStandard five-field cron, must be quotedNo expression at all (hourly/daily/weekly keywords via CLI)

The pattern is stark. Render's file is infrastructure-as-code for the project. Railway's and Fly's files are run-config for one deployable unit, and everything structural — data, environments, previews — is ambient platform state you click or script into existence. That asymmetry decides what "compatible" has to mean, as the rest of this post shows.

render.yaml: the only real multi-resource IaC

A Blueprint is the closest thing in the git-push PaaS world to a Terraform file that lives in your repo. One render.yaml at the repository root can stand up an entire system:

yaml
services:
  - type: web
    name: api
    runtime: node
    plan: standard
    region: oregon
    branch: main
    autoDeploy: true
    buildCommand: npm ci && npm run build
    startCommand: node dist/server.js
    healthCheckPath: /healthz
    numInstances: 2
    envVars:
      - key: DATABASE_URL
        fromDatabase:
          name: app-db
          property: connectionString
      - key: SESSION_SECRET
        generateValue: true
  - type: worker
    name: billing-worker
    runtime: node
    plan: starter
    buildCommand: npm ci && npm run build
    startCommand: node dist/worker.js
  - type: cron
    name: nightly-cleanup
    schedule: "0 4 * * *"
    buildCommand: npm ci && npm run build
    startCommand: node dist/cleanup.js
 
databases:
  - name: app-db
    databaseName: app
    plan: standard
    region: oregon
 
previews:
  generation: automatic

Each line of that file is a clause a compatible API must honor:

  • Service topology. Six service types exist in the current spec: web, pserv (private), worker, cron, keyvalue, and the newer workflow. Static sites ride along as type: web with runtime: static plus staticPublishPath, routes, and headers.
  • Data. databases: declares managed Postgres (name, database name, plan, region, Postgres version, ipAllowList), and envVars wire connection strings into services via fromDatabase. There is no "go create the database in the dashboard" step.
  • Secrets. generateValue: true mints a secret at creation time; sync: false marks a value the platform must not overwrite on later Blueprint syncs. The first seeds credentials, the second protects hand-rotated ones from being clobbered by the next deploy.
  • Cron with a YAML gotcha. The schedule is a standard five-field cron expression — and it must be quoted. A schedule like */15 * * * * starts with *, which unquoted YAML reads as an alias reference and rejects. A compatible parser must accept the string form.
  • Preview policy as code. The top-level previews: block sets PR preview generation to automatic or manual, and each resource can downsize its preview copy with previewPlan (or previewDiskSizeGB for databases). Previews are declared per-resource in the repo, not a dashboard toggle.
  • Deploy wiring. repo, branch, rootDir (monorepo support), autoDeploy, preDeployCommand (migrations before the new version goes live), Docker fields (dockerfilePath, dockerContext, or a prebuilt image), and ipAllowList semantics (omitted on Postgres means open to any credentialed source; required, possibly empty, on Key Value).

In short: render.yaml is a project manifest, and "supporting" it means reconciling a whole dependency graph — databases before the services that reference them, preview copies with their own plans — from one file.

railway.json: one service, build plus deploy

Railway's config file is deliberately small. It answers exactly two questions — how do I build this service, and how do I run it — and nothing else:

json
{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "NIXPACKS",
    "buildCommand": "npm ci && npm run build"
  },
  "deploy": {
    "startCommand": "node dist/server.js",
    "healthcheckPath": "/healthz",
    "healthcheckTimeout": 100,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10,
    "cronSchedule": "*/15 * * * *"
  }
}

(TOML users get the identical surface as railway.toml with [build] and [deploy] tables; config in code overrides dashboard settings either way.)

What is in the file is genuinely useful: builder selection (Nixpacks versus Dockerfile, with Railway's next-generation Railpack builder rolling out underneath), build and start commands, health-check path and timeout, restart policy with bounded retries, replica count, and — notably — cronSchedule, which converts the service into a scheduled run on a real five-field cron expression. Railway is the only one of the three besides Render to give cron a first-class config key.

What is not in the file is the rest of the system:

  • Volumes are created and attached via the CLI (railway volume create, railway volume attach). The mount path lives in platform state, not in the repo.
  • Databases are provisioned as services through the dashboard or CLI and wired with template variables. A railway.json never declares Postgres.
  • PR environments are a project-level dashboard setting (optionally orchestrated through GitHub Actions and Railway's GraphQL API). There is no previews: clause to port.
  • Multi-service wiring is ambient: services in one project share private networking by convention, but the file describes one service at a time.

For a Render-compatible API, a Railway migrator arrives with less declarative surface, not more. No Railway-ism collides with the Blueprint model — instead there is missing state (volumes, database topology, environment policy) that migration tooling must discover from the Railway API and re-emit as Blueprint clauses. The railway.json itself maps almost 1:1 onto a single Blueprint service entry.

fly.toml: one app, machine-level control

Fly's file sits at the opposite end of the abstraction ladder from Render's: not project IaC, but machine-group config with unusual depth. One fly.toml governs one app — one image, one or more Machines — and it says a great deal about how those machines run:

toml
app = "api"
primary_region = "ord"
kill_signal = "SIGTERM"
kill_timeout = "60s"
 
[build]
  dockerfile = "Dockerfile"
 
[deploy]
  release_command = "bin/rails db:migrate"
  strategy = "rolling"
 
[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 1
  [http_service.concurrency]
    type = "requests"
    hard_limit = 250
    soft_limit = 200
 
[processes]
  app = "bin/rails server"
  worker = "bin/sidekiq"
 
[[mounts]]
  source = "api_data"
  destination = "/data"
  initial_size = "10gb"
 
[[http_service.checks]]
  grace_period = "10s"
  method = "GET"
  path = "/healthz"

The clauses worth studying, because they have no direct Blueprint equivalent:

  • Process groups. [processes] runs multiple command lines — web plus worker — from the same image in one deploy. Render's model wants these as separate services sharing a repo; a migrator's fly.toml has to be split, not just translated.
  • Concurrency and autostart/autostop. Per-service soft/hard concurrency limits plus machines that stop without traffic and start on request are Fly's scaling story. The Blueprint vocabulary for this is numInstances plus min/max autoscaling — coarser, and with no idle-to-zero semantics to map onto.
  • Release commands. release_command runs migrations once per deploy before new Machines start — the direct ancestor of Render's preDeployCommand, and the easiest clause in the file to honor.
  • Mounts, half-declared. [[mounts]] names a volume and a destination path, but the volume itself is created out-of-band with flyctl. Like Railway's volumes, the storage topology is half in the file, half in platform state.

And the genuine absences:

  • No cron key exists in fly.toml. Recurring work runs as a long-lived worker process group, as supercronic baked into the image (with autostop disabled so the machine does not sleep through its schedule), or as scheduled Machines created via fly machine run --schedule — which accepts only fuzzy hourly/daily/weekly keywords, not cron expressions. Of the three platforms, Fly is the only one where "run this every 15 minutes" cannot be written down declaratively.
  • No managed database. Postgres on Fly is a Fly app you operate (or an external provider), so there is no database clause to translate — only connection strings in [env] or fly secrets.
  • No preview environments. The per-PR pattern is "CI deploys an ephemeral app per branch," entirely outside the config file.

Fly's file is the deepest of the three about runtime and the shallowest about system: everything about machines, nothing about data or environments.

The Render-compat must-honor list

Now the second half of the promise: if your API claims Render compatibility — the way Bex.co does — so that a Render user's repo deploys without rewrites, which render.yaml clauses are load-bearing? In rough order of "what breaks first if you ignore it":

  1. Service types and runtimes. web, pserv, worker, cron, keyvalue, workflow, plus runtime: static with staticPublishPath. Ignore one and whole repos fail to import.
  2. Build and start commands. buildCommand, startCommand, preDeployCommand, and the Docker trio (runtime: docker, dockerfilePath, dockerContext, prebuilt image). The deploy pipeline is these fields.
  3. Repo wiring. repo, branch, rootDir, autoDeploy. Monorepo users live or die by rootDir; everyone else by branch tracking.
  4. Environment and secrets. envVars with literal values, fromDatabase references, generateValue, sync: false, and shared envVarGroups. Get sync: false wrong and you overwrite rotated secrets on every sync — a data-loss bug, not a cosmetic gap.
  5. Plans and regions. plan per resource and region per resource. You need a documented size map (what your Starter/Standard/Pro equivalents are) and a region story, even if your answer is "one region today."
  6. Disks. disk: with mount path and size. Stateful services cannot migrate without it.
  7. Health checks and instances. healthCheckPath, numInstances, minInstances/maxInstances. Zero-downtime deploys and autoscaling hang off these.
  8. Cron schedules. type: cron with quoted five-field schedule. Accept the quotes; validate the expression.
  9. Preview policy. previews: generation mode plus per-resource previewPlan and database previewDiskSizeGB. Teams that review in previews will notice on the first PR if this is missing.
  10. Databases and access rules. databases: (Postgres name, plan, version, ipAllowList) and Key Value's required ipAllowList. The access-list semantics — omitted means open on Postgres — must be preserved exactly, or you silently change a migrator's security posture.
  11. Notifications and grouping. projects, envVarGroups attachments, and notification wiring. Last in priority, first in "enterprise repo won't import without it."

Two translation notes for the non-Render files: Railway's [processes]-less single-service file maps cleanly onto one Blueprint service, with cronSchedule becoming a type: cron entry — but volumes, databases, and PR policy must be lifted from the Railway API, because the file never had them. Fly's [processes] must be split into sibling Blueprint services, release_command becomes preDeployCommand, and [[mounts]] becomes disk: — while cron, databases, and previews have no source clause at all and must be reconstructed from out-of-band state (or the migrator's CI scripts, where the preview and schedule logic actually lives).

Cheat-sheet: translating constructs across the three models

ConstructRailway sourceFly sourceRender-Blueprint target
Web service[deploy] service[http_service] apptype: web
WorkerUnexposed service[processes] grouptype: worker (split from web)
CroncronScheduleCLI --schedule / supercronic (not in file)type: cron + quoted schedule
DatabaseDashboard serviceSeparate app / externaldatabases: entry
VolumeCLI-created[[mounts]] + CLI-created volumedisk:
Preview envDashboard PR settingCI-managed ephemeral appspreviews: + previewPlan
Release migrationStart-command wrapperrelease_commandpreDeployCommand
Generated secretDashboard secretfly secretsgenerateValue: true

The through-line: Render's file is the only one of the three that can be the input to a migration. Railway's and Fly's files are evidence — useful, precise evidence about one service — but the migration input is the file plus whatever the dashboard, the CLI, and CI scripts remember. A Render-compatible platform earns the label exactly to the extent its importer closes that gap: every Blueprint clause honored, every out-of-band construct given a declared home.

That is why the file format matters beyond aesthetics. A platform whose state lives in clicks cannot be diffed, reviewed, or replayed; a platform whose state lives in render.yaml can. If you are choosing where to deploy — or what to be compatible with — pick the contract that survives the loss of its dashboard.

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.

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