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?
| Capability | railway.json / railway.toml | render.yaml (Blueprint) | fly.toml |
|---|---|---|---|
| Scope of one file | One service | Whole project: services, databases, env groups, preview policy | One app (one image, N machines) |
| Web service | Native ([deploy] + networking defaults) | Native (type: web) | Native ([http_service]) |
| Background worker | Native (service with no public port) | Native (type: worker) | Native ([processes] group) |
| Cron / scheduled jobs | Native (cronSchedule in [deploy]) | Native (type: cron + quoted schedule) | Absent — scheduled Machines via CLI with fuzzy intervals, or supercronic baked into the image |
| Private / internal service | Native (unexposed service) | Native (type: pserv) | Native (app with no public [http_service]/[[services]]) |
| Static site | Via builder output, no dedicated type | Native (type: web + runtime: static, staticPublishPath) | Via [[statics]] for asset offload, not a site type |
| Managed database | Absent — provisioned as a service in dashboard | Native (databases: for Postgres, type: keyvalue for Redis-compatible) | Absent — Postgres runs as a separate Fly app or externally |
| Volumes / disks | Absent — railway volume create/attach via CLI | Native (disk: with mount path and size) | Half — [[mounts]] references a named volume, but the volume itself is created via flyctl |
| Env vars + generated secrets | Partial — build/start commands and vars; secrets via dashboard | Native (envVars with generateValue, sync: false, shared envVarGroups) | Native ([env]; secrets via fly secrets, referenced by name) |
| Health checks | Native (healthcheckPath, healthcheckTimeout) | Native (healthCheckPath) | Native ([[http_service.checks]], machine checks) |
| Scaling / replicas | numReplicas, restart policy | numInstances, minInstances/maxInstances autoscaling | Machine count/size, concurrency limits, autostart/autostop |
| Regions | Dashboard-selected per environment | Native (region per resource) | Native (primary_region) |
| Build config | Native (builder, buildCommand, dockerfilePath) | Native (runtime, buildCommand, dockerfilePath, dockerContext) | Native ([build] with Dockerfile, builder, or image) |
| Preview environments | Absent — PR environments are a dashboard toggle | Native (previews: generation policy + per-resource previewPlan) | Absent — per-PR apps are a CI pattern, not a platform primitive |
| Cron expression syntax | Standard five-field cron | Standard five-field cron, must be quoted | No 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:
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: automaticEach 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 newerworkflow. Static sites ride along astype: webwithruntime: staticplusstaticPublishPath, routes, and headers. - Data.
databases:declares managed Postgres (name, database name, plan, region, Postgres version,ipAllowList), andenvVarswire connection strings into services viafromDatabase. There is no "go create the database in the dashboard" step. - Secrets.
generateValue: truemints a secret at creation time;sync: falsemarks 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 toautomaticormanual, and each resource can downsize its preview copy withpreviewPlan(orpreviewDiskSizeGBfor 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 prebuiltimage), andipAllowListsemantics (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:
{
"$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.jsonnever 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:
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'sfly.tomlhas 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
numInstancesplus min/max autoscaling — coarser, and with no idle-to-zero semantics to map onto. - Release commands.
release_commandruns migrations once per deploy before new Machines start — the direct ancestor of Render'spreDeployCommand, 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 withflyctl. 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 viafly machine run --schedule— which accepts only fuzzyhourly/daily/weeklykeywords, 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]orfly 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":
- Service types and runtimes.
web,pserv,worker,cron,keyvalue,workflow, plusruntime: staticwithstaticPublishPath. Ignore one and whole repos fail to import. - Build and start commands.
buildCommand,startCommand,preDeployCommand, and the Docker trio (runtime: docker,dockerfilePath,dockerContext, prebuiltimage). The deploy pipeline is these fields. - Repo wiring.
repo,branch,rootDir,autoDeploy. Monorepo users live or die byrootDir; everyone else by branch tracking. - Environment and secrets.
envVarswith literal values,fromDatabasereferences,generateValue,sync: false, and sharedenvVarGroups. Getsync: falsewrong and you overwrite rotated secrets on every sync — a data-loss bug, not a cosmetic gap. - Plans and regions.
planper resource andregionper 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." - Disks.
disk:with mount path and size. Stateful services cannot migrate without it. - Health checks and instances.
healthCheckPath,numInstances,minInstances/maxInstances. Zero-downtime deploys and autoscaling hang off these. - Cron schedules.
type: cronwith quoted five-fieldschedule. Accept the quotes; validate the expression. - Preview policy.
previews:generation mode plus per-resourcepreviewPlanand databasepreviewDiskSizeGB. Teams that review in previews will notice on the first PR if this is missing. - Databases and access rules.
databases:(Postgres name, plan, version,ipAllowList) and Key Value's requiredipAllowList. The access-list semantics — omitted means open on Postgres — must be preserved exactly, or you silently change a migrator's security posture. - Notifications and grouping.
projects,envVarGroupsattachments, 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
| Construct | Railway source | Fly source | Render-Blueprint target |
|---|---|---|---|
| Web service | [deploy] service | [http_service] app | type: web |
| Worker | Unexposed service | [processes] group | type: worker (split from web) |
| Cron | cronSchedule | CLI --schedule / supercronic (not in file) | type: cron + quoted schedule |
| Database | Dashboard service | Separate app / external | databases: entry |
| Volume | CLI-created | [[mounts]] + CLI-created volume | disk: |
| Preview env | Dashboard PR setting | CI-managed ephemeral apps | previews: + previewPlan |
| Release migration | Start-command wrapper | release_command | preDeployCommand |
| Generated secret | Dashboard secret | fly secrets | generateValue: 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.



