- name
- railway
- description
- Use when deploying an app to Railway — pushing a repo or running railway up, provisioning a managed database, wiring env and reference variables across services, attaching a volume, or fixing a deploy that builds but will not serve. NOT Render (that is `render`), NOT Fly machines (that is `fly-io`), NOT a self-hosted PaaS on your own box (that is `coolify`).
- tags
- ["railway","paas","deploy","services","env-vars","volumes","databases","private-networking","railway-json"]
- recommends
- ["render","fly-io","coolify","postgresdb","db-migrations","domains-dns","deployment","docker"]
- origin
- risco
# 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.
```text
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
```bash
# 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. Use `railway up --detach` to not block on logs, `--service api` to target a
specific service in a multi-service project (otherwise it prompts).
```bash
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.
```ts
// 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>`.
```jsonc
{
"$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](references/config-as-code.md).
## Variables & reference variables
Set plain variables via CLI or dashboard; never inline secrets into `railway.json` (it's
committed).
```bash
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.
```bash
# 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.
```bash
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.
```ts
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.
```bash
# 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
```bash
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 `$PORT` and binds `0.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 `healthcheckPath` route 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](references/cli-cookbook.md).
View on GitHub