Skip to main content

Ten Platforms, One Accidental Contract: The PORT-and-Start-Script Standard Behind Every Git-Push Deploy

9 min readDora NodaDora Noda
Share
On this page

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:

json
{
  "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:

  1. build script. Zero-config builders — Railpack, Nixpacks, Paketo, Google Cloud buildpacks, Heroku's Node buildpack — all look for something to run. Cedar's generated apps had no scripts key at all, so every one of those builders found nothing. A build script is the difference between "push and it works" and "write a Dockerfile before anything happens."
  2. start script. Same story at boot time. No start, no container command, no running service. The plan doc's wry summary: once the conventions land, there is almost nothing left for a setup deploy generator to emit — Coolify is dashboard-only, Cloud Run is gcloud flags, Heroku is at most a Procfile containing web: yarn start, and Dokku, Dokploy, Koyeb, and Northflank need nothing at all.
  3. start must 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 of devDependencies) was rightly rejected in review. The correct split is by phase: build and dev are build-time and may use the CLI from devDependencies, but start has to run through a small runtime package (@cedarjs/api-server's cedarjs-server bin) declared in dependencies. Why that matters is the subject of the next section.
  4. Read PORT, on the public side only. Every container host injects PORT; the framework has to honor it. Cedar's resolution chain is CEDAR_* → REDWOOD_* → PORT/HOST → cedar.toml → default, with one critical refinement: PORT applies to the public listener only. Without that scoping, a both-sides serve command binds two listeners to the same PORT and they collide.
  5. Bind dual-stack (::), not 0.0.0.0. 0.0.0.0 is IPv4-only. Railway's private networking is IPv6-native, so a service bound to 0.0.0.0 is 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 want 0.0.0.0 in 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:

TierPlatformsWhat it takes
Zero-config, no caveatsRailway (Railpack), Render, Cloud Run, Coolify, Dokku, Dokploy, Koyeb, NorthflankPush. build + start + PORT is enough
One env var neededHeroku, DigitalOcean App PlatformDisable dependency pruning (see below)
Dockerfile-firstFly.ioBring 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 start touches devDependencies, 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.0 and 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 applying PORT to every listener breaks any command that serves both sides in one process. Scope PORT to the public side, or give each side its own variable.
  • Your health endpoint may 503 the platform's probe. Cedar generates a healthz.js function for Render — but the generated render.yaml never references it, so the file is inert. The team verified by booting the server and injecting requests: GET /graphql/health returns 200 and is usable as a probe path, while GET /graphql/readiness returns 503 unless the request carries a matching x-yoga-id header — which a platform health checker never will. A readiness endpoint that requires a secret header is not a readiness endpoint. Check yours with a bare curl before 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 apiUrl pointing 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.

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