Three platforms will all take your git repo and give you a running HTTPS service. But ask each one "what do I write down to describe my app?" and you get three wildly different answers: Railway says nothing — we'll figure it out, Render says a YAML blueprint describing your whole stack, and Fly.io says a TOML file where you control the deploy strategy, the release command, and which machines stay warm. Same input, same output, three philosophies about who owns the knowledge of how your app runs.
Here is the comparison up front, before the details:
| Railway (Railpack) | Render (render.yaml) | Fly.io (fly.toml) | |
|---|---|---|---|
| Config file | railway.json / railpack.json — optional | render.yaml — the whole stack | fly.toml — required, generated by fly launch |
| Philosophy | Convention: detect from package.json | Declaration: infrastructure as code | Control: explicit production knobs |
| Optimizes for | Time to first deploy | Reproducible infrastructure | Production control |
| First-deploy effort | Push; zero config | Write the blueprint, then push | Answer fly launch prompts, tune TOML |
| Multi-service story | Dashboard composition, not file composition | Native: services + databases in one file | One file per app; compose by convention |
| Sharp edge | Magic mis-detects are hard to debug | Ceiling on deploy-strategy control | You must learn the surface before deploy one |
The rest of this post earns each cell: what each system asks of you, what it gives back, and where each philosophy breaks down as an app grows past one service. It closes with the lesson for anyone building a self-hosted git-push PaaS: convention-by-default detection with a declarative escape hatch beats both pure magic and full-control TOML.
Railway Railpack: convention
Railway's builder is Railpack, which replaced Nixpacks as the default. Its pitch is zero configuration: push a repo, and Railpack inspects it — package.json scripts, lockfiles, framework markers — picks a language provider, and derives the install, build, and start commands. There is deliberately nothing to write. Configuration exists only as overrides for when detection gets it wrong or your app needs more: a railway.json (or railway.toml, or provider-level railpack.json) declaring a custom buildCommand or start command, RAILPACK_* environment variables such as RAILPACK_PACKAGES for Mise packages or RAILPACK_BUILD_APT_PACKAGES for system libraries, and the venerable Procfile, which Railpack still detects but Railway no longer recommends.
What this optimizes for is unambiguous: time to first deploy. The happy path — a standard Next.js, Django, or Express app — goes from git push to a live URL with no platform-specific file in the repo at all. There is no schema to learn, no plan field to set, no region to pick. For prototypes, hackathon projects, and the first production deploy of a small team, that is exactly the right trade. Every required config key is a chance to bounce a new user; Railway has zero of them.
The breakdown arrives along two axes: infrastructure you cannot express, and magic you cannot debug. Railpack describes a build, not a stack — there is no file in which to declare "this API plus a Postgres plus a Redis plus a cron worker, wired together." Multi-service composition happens in the Railway dashboard and API, which means the repo is not the source of truth for the system.
And when detection misfires — the wrong start command, a monorepo root it cannot see, a nixpacks.toml left over from the old builder that Railpack silently ignores — the failure mode is uniquely frustrating: there is no config to read, so there is nothing to diff against your mental model. You debug by adding the config you were promised you would never need, in a file whose path does not even follow the service's root directory setting. Convention is wonderful until it guesses wrong; then you pay for all the learning you skipped, with interest.
Render render.yaml: declaration
Render's answer is the Blueprint: a render.yaml file, committed to the repo, that declares the entire stack as infrastructure as code. The top-level keys read like a manifest for the system: services (web services, private services, background workers, cron jobs, static sites), databases (managed Postgres), keyValue (managed Redis), envVarGroups (shared environment), and previews (preview-environment behavior). A JSON schema is published for IDE autocompletion, and pushing the file provisions or updates every resource it names — databases included — with service-to-service wiring expressed through fromService and fromDatabase property references rather than pasted connection strings.
services:
- type: web
name: api
runtime: node
plan: starter
buildCommand: npm ci && npm run build
startCommand: node dist/server.js
healthCheckPath: /health
envVars:
- key: DATABASE_URL
fromDatabase:
name: app-db
property: connectionString
databases:
- name: app-db
plan: starterWhat this optimizes for is reproducible infrastructure without Docker expertise. The blueprint is reviewable in a pull request, revertible with git revert, and re-runnable against a fresh account — the properties infrastructure-as-code people actually mean by the term, applied to an app platform. A full-stack deploy (API plus worker plus Postgres plus Redis) is one file, and preview environments inherit the same declaration, so staging is structurally identical to production instead of a hand-maintained lookalike. For teams that have been burned by snowflake environments assembled in a dashboard, this is the feature that sells Render.
The ceiling is expressiveness, not effort. A Blueprint declares what exists, not how releases behave: there is no deploy-strategy knob (no rolling-vs-bluegreen choice), no release-command primitive that runs migrations in a one-off container before traffic shifts, no per-service scaling policy beyond the plan tier. Those behaviors are Render's opinions, applied uniformly — fine until your deploy needs a migration gate or your traffic needs a canary.
The semantics are also Render-only: plan: starter, type: pserv, fromDatabase — every line is meaningful to exactly one vendor's API. The blueprint that makes your Render setup reproducible simultaneously encodes how Render-specific it is. Declaration wins the reproducibility argument and quietly concedes portability and release control.
Fly fly.toml: control
Fly.io's fly.toml is the opposite bet: a required, checked-in file — scaffolded by fly launch, which detects your framework and writes the first draft — in which nearly every production behavior is explicit. The sections read like an operator's checklist. [build] picks the image source: Dockerfile path, buildpacks builder, or a prebuilt image, plus build args. [deploy] sets the release_command (migrations run once in an ephemeral Machine with the new image; a nonzero exit stops the deploy) and the strategy: rolling (default, with tunable max_unavailable), immediate, canary, or bluegreen (the last two requiring health checks and refusing volumes). [http_service] wires the public surface — internal_port, force_https, concurrency soft/hard limits that drive both load balancing and autostop decisions — and the scale-to-zero trio: auto_stop_machines = "stop", auto_start_machines = true, min_machines_running = 0. Add [mounts] for volumes, [processes] for multi-process Machines, kill_signal/kill_timeout for shutdown behavior, and per-service [[services]] blocks when one HTTP surface is not enough.
app = "api"
primary_region = "ord"
[build]
dockerfile = "Dockerfile"
[deploy]
release_command = "npx prisma migrate deploy"
strategy = "bluegreen"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[http_service.concurrency]
type = "requests"
soft_limit = 200
hard_limit = 250
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
path = "/health"What this optimizes for is production control. Nothing about how your app releases, scales, or shuts down is a platform secret: the migration gate, the deploy strategy, the cold-start policy, the concurrency limits are all in the file, diffable and reviewable. That explicitness compounds — the engineer who wrote the fly.toml understands the production behavior, because they specified it. For teams running stateful or latency-sensitive workloads across regions, where "the platform handles it" is a risk rather than a comfort, this is the correct trade.
The cost is the learning surface, and it is due before the first deploy, not after. A fly.toml that uses bluegreen deploys, release commands, autostop, concurrency limits, and health checks demands that the author understand all five concepts — what soft_limit does to autostop capacity math, why bluegreen refuses volumes, that the release command runs without volumes attached and times out after five minutes by default.
fly launch scaffolds the file, but scaffolding is not understanding: the first time a deploy stalls on a failed health check or a release command times out, the tenant is debugging production semantics, not app code. And the file is per-app, not per-fleet — a Postgres cluster, a Redis, and three services are N files plus out-of-band wiring, the mirror image of Render's one-file stack. Control is complete within one app's boundary and silent about everything outside it.
Same app, three files
Take one ordinary system — a Node API backed by Postgres, with migrations that must run before traffic shifts — and ask what each platform's file says about it:
- Railway: no file required. Push the repo; Railpack detects Node, runs the build, starts the server. Postgres is attached in the dashboard, surfacing as a
DATABASE_URLvariable. Migrations run wherever you put them — typically the start command — with no platform gate: if the migration fails mid-deploy, the container crashes and Railway restarts it, which is a crash loop, not a release gate. - Render: one
render.yamldeclares the web service and the database, wires them withfromDatabase, and provisions both on push. Reproducibility is total. But the migration gate does not exist as a primitive: the best available answer is a build command that runs migrations at build time (wrong phase — the old code is still serving) or a start command that migrates on boot (no gate — traffic arrives whether it succeeded or not). - Fly.io:
fly.tomldeclares the release command, the bluegreen strategy, and the health checks that make bluegreen meaningful. The migration gate is real: failed migration, stopped deploy, old Machines keep serving. But Postgres is a separate concern — a managed cluster or a separate Fly app — referenced by a secret you set, not a resource the file provisions. The release story is complete; the stack story is yours to assemble.
Notice what just happened: each file is eloquent about exactly what its philosophy values and mute about the rest. Railway cannot say "these three services form one system." Render cannot say "migrate before shifting traffic." Fly cannot say "provision the database this app needs." The config surface is the philosophy, enforced by what the schema allows you to write down.
The lesson for a self-hosted PaaS
If you are building the git-push layer yourself — on machines you own, behind your own API — you get to choose which philosophy your tenants inherit, and the right answer is a hybrid the commercial platforms each approach from one side: convention-by-default detection with a declarative escape hatch.
Start where Railway starts. The first deploy of a standard app should require zero platform files: detect the framework, derive build and start, provision TLS and a domain. Time-to-first-deploy is the top of every adoption funnel, and every mandatory key is a filter. A self-hosted platform that demands a manifest before it will run npm start has already lost the audience that makes platforms grow.
But make the escape hatch Render-shaped, not Fly-shaped. The moment a tenant outgrows one service — a worker, a cron job, a database, a preview environment that must mirror production — they need a declarative file that describes the stack, reviewable in pull requests and reproducible from git. That file should stay small: services, datastores, wiring, environment. What it should not become is a fly.toml — a surface where deploy strategies, concurrency math, and shutdown signals are mandatory reading. Those are operator concerns, and on a self-hosted PaaS the operator is the platform team, not each tenant: pick sane release semantics (migrate-then-shift, health-gated rollouts), implement them once in the controller, and let the tenant file stay silent about mechanics. Tenants should declare intent; the platform should own procedure.
This ordering — magic first, declaration second, control never-mandatory — also matches how teams actually grow. Nobody's first deploy needs bluegreen strategy selection; everybody's fiftieth deploy needs to know that staging matches production. A config surface that grows in that order never asks a day-one user a day-five-hundred question, and never tells a day-five-hundred team that their stack cannot be written down. Railway never gives you the file; Fly makes you read the manual on day one; Render's blueprint, layered over zero-config detection, is the shape to copy — with the release semantics Render omits built into the platform underneath.
Three files, three bets about what developers should have to know. Railway bets they should know nothing, Render bets they should know their stack, Fly bets they should know their production behavior — and each bet is right for the audience it serves. A platform you host yourself gets to serve all three audiences in sequence: detect first, declare second, and keep the machinery where it belongs — in the controller, not in every tenant's repo.
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.



