Railway Git-Driven Deployment: How It Works and Where It Breaks
Railway's Git-driven deployment auto-builds on push, but three misconfigurations cause most failures: missing start command, unbounded memory, and env var drift. This guide shows a working Node.js + Postgres setup, the exact CLI steps to deploy, and how to verify each layer.
10 May 2026, 19:59 UTC

The Short Answer
Railway builds and deploys your application automatically when you push to the default branch of a connected Git repository. It uses a Dockerfile (or Nixpacks auto-detection) to create a container image, runs the command defined in your Procfile or package.json start script, assigns a public *.railway.app URL, and injects environment variables at runtime. The entire pipeline is zero-config for common stacks, but three specific misconfigurations cause most production failures: missing start command, unbounded memory growth, and environment variable drift between local and deployed environments.
How the Pipeline Executes
When you connect a GitHub, GitLab, or Bitbucket repository to a Railway project, Railway installs a webhook on the default branch (usually main or master). A push to that branch triggers a build job on Railway's build fleet. The build process follows this sequence:
- Source checkout — Railway clones the repository at the pushed commit SHA.
- Buildpack detection — If a
Dockerfileexists at the repo root, Railway uses it. Otherwise Nixpacks inspects the codebase (looking forpackage.json,requirements.txt,go.mod, etc.) and generates a build plan. - Image build — The build runs in an isolated container with 4 GB RAM and 2 vCPU by default. Build logs stream to the Railway dashboard in real time.
- Start command resolution — Railway looks for a
Procfileat the repo root with aweb:process type. Absent that, it falls back to thestartscript inpackage.json(Node),CMDinDockerfile, or the language-specific default (e.g.,python app.py). - Deploy — The built image is pushed to Railway's internal registry, rolled out to the service's allocated resources, and health-checked against the exposed port (default
$PORT, injected at runtime). - URL assignment — A public HTTPS endpoint
https://<service-name>.<project-id>.railway.appis provisioned or updated.
Worked Example: Node.js Service with PostgreSQL
This configuration deploys a typical Express API backed by a managed PostgreSQL instance. The repository structure:
my-api/
├── Dockerfile
├── package.json
├── src/
│ └── index.js
└── railway.json # optional service-level config
Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
package.json (start script)
{
"name": "my-api",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3"
}
}
src/index.js (reads injected env)
import express from 'express';
import pg from 'pg';
const app = express();
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false } // Railway Postgres requires SSL
});
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', db: 'connected' });
} catch (e) {
res.status(500).json({ status: 'degraded', db: e.message });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Listening on ${port}`));
railway.json (optional, pins resource limits)
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE"
},
"deploy": {
"numReplicas": 1,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}
Deploy Steps (run locally with Railway CLI)
- Install CLI:
npm i -g @railway/cli(requires Node ≥ 18). - Authenticate:
railway login— opens browser for OAuth; token stored in~/.railway/config.json. - Link project:
railway link— select existing project or create new; writes.railway/project.jsonto repo root. - Add PostgreSQL:
railway add --database postgresql— provisions a managed instance, injectsDATABASE_URLinto the service's environment automatically. - Set custom env (if needed):
railway variables set NODE_ENV=production— persists in Railway dashboard, not in repo. - Push to trigger build:
git push origin main— webhook fires; watchrailway logs -for the dashboard for build progress. - Verify:
railway statusshows service URL, replica count, and last deployment SHA. Hitcurl https://<url>/health— expect{"status":"ok","db":"connected"}.
Where It Breaks: Three Common Failure Modes
1. Missing or Incorrect Start Command
Symptom: Build succeeds, deployment shows "CrashLoopBackOff" or "Application failed to start" in logs.
Cause: Railway cannot determine what command launches your server. Nixpacks defaults vary by language and often guess wrong for monorepos or non-standard entry points.
Fix: Always provide an explicit Procfile at repo root:
web: node src/index.js
Or ensure package.json has a start script (not just dev). For Dockerfile builds, the CMD instruction is authoritative — Railway ignores Procfile when a Dockerfile is present.
Verification: After a fix, push a trivial change (git commit --allow-empty -m "trigger deploy" && git push) and confirm the log line Listening on $PORT appears.
2. Unbounded Memory Growth Exceeds Plan Limits
Symptom: Service restarts every 30–60 seconds; logs show OOMKilled or Railway dashboard reports "Memory limit exceeded".
Cause: Default service tier (Hobby/Pro) allocates 512 MB or 1 GB RAM. Node.js V8 heap defaults to ~1.4 GB on 64-bit; a memory leak or large in-memory cache will OOM before the process can GC.
Fix: Set Node heap limit via NODE_OPTIONS and configure Railway resource limits:
# In Railway dashboard → Service → Settings → Resources
# Or via railway.json:
"resources": {
"memory": "1024",
"cpu": "500"
}
And in Dockerfile or start command:
CMD ["node", "--max-old-space-size=512", "src/index.js"]
Risk: Increasing limits raises monthly cost. Hobby plan includes 512 MB RAM; each additional 512 MB is $5/mo. Monitor with railway logs --tail 100 | grep -i memory and the dashboard metrics tab.
3. Environment Variable Drift
Symptom: Works locally with .env file, fails in production with "connection refused" or "undefined variable".
Cause: Railway does not read .env files from the repository at runtime. Variables must be set in the Railway dashboard (Project → Variables or Service → Variables) or via railway variables set. A .env committed to Git is a security risk and has no effect on the deployed container.
Fix: Keep secrets out of Git. Use the CLI to sync a local .env once:
# Run from repo root (requires jq)
cat .env | while IFS='=' read -r key value; do
[[ -z "$key" || "$key" =~ ^# ]] && continue
railway variables set "$key=$value"
done
Then verify in dashboard: Project → Variables shows each key with "Service" scope. For per-service overrides (e.g., different DATABASE_URL per environment), use Service → Variables.
Verification: railway variables lists all injected vars. SSH into a running replica (Pro plan only): railway shell → env | grep DATABASE.
Limits You Cannot Work Around
- Build timeout: 30 minutes hard limit. Large monorepos or heavy native compiles (e.g.,
node-gyp) may hit this. Split builds or pre-compile artifacts in CI. - Ephemeral filesystem: Each deploy gets a fresh container. Write uploads, SQLite files, or logs to object storage (S3, Cloudflare R2) or a managed database.
- No background workers on Hobby: Only one
webprocess per service. For cron or queue workers, create a separate service withworker:inProcfile(Pro plan required). - Single-region deployment: Railway projects deploy to one region (default
us-west1). Multi-region requires multiple projects and external load balancing. - No native IPv6: Public URLs are IPv4-only. If your upstream requires IPv6, proxy through Cloudflare.
Practical Verification Checklist
After any deployment change, run this sequence before considering it done:
railway status— confirms service isRUNNING, shows URL and replica count.curl -I https://<service-url>/health— returns200 OKwithin 2 s.railway logs --tail 50— noERROR,OOMKilled, orECONNREFUSEDlines.railway variables— all required keys present, no placeholder values.- Dashboard → Metrics — memory < 80% of limit, CPU < 70% sustained, restart count = 0 in last 10 min.
When to Choose Railway Over Alternatives
| Factor | Railway | Fly.io | Render | Heroku |
|---|---|---|---|---|
| Git-driven deploy | Native, zero-config | Via fly deploy or GitHub Actions | Native | Native |
| Managed Postgres | Included, auto-injected DATABASE_URL | Separate app, manual wiring | Included | Add-on |
| Pricing model | Usage-based (RAM/CPU/GB-hr) | Per-VM fixed | Per-service fixed tiers | Per-dyno fixed |
| CLI ergonomics | Strong (railway shell, logs -f) | Strong (fly ssh console) | Basic | Strong |
| Cold starts | None (always running) | None | Free tier spins down | Free tier spins down |
Railway wins when you want GitOps without writing CI/CD YAML, need a managed database that "just works," and can tolerate usage-based billing. It loses when you need multi-region HA, persistent local disks, or predictable flat-rate pricing.
Final Note
The deployment model is intentionally opaque — you don't manage Kubernetes, load balancers, or SSL certificates. That opacity is the product. When it works, you ship in minutes. When it breaks, the three failure modes above cover 90% of cases. Keep a Procfile, cap your heap, and never commit .env.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.