Cedar.js sat down in August 2026 to answer a narrow question — why is deploying to Railway so annoying? — and came back with an answer nobody expected: Railway wasn't the problem. Buried in the framework's deploy-simplification audit, the team found that one start script plus PORT handling yields working deploys on Railway, Render, Cloud Run, DigitalOcean App Platform, Heroku, Coolify, Dokku, Dokploy, Koyeb, and Northflank — ten platforms, zero per-platform integration to write or maintain.
Nobody designed that. There is no standards body for "how a git repo becomes a running HTTPS service." What happened instead is that over a decade of Heroku's PORT convention compounded with every zero-config builder looking for the same two package.json scripts — until the whole industry accidentally agreed on a contract. This post spells out that contract exactly, shows the platform-by-platform matrix of where it holds, and documents precisely where it silently breaks. If you deploy apps — or build the platform others deploy onto — this is the cheapest compatibility surface you will ever get.
The contract in five lines
Here is the entire standard. A repo deploys everywhere if it honors all five:
{
"scripts": {
"build": "cedar build",
"start": "cedarjs-server"
},
"dependencies": {
"@cedarjs/api-server": "^7.0.0"
}
}Plus two runtime behaviors: read the listening port from PORT (public side only), and default the bind host to dual-stack.
Each line exists because a specific platform punishes its absence:
buildscript. Zero-config builders — Railpack, Nixpacks, Paketo, Google Cloud buildpacks, Heroku's Node buildpack — all look for something to run. Cedar's generated apps had noscriptskey at all, so every one of those builders found nothing. Abuildscript is the difference between "push and it works" and "write a Dockerfile before anything happens."startscript. Same story at boot time. Nostart, no container command, no running service. The plan doc's wry summary: once the conventions land, there is almost nothing left for asetup deploygenerator to emit — Coolify is dashboard-only, Cloud Run isgcloudflags, Heroku is at most aProcfilecontainingweb: yarn start, and Dokku, Dokploy, Koyeb, and Northflank need nothing at all.startmust resolve to a runtime dependency, not the CLI. This is the subtle one, and the Cedar team initially got it wrong — their first prescription (promote the whole CLI toolchain out ofdevDependencies) was rightly rejected in review. The correct split is by phase:buildanddevare build-time and may use the CLI fromdevDependencies, butstarthas to run through a small runtime package (@cedarjs/api-server'scedarjs-serverbin) declared independencies. Why that matters is the subject of the next section.- Read
PORT, on the public side only. Every container host injectsPORT; the framework has to honor it. Cedar's resolution chain isCEDAR_* → REDWOOD_* → PORT/HOST → cedar.toml → default, with one critical refinement:PORTapplies to the public listener only. Without that scoping, a both-sidesservecommand binds two listeners to the samePORTand they collide. - Bind dual-stack (
::), not0.0.0.0.0.0.0.0is IPv4-only. Railway's private networking is IPv6-native, so a service bound to0.0.0.0is unreachable there — the plan calls this "the single most likely thing to break a first Railway two-service deploy." Cedar took the strong option: default to::unconditionally and delete the old "you most likely want0.0.0.0in production" hints, since::accepts both stacks on dual-stack hosts.
The PORT-on-the-public-side half of this is older than most of the platforms involved. Heroku originated the contract — the platform picks a port, injects it via PORT, expects the process to bind every interface at 0.0.0.0:$PORT — and Cloud Run, Render, Fly.io, Railway, and App Engine all follow it. Render's variant: web services must bind 0.0.0.0, default port 10000, with PORT injected at runtime. Over a decade of that behavior trained every framework, builder, and deploy doc to assume the same things, which is exactly how an accident becomes a standard.
The matrix: ten platforms, three tiers
Conventions hold — but not uniformly. Verified platform by platform, the field sorts into three tiers:
| Tier | Platforms | What it takes |
|---|---|---|
| Zero-config, no caveats | Railway (Railpack), Render, Cloud Run, Coolify, Dokku, Dokploy, Koyeb, Northflank | Push. build + start + PORT is enough |
| One env var needed | Heroku, DigitalOcean App Platform | Disable dependency pruning (see below) |
| Dockerfile-first | Fly.io | Bring your own Dockerfile; buildpacks officially discouraged |
The middle tier is where the "runtime dependency, not devDependency" rule bites. Heroku's Node buildpack and DigitalOcean's Paketo builders both strip devDependencies after build by default. So a start script routed through the CLI fails in the cruelest possible way: build succeeds, deploy succeeds, container starts — command not found. Railway only works by luck of configuration: Railpack prunes behind the opt-in RAILPACK_PRUNE_DEPS flag. The fixes are one env var each — NPM_CONFIG_PRODUCTION=false or YARN_PRODUCTION=false on Heroku, YARN2_SKIP_PRUNING=true or NPM_CONFIG_PRODUCTION=false on DigitalOcean — or, structurally better, stop routing start through anything in devDependencies in the first place.
Two footnotes worth knowing. First, Coolify is the standout for self-hosters: its Static build pack alongside Nixpacks can natively express the recommended topology (static web/dist next to a Node API process) instead of forcing everything into a single container. Second, Fly.io sits outside the matrix deliberately — its own docs call buildpacks "brittle, bloated, and prone to change," so the Dockerfile path (setup docker in Cedar's case) is the honest answer there, not convention compliance.
Where the convention silently breaks
A standard is only as good as its documented failure modes. The audit surfaced five, each verified against a real deploy path rather than theorized:
- Dependency pruning eats your start command. Covered above, but worth restating as the highest-frequency break: the failure happens after a green build, in a log line that looks like a typo. If your framework's
starttouchesdevDependencies, Heroku and DigitalOcean are broken by default and you will not find out until boot. - IPv4-only binds die on IPv6-native private networks. Railway's service-to-service traffic runs over IPv6. Bind
0.0.0.0and the public side works while the api service is unreachable from the web service — a split-brain failure that looks like a framework proxying bug. Default to::. - Two listeners, one
PORT. Naively applyingPORTto every listener breaks any command that serves both sides in one process. ScopePORTto the public side, or give each side its own variable. - Your health endpoint may 503 the platform's probe. Cedar generates a
healthz.jsfunction for Render — but the generatedrender.yamlnever references it, so the file is inert. The team verified by booting the server and injecting requests:GET /graphql/healthreturns 200 and is usable as a probe path, whileGET /graphql/readinessreturns 503 unless the request carries a matchingx-yoga-idheader — which a platform health checker never will. A readiness endpoint that requires a secret header is not a readiness endpoint. Check yours with a barecurlbefore trusting it. - Non-HTTP and split-service topologies are out of scope. The convention covers one container serving HTTP. Genuine zero-config with a separate API service is unachievable in principle: the two services must be wired to each other — a proxy target or an
apiUrlpointing at a domain that doesn't exist until after the first deploy — and that wiring is irreducibly deployment-specific. Railway's monorepo autodetection comes closest and still leaves the proxy target unset. TCP/L4 workloads, background workers, and cron never enter the picture at all.
Note the shape of that list: every break is at a seam between the convention and something the convention never claimed to cover — build-vs-runtime phase boundaries, v4-vs-v6 address families, public-vs-internal listeners, framework auth vs. platform probes, one container vs. two. The contract is solid exactly where it is written down and absent everywhere else.
What this means if you run a platform
Here the audit flips from framework advice to platform strategy, and the conclusion is blunt: adopt the convention and inherit every framework's deploy docs for free. The moment a platform honors build + start + PORT + ::, every framework that already converged on the contract — Cedar, Redwood, Next.js, anything buildpack-shaped — deploys onto it with documentation the platform never wrote. That is an enormous support-load transfer for roughly four behaviors in the build pipeline.
It also reframes what a platform's own build UX has to do. Cedar's team discovered their generic setup deploy container generator was nearly worthless because the conventions work: there is almost nothing provider-specific left to emit. The real remaining work was elsewhere — extracting the SQLite-to-Postgres migration (eleven manual steps, one Neon-specific) into a provider-agnostic setup database postgres, and documenting serve tiers: single-container as the convenient topology (no service-to-service wiring, web proxying to API in-process) versus API-process-plus-static/CDN as the recommended one.
For a git-push PaaS, the checklist writes itself. Detect build/start before demanding a Dockerfile. Inject PORT and probe it. Default binds to dual-stack. Don't prune devDependencies before start runs — or at least fail loudly at build time when start resolves into pruned territory instead of green-lighting a deploy that dies at boot. Ship a health-check path convention and verify the framework's endpoint answers a bare probe. Each of these is a small behavior; together they are the difference between "works with every framework's defaults" and a per-framework integration matrix that grows forever.
The deeper lesson is about where standards come from. Nobody voted the PORT-plus-start-script contract into existence. It accreted — Heroku's runtime behavior, npm's script conventions, builders all copying each other's autodetection — until deviation became more expensive than compliance. Platforms that fight it (Fly.io excepted, with reasons stated) pay a per-framework tax. Platforms that honor it get eleven frameworks' worth of deploy documentation written by strangers. Accidental standards are still standards. The only choice is whether you interoperate with them on purpose.
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.



