Skip to main content

Railway vs Render vs Fly.io: App Hosting Platforms for Node.js (2026)

·PkgPulse Team

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

FeatureRailwayRenderFly.io
Deploy methodGit push / CLIGit push / CLIDocker / CLI
Build systemNixpacks (auto)Native runtimesDockerfile
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 creditFree tier$5/month credit
PricingUsage-basedPlan-basedUsage-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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.