Railway vs Render vs Fly.io: App Hosting Platforms for Node.js (2026)
TL;DR
Railway is the modern PaaS — deploy from GitHub with zero config, provisioned databases, environment management, usage-based pricing, the fastest path from git push to production. Render is the unified cloud platform — web services, static sites, cron jobs, managed databases, free tier, auto-scaling, a modern Heroku alternative. Fly.io is the edge-first platform — deploy Docker containers globally, multi-region databases, Machines API, sub-200ms latency worldwide. In 2026: Railway for the fastest deployment experience, Render for a complete PaaS with free tier, Fly.io for global edge deployment.
Key Takeaways
- Railway: Fastest deploy — git push to production in seconds, usage-based pricing
- Render: Most complete — web services + databases + cron + static, free tier
- Fly.io: Most global — deploy to 30+ regions, edge machines, multi-region Postgres
- Railway has the best developer experience for quick deployments
- Render has the most generous free tier for getting started
- Fly.io has the best multi-region deployment capabilities
Railway
Railway — modern cloud platform:
Deploy from GitHub
# CLI deploy:
npm install -g @railway/cli
railway login
railway init
railway up
# Or connect GitHub repo:
# 1. Connect repo in Railway dashboard
# 2. Push to main → auto-deploy
# 3. Push to branch → preview environment
railway.toml configuration
[build]
builder = "nixpacks"
buildCommand = "npm run build"
[deploy]
startCommand = "npm start"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 5
[deploy.resources]
memory = "512Mi"
cpu = "1"
Provisioned services
// Railway provisions services with environment variables:
// PostgreSQL (auto-provisioned):
import { Pool } from "pg"
const pool = new Pool({
connectionString: process.env.DATABASE_URL, // Auto-injected by Railway
})
// Redis (auto-provisioned):
import { createClient } from "redis"
const redis = createClient({
url: process.env.REDIS_URL, // Auto-injected
})
// Express app:
import express from "express"
const app = express()
const PORT = process.env.PORT || 3000 // Railway sets PORT
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() })
})
app.get("/api/packages", async (req, res) => {
const { rows } = await pool.query(
"SELECT * FROM packages ORDER BY downloads DESC LIMIT 50"
)
res.json(rows)
})
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Environment management
# Railway CLI — environment variables:
railway variables set DATABASE_URL="postgres://..."
railway variables set REDIS_URL="redis://..."
railway variables set NODE_ENV="production"
# Multiple environments:
railway environment create staging
railway environment switch staging
railway variables set NODE_ENV="staging"
# Deploy to specific environment:
railway up --environment staging
# Logs:
railway logs
railway logs --follow
# Service management:
railway service list
railway service restart
Render
Render — unified cloud platform:
render.yaml (Infrastructure as Code)
# render.yaml — define all services:
services:
# Web service:
- type: web
name: pkgpulse-api
runtime: node
buildCommand: npm install && npm run build
startCommand: npm start
envVars:
- key: DATABASE_URL
fromDatabase:
name: pkgpulse-db
property: connectionString
- key: REDIS_URL
fromService:
name: pkgpulse-redis
type: redis
property: connectionString
- key: NODE_ENV
value: production
healthCheckPath: /health
autoDeploy: true
plan: starter # free, starter, standard, pro
scaling:
minInstances: 1
maxInstances: 3
targetMemoryPercent: 80
# Background worker:
- type: worker
name: pkgpulse-worker
runtime: node
buildCommand: npm install && npm run build
startCommand: npm run worker
envVars:
- key: DATABASE_URL
fromDatabase:
name: pkgpulse-db
property: connectionString
# Cron job:
- type: cron
name: pkgpulse-sync
runtime: node
buildCommand: npm install && npm run build
startCommand: npm run sync
schedule: "0 */6 * * *" # Every 6 hours
envVars:
- key: DATABASE_URL
fromDatabase:
name: pkgpulse-db
property: connectionString
# Static site:
- type: web
name: pkgpulse-docs
runtime: static
buildCommand: npm run build:docs
staticPublishPath: ./dist
headers:
- path: /*
name: Cache-Control
value: public, max-age=3600
databases:
- name: pkgpulse-db
plan: starter # free, starter, standard, pro
databaseName: pkgpulse
postgresMajorVersion: 16
- name: pkgpulse-redis
plan: starter
type: redis
maxmemoryPolicy: allkeys-lru
Node.js application
// Express app on Render:
import express from "express"
import { Pool } from "pg"
const app = express()
const PORT = process.env.PORT || 10000 // Render uses port 10000
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
})
app.get("/health", (req, res) => {
res.json({ status: "ok" })
})
app.get("/api/packages", async (req, res) => {
const { rows } = await pool.query(
"SELECT * FROM packages ORDER BY downloads DESC LIMIT $1",
[req.query.limit || 50]
)
res.json(rows)
})
// Background worker:
// worker.ts
import { Pool } from "pg"
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
async function syncPackages() {
console.log("Starting package sync...")
const response = await fetch("https://registry.npmjs.org/-/v1/search?text=react&size=100")
const { objects } = await response.json()
for (const { package: pkg } of objects) {
await pool.query(
`INSERT INTO packages (name, description, version)
VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE SET
description = $2, version = $3, updated_at = NOW()`,
[pkg.name, pkg.description, pkg.version]
)
}
console.log(`Synced ${objects.length} packages`)
}
// Run continuously for worker service:
setInterval(syncPackages, 6 * 60 * 60 * 1000) // Every 6 hours
syncPackages() // Initial run
Auto-scaling and deploy hooks
// Render auto-scaling is configured in render.yaml or dashboard:
// - Min/max instances
// - Target CPU/memory percentage
// - Custom metrics (via Render API)
// Deploy hooks (trigger deploys programmatically):
const deployHookUrl = process.env.RENDER_DEPLOY_HOOK_URL
// Trigger deploy:
await fetch(deployHookUrl!, { method: "POST" })
// Render API:
const RENDER_API = "https://api.render.com/v1"
const headers = {
Authorization: `Bearer ${process.env.RENDER_API_KEY}`,
}
// List services:
const services = await fetch(`${RENDER_API}/services`, { headers })
const { data } = await services.json()
// Get deploy status:
const deploys = await fetch(
`${RENDER_API}/services/${SERVICE_ID}/deploys`,
{ headers }
)
Fly.io
Fly.io — global edge platform:
Deploy with fly.toml
# fly.toml
app = "pkgpulse-api"
primary_region = "iad" # US East
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[checks]
[checks.health]
port = 3000
type = "http"
interval = "10s"
timeout = "2s"
grace_period = "10s"
method = "GET"
path = "/health"
[[vm]]
memory = "512mb"
cpu_kind = "shared"
cpus = 1
[env]
NODE_ENV = "production"
Multi-region deployment
# Deploy:
fly launch
fly deploy
# Add regions:
fly regions add ams lhr nrt syd
# → App runs in: iad (primary), ams, lhr, nrt, syd
# Scale:
fly scale count 3 # 3 machines per region
fly scale vm shared-cpu-2x --memory 1024
# Secrets:
fly secrets set DATABASE_URL="postgres://..."
fly secrets set REDIS_URL="redis://..."
# Postgres (multi-region):
fly postgres create --name pkgpulse-db --region iad
fly postgres attach pkgpulse-db
# Add read replicas:
fly postgres create --name pkgpulse-db-replica --region ams
Node.js with multi-region
// Dockerfile:
// FROM node:20-slim
// WORKDIR /app
// COPY package*.json ./
// RUN npm ci --production
// COPY dist/ ./dist/
// EXPOSE 3000
// CMD ["node", "dist/server.js"]
import express from "express"
import { Pool } from "pg"
const app = express()
const PRIMARY_REGION = process.env.PRIMARY_REGION || "iad"
const FLY_REGION = process.env.FLY_REGION || "unknown"
// Read replica routing:
const primaryPool = new Pool({
connectionString: process.env.DATABASE_URL,
})
const replicaPool = new Pool({
connectionString: process.env.DATABASE_URL?.replace(
"5432",
"5433" // Fly.io read replica port
),
})
// Route reads to nearest replica, writes to primary:
app.get("/api/packages", async (req, res) => {
const pool = replicaPool // Read from nearest replica
const { rows } = await pool.query(
"SELECT * FROM packages ORDER BY downloads DESC LIMIT 50"
)
res.setHeader("fly-region", FLY_REGION)
res.json(rows)
})
app.post("/api/packages", async (req, res) => {
// Write must go to primary region:
if (FLY_REGION !== PRIMARY_REGION) {
// Replay request to primary region:
res.setHeader("fly-replay", `region=${PRIMARY_REGION}`)
return res.status(409).end()
}
const { name, description, version } = req.body
const { rows } = await primaryPool.query(
"INSERT INTO packages (name, description, version) VALUES ($1, $2, $3) RETURNING *",
[name, description, version]
)
res.json(rows[0])
})
app.get("/health", (req, res) => {
res.json({
status: "ok",
region: FLY_REGION,
primary: PRIMARY_REGION,
})
})
app.listen(3000)
Machines API
// Fly Machines API — programmatic VM management:
const FLY_API = "https://api.machines.dev/v1"
const headers = {
Authorization: `Bearer ${process.env.FLY_API_TOKEN}`,
"Content-Type": "application/json",
}
// List machines:
const machines = await fetch(
`${FLY_API}/apps/pkgpulse-api/machines`,
{ headers }
)
const machineList = await machines.json()
machineList.forEach((m: any) => {
console.log(`${m.id}: ${m.region} — ${m.state}`)
})
// Create a machine:
const newMachine = await fetch(
`${FLY_API}/apps/pkgpulse-api/machines`,
{
method: "POST",
headers,
body: JSON.stringify({
region: "ams",
config: {
image: "registry.fly.io/pkgpulse-api:latest",
guest: { cpu_kind: "shared", cpus: 1, memory_mb: 512 },
services: [{
ports: [{ port: 443, handlers: ["tls", "http"] }],
internal_port: 3000,
protocol: "tcp",
}],
env: { NODE_ENV: "production" },
},
}),
}
)
// Stop/start machines:
await fetch(`${FLY_API}/apps/pkgpulse-api/machines/${machineId}/stop`, {
method: "POST", headers,
})
await fetch(`${FLY_API}/apps/pkgpulse-api/machines/${machineId}/start`, {
method: "POST", headers,
})
Feature Comparison
| Feature | Railway | Render | Fly.io |
|---|---|---|---|
| Deploy method | Git push / CLI | Git push / CLI | Docker / CLI |
| Build system | Nixpacks (auto) | Native runtimes | Dockerfile |
| Web services | ✅ | ✅ | ✅ |
| Background workers | ✅ | ✅ | ✅ |
| Cron jobs | ✅ | ✅ | ✅ (via Machines) |
| Static sites | ✅ | ✅ | ❌ |
| Managed Postgres | ✅ | ✅ | ✅ (multi-region) |
| Managed Redis | ✅ | ✅ | ✅ (Upstash) |
| Multi-region | ❌ | ❌ | ✅ (30+ regions) |
| Auto-scaling | ✅ (basic) | ✅ | ✅ (Machines) |
| Preview environments | ✅ | ✅ | ✅ |
| Custom domains | ✅ | ✅ | ✅ |
| HTTPS | ✅ (auto) | ✅ (auto) | ✅ (auto) |
| Docker support | ✅ | ✅ | ✅ (native) |
| DDoS protection | ✅ | ✅ | ✅ |
| Free tier | $5/month credit | Free tier | $5/month credit |
| Pricing | Usage-based | Plan-based | Usage-based |
When to Use Each
Use Railway if:
- Want the fastest deployment experience (git push → live in seconds)
- Need provisioned databases and services with auto-config
- Prefer usage-based pricing with no complex configuration
- Building side projects or startups that need quick iteration
Use Render if:
- Want a complete PaaS with web services, workers, cron, and static sites
- Need managed databases with easy scaling
- Prefer Infrastructure as Code with render.yaml
- Want a free tier to get started without a credit card
Use Fly.io if:
- Need global multi-region deployment for low latency
- Want to deploy Docker containers with full control
- Need multi-region Postgres with read replicas
- Building latency-sensitive applications serving a global audience
Methodology
Feature comparison based on Railway, Render, and Fly.io platforms and pricing as of March 2026.
Compare hosting platforms and developer tooling on PkgPulse →