Railway — ship a repo to a managed PaaS
Railway is an opinionated PaaS: connect a GitHub repo or run railway up, and it builds your
code (Railpack by default, or your Dockerfile), runs the container, networks it, and hands you a
domain. You manage services, environments, variables, volumes, and databases — not servers,
not an OS. This skill makes you fast and correct on Railway's specific surface, not generic
deploy theory.
repo / `railway up` → Railpack (or Dockerfile) build → container runs (binds 0.0.0.0:$PORT)
→ variables + reference vars injected → private net (*.railway.internal) + public domain
Is Railway the right target?
Settle this before touching config. If the user named the platform, honor it and route.
| Want | Use | Why / route |
|---|---|---|
| Push a repo, get a built+running app with zero ops | Railway (this skill) | Managed PaaS, Railpack build, by-the-minute billing |
render.yaml Blueprint, free static sites, their dashboard model |
render |
Different PaaS — route to the render skill |
fly.toml, Firecracker microVMs, fly machine, multi-region edge |
fly-io |
Railway has no fly machine equivalent — route to fly-io |
| Self-host the PaaS on your own Hetzner/DO box | coolify |
Railway is fully managed; Coolify is BYO-server |
| Raw VPS, you manage the OS | hetzner / digitalocean |
IaaS, not PaaS |
| Platform-agnostic release strategy (rolling, blue-green theory) | deployment |
This skill is Railway mechanics, not strategy |
The 60-second path
# Install the CLI (pick one). npm form needs Node 16+.
brew install railway # macOS
npm i -g @railway/cli # any Node 16+ host
bash <(curl -fsSL railway.com/install.sh) # shell installer
railway login # opens browser; for CI use RAILWAY_TOKEN instead (no login)
railway init # create a NEW project from this dir, OR:
railway link # link this dir to an EXISTING project/service
railway up # build + deploy the current dir; streams build/deploy logs
Why link vs init: init makes a fresh project; link attaches an already-created project
(the common case once the project exists in the dashboard). railway up deploys whatever is in
the working dir — no git push required.
Two ways to deploy — pick one per service
- GitHub auto-deploy (default for most teams): connect the repo in the dashboard; every push to the watched branch triggers a build + deploy. Best when you want CI-style "merge to ship".
- CLI
railway up: deploy the working tree directly. Best for first setup, hotfixes, or hosts without git. Userailway up --detachto not block on logs,--service apito target a specific service in a multi-service project (otherwise it prompts).
railway up --detach --service api --environment production
There is no railway deploy --prod — that command does not exist. Use railway up (or push to
the connected branch). To re-run the last deploy unchanged: railway redeploy.
Bind 0.0.0.0:$PORT — the #1 first-deploy failure
Railway's edge proxy and private network reach your container over an injected $PORT. If you
bind localhost/127.0.0.1, the build succeeds but every request 502s and healthchecks fail.
// Bad — only reachable from inside the container; proxy gets connection refused -> 502
app.listen(3000, "127.0.0.1");
// Good — listen on the injected port, bind all interfaces
const port = Number(process.env.PORT) || 3000;
app.listen(port, "0.0.0.0", () => console.log(`up on :${port}`));
Same rule for every stack: read $PORT, bind 0.0.0.0 (or :: — see private networking).
Never hardcode the port the proxy talks to.
Config as code — railway.json (or railway.toml)
Commit railway.json to make build/deploy reproducible. Code config overrides the
dashboard for the fields it sets. Per-environment overrides go under environments.<name>.
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "RAILPACK",
"buildCommand": "npm run build"
},
"deploy": {
"startCommand": "node dist/server.js",
"healthcheckPath": "/healthz",
"healthcheckTimeout": 300,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
},
"environments": {
"production": {
"deploy": { "numReplicas": 2 }
}
}
}
builder is RAILPACK (default; Railway's successor to Nixpacks) or DOCKERFILE (a Dockerfile
is also auto-detected). restartPolicyType is ON_FAILURE | ALWAYS | NEVER. The full field
list — watchPatterns, dockerfilePath, railpackVersion, preDeployCommand,
multiRegionConfig, cronSchedule, overlapSeconds, drainingSeconds, and TOML variants —
lives in references/config-as-code.md.
Variables & reference variables
Set plain variables via CLI or dashboard; never inline secrets into railway.json (it's
committed).
railway variables # list
railway variables --set "LOG_LEVEL=info" # set (older: railway variable set KEY=value)
Wire one service's value into another with template syntax instead of copying it. References stay correct across credential rotations and across environments.
# Bad — hardcoded, breaks the moment Railway rotates the DB password
DATABASE_URL=postgresql://user:p4ss@containers-us-west-12.railway.app:6543/railway
# Good — reference the Postgres service's own variable
DATABASE_URL=${{Postgres.DATABASE_URL}}
Also available: ${{shared.SENTRY_DSN}} (project-shared vars) and Railway-provided vars like
${{RAILWAY_PUBLIC_DOMAIN}}. The left side of the dot is the service name exactly as it
appears in the project.
Managed databases
Add Postgres / MySQL / Redis / MongoDB as services from Railway's official templates, then reference their connection vars into the app.
railway add # interactive: pick a database template (or use + New / cmd-K in dashboard)
railway connect # open a db shell (psql / mongosh / redis-cli) against the service
The DB service exposes vars (e.g. DATABASE_URL, PGHOST) on its Variables tab. Reference them
into the app service as ${{Postgres.DATABASE_URL}} — do not paste the literal URL. Schema
design and SQL live in postgresdb; migrations live in db-migrations. This skill only
provisions and wires.
Volumes
Attach a volume to a service via the dashboard or CLI. Railway auto-injects
RAILWAY_VOLUME_NAME and RAILWAY_VOLUME_MOUNT_PATH at runtime — read them, never define them
yourself.
const dataDir = process.env.RAILWAY_VOLUME_MOUNT_PATH ?? "/data";
Gotcha: a volume pins the service to a single replica — a volume blocks horizontal scaling. If you need many replicas, keep state in a managed DB or object storage, not a volume.
Environments
A project has production plus any environments you add (staging, PR environments). Each gets
its own variables and its own environments.<name> config override. Switch with
railway environment <name>; target a deploy with railway up --environment staging. Variables
and reference targets resolve within the active environment.
Private networking
Every service gets a DNS name under *.railway.internal. Talk service-to-service over it —
internal egress is free, and traffic never leaves Railway.
# from the app service, reach the API service privately
curl http://api.railway.internal:8080/internal/ping
IPv6 gotcha: environments created before 2025-10-16 are IPv6-only internally — bind ::
(not just 0.0.0.0) or recreate the environment. Newer environments resolve both IPv4 and IPv6.
Cross-project and cross-environment private traffic is blocked by design.
Domains
railway domain # generate a *.up.railway.app domain for the current service
For a custom domain, add it in the service settings and Railway gives you a CNAME target. The
registrar-side DNS record work (CNAME/ALIAS at your provider) belongs to domains-dns — this
skill stops at "here is the CNAME target".
Healthcheck & failed-deploy triage
Set healthcheckPath in railway.json so Railway gates the deploy on a real readiness route.
When a deploy builds but won't go live, read railway logs and walk these in order:
| Symptom | Likely cause | Fix |
|---|---|---|
| 502 on every request, healthcheck times out | App bound localhost, not 0.0.0.0:$PORT |
Bind 0.0.0.0 (or ::), listen on $PORT |
| "no start command" / container exits 0 immediately | No startCommand and Railpack can't infer one |
Set deploy.startCommand |
| App crashes on boot referencing a config key | Missing variable / unresolved ${{...}} reference |
Set the var; check the service name in the reference |
| Healthcheck 404s | healthcheckPath points at a route that doesn't exist |
Point it at an existing route or remove it |
Cost awareness
There is no permanent free tier. New accounts get a one-time $5 trial credit. Hobby is $5/mo (includes $5 usage), Pro is $20/mo (includes $20 usage); usage is billed by the minute and the included subscription credit is consumed first, then you pay the delta. An idle service left running still bills. Don't leave throwaway preview services up; delete environments you're done with.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
Binding localhost / 127.0.0.1 |
Proxy + private net can't reach it → 502 | Bind 0.0.0.0 (or ::), listen on $PORT |
Inlining a secret/connection string in railway.json |
It's committed → leaked secret | Put secrets in variables; reference them |
Hardcoding DATABASE_URL into the app's vars |
Breaks on credential rotation / env change | Use ${{Postgres.DATABASE_URL}} |
| One mega-service running web + worker + cron | No independent scaling/restart, tangled logs | Split into services in one project |
| Assuming a free tier exists | Surprise — trial credit runs out, deploys stop | Plan for Hobby/Pro; watch by-the-minute usage |
| Assuming IPv4 on a pre-2025-10-16 environment | Private DNS resolves IPv6-only → connection refused | Bind :: or recreate the environment |
Committing the .railway link as if it were config |
It's local link state, not portable config | Config-as-code goes in railway.json |
Verification
- App reads
$PORTand binds0.0.0.0(or::) — not a hardcoded port on localhost. - No secret/connection string is inlined in
railway.json; secrets are variables. - Cross-service values use
${{Service.VAR}}references, not copied literals. builder∈ {RAILPACK, DOCKERFILE};restartPolicyType∈ {ON_FAILURE, ALWAYS, NEVER}.- A real
healthcheckPathroute exists if one is configured.
Run bash scripts/verify.sh from the target dir to structurally lint a present railway.json.
It is a no-op pass when no config file exists (CLI/dashboard-only use is valid). For the deeper
CLI recipe catalog (CI deploys with RAILWAY_TOKEN, railway run for local dev against prod
vars, db shells, multi-service deploys), see references/cli-cookbook.md.