Express.js Deployment

Deploy with Render, Railway, Vercel, PM2, Nginx, and Docker — PaaS to production VPS

Render Railway Vercel PM2 Nginx Docker

Table of Contents

1. Render

What it is: Render is a modern Platform-as-a-Service (PaaS) that simplifies application deployment by abstracting away infrastructure management. It connects directly to your Git repository and automatically builds, deploys, and scales your applications with built-in SSL, CDN, and DDoS protection.

Why it matters

  • Zero DevOps: No server configuration, SSH setup, or firewall rules to manage
  • Native backend support: First-class support for background workers and cron jobs (unlike Vercel)
  • Multiple languages: Supports Node.js, Python, Go, Ruby, Rust, Elixir, and Bun natively
  • Predictable pricing: Plan-based billing instead of usage-based surprises

Key limitations to know

  • Free tier services spin down after 15 minutes of inactivity → cold starts (~30 seconds)
  • Costs add up quickly: a typical production setup (web service + worker + database) can run $50+/month
  • Less infrastructure control than a VPS (can't customize kernel or deep server configs)

render.yaml — Infrastructure as Code:

YAMLservices: - type: web name: my-api runtime: node buildCommand: npm install startCommand: npm start envVars: - key: NODE_ENV value: production - key: DATABASE_URL fromDatabase: name: my-db property: connectionString healthCheckPath: /health autoDeploy: true plan: starter - type: worker name: my-worker runtime: node buildCommand: npm install startCommand: npm run worker envVars: - key: DATABASE_URL fromDatabase: name: my-db property: connectionString - type: cron name: daily-cleanup runtime: node schedule: "0 0 * * *" buildCommand: npm install startCommand: npm run cleanup databases: - name: my-db databaseName: myapp user: myapp_user plan: free

Deploy via CLI:

Bashnpm install -g render-cli render login render link --name my-api render deploy render logs --tail

Express app ready for Render:

JavaScriptconst express = require('express'); const app = express(); const PORT = process.env.PORT || 3000; app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.get('/api/users', async (req, res) => { const users = await db.findMany(); res.json(users); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });

Environment-specific configuration:

JavaScript — config.jsconst config = { development: { databaseUrl: 'postgresql://localhost/myapp', logLevel: 'debug' }, production: { databaseUrl: process.env.DATABASE_URL, logLevel: 'info' } }; const env = process.env.NODE_ENV || 'development'; module.exports = config[env];

2. Railway

What it is: Railway is a deployment platform focused on developer experience and speed. It offers one-click deployments, automatic framework detection, built-in database provisioning, and a visual dashboard for managing entire application stacks.

Why it matters

  • Fastest path to production: Push to GitHub → auto-detects Node.js → live in minutes
  • Built-in databases: One-click deploy PostgreSQL, MySQL, MongoDB, Redis alongside your app
  • No YAML required: Works without configuration files for most projects
  • Spend limits: Set a cap to avoid surprise bills

Key limitations to know

  • Usage-based billing can be unpredictable (small app ~$10/month, production app up to ~$80/month)
  • Limited control over underlying infrastructure (no kernel or host OS access)
  • Only 4 regions (US West, US East, EU West, Asia Southeast)
  • Less battle-tested for large-scale production compared to AWS or Render

Basic deployment (zero YAML):

Bashnpm install -g @railway/cli railway login railway init railway up railway open

Adding PostgreSQL and Redis:

Bash & JavaScriptrailway add --plugin postgresql railway add --plugin redis const { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } }); const Redis = require('ioredis'); const redis = new Redis(process.env.REDIS_URL); async function cache(req, res, next) { const key = `cache:${req.url}`; const cached = await redis.get(key); if (cached) return res.json(JSON.parse(cached)); res.sendResponse = res.json; res.json = (data) => { redis.setex(key, 300, JSON.stringify(data)); res.sendResponse(data); }; next(); }

railway.json for custom configuration:

JSON{ "$schema": "https://railway.app/railway.schema.json", "build": { "builder": "NIXPACKS", "buildCommand": "npm run build" }, "deploy": { "numReplicas": 2, "restartPolicyType": "ON_FAILURE", "restartPolicyMaxRetries": 10, "startCommand": "node dist/server.js", "healthcheckPath": "/health", "healthcheckTimeout": 300 } }

3. Vercel

What it is: Vercel is a deployment platform optimized for frontend frameworks (especially Next.js). It uses a serverless architecture where code runs as short-lived functions that scale to zero when not in use.

Why it matters

  • Framework-defined infrastructure: Automatically optimizes Next.js, React, Vue, and Svelte apps
  • Preview deployments: Every pull request gets a unique, sharable URL
  • Global CDN: Content served from edge locations worldwide
  • Fluid compute: Concurrent requests within same function instance reduce cold starts

Key limitations to know

  • Execution time limit: Functions max out at 800 seconds (~13.3 minutes)
  • Memory limit: Maximum 4GB per function
  • No long-running processes: Can't run WebSockets, background workers, or cron jobs natively
  • Cold starts: First request after idle takes extra time
  • Docker not supported: Must use serverless model
  • Usage-based billing can spike: Bandwidth overages at $0.15/GB

Not suitable for: Data processing (ETL), media transcoding, real-time features (WebSockets), long-running API tasks.

Basic deployment:

Bashnpm i -g vercel vercel vercel --prod

vercel.json configuration:

JSON{ "functions": { "api/users.js": { "memory": 1024, "maxDuration": 10 }, "api/report.js": { "memory": 3008, "maxDuration": 60 } }, "rewrites": [ { "source": "/api/(.*)", "destination": "/api/$1" } ], "headers": [ { "source": "/api/(.*)", "headers": [ { "key": "Access-Control-Allow-Origin", "value": "*" }, { "key": "Cache-Control", "value": "s-maxage=60" } ] } ] }

Serverless function (API route):

JavaScriptexport default async function handler(req, res) { const { method, query, body } = req; if (method === 'GET') { const users = await db.users.findMany({ take: parseInt(query.limit) || 50 }); res.status(200).json(users); } if (method === 'POST') { const user = await db.users.create({ data: body }); res.status(201).json(user); } } export const config = { runtime: 'edge', regions: ['iad1', 'sfo1'] };

Database connection in serverless:

JavaScriptimport { Pool } from '@neondatabase/serverless'; const pool = new Pool({ connectionString: process.env.DATABASE_URL, maxConnections: 10, idleTimeout: 30 }); export async function query(sql, params) { const client = await pool.connect(); try { return await client.query(sql, params); } finally { client.release(); } }

4. PM2 (Process Manager 2)

What it is: PM2 is a production process manager for Node.js applications. It keeps your app running forever, handles crashes with automatic restarts, enables zero-downtime reloads, and provides built-in load balancing via cluster mode.

Why it matters

  • Process supervision: Automatically restarts crashed applications
  • Cluster mode: Spawns multiple processes across CPU cores (no code changes)
  • Zero-downtime reloads: Update code without dropping requests
  • Log management: Aggregates and rotates logs automatically
  • Startup script: Ensures app restarts after server reboot
  • Lightweight: ~100MB overhead for 46 services in production

Basic PM2 usage:

Bashnpm install -g pm2 pm2 start index.js pm2 start index.js --name my-api pm2 start index.js -i max --name my-api pm2 start index.js --watch pm2 list pm2 logs pm2 monit

Ecosystem file (ecosystem.config.js):

JavaScriptmodule.exports = { apps: [ { name: 'api-gateway', script: './services/gateway/index.js', instances: 2, exec_mode: 'cluster', max_memory_restart: '500M', env: { NODE_ENV: 'development', PORT: 3000 }, env_production: { NODE_ENV: 'production', PORT: 3000 }, error_file: './logs/api-error.log', out_file: './logs/api-out.log', kill_timeout: 5000 }, { name: 'background-worker', script: './services/worker/index.js', instances: 1, exec_mode: 'fork' }, { name: 'cron-jobs', script: './services/cron/index.js', instances: 1, cron_restart: '0 */6 * * *' } ], deploy: { production: { user: 'deploy', host: 'your-server.com', ref: 'origin/main', repo: 'git@github.com:myapp.git', path: '/var/www/myapp', 'post-deploy': 'pm2 reload ecosystem.config.js --env production' } } };

Zero-downtime deployment & startup:

Bashpm2 start ecosystem.config.js --env production pm2 reload ecosystem.config.js pm2 gracefulReload my-api pm2 save pm2 startup pm2 startup systemd -u ubuntu --hp /home/ubuntu

5. Nginx

What it is: Nginx is a high-performance web server and reverse proxy. In Node.js deployments, it sits in front of your application, handling SSL termination, static file serving, load balancing, and routing requests to your Node.js processes.

Why it matters

  • Reverse proxy: Shields your Node.js app (localhost:3000) from direct internet exposure
  • SSL termination: Manages HTTPS certificates (Let's Encrypt)
  • Load balancing: Distributes traffic across multiple Node.js processes
  • Static file serving: Serves CSS, JS, images directly (10x faster than Node.js)
  • Rate limiting: Protects against DDoS and abuse
  • Gzip compression: Reduces bandwidth usage by 70–90%

Basic reverse proxy config:

Nginxserver { listen 80; server_name api.myapp.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name api.myapp.com; ssl_certificate /etc/letsencrypt/live/api.myapp.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.myapp.com/privkey.pem; add_header Strict-Transport-Security "max-age=31536000" always; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /static/ { alias /var/www/myapp/public/; expires 30d; } location /health { proxy_pass http://127.0.0.1:3000; access_log off; } }

Load balancing multiple Node.js instances:

Nginxupstream node_cluster { least_conn; server 127.0.0.1:3001 weight=3; server 127.0.0.1:3002 weight=1; server 127.0.0.1:3003 weight=1; server 127.0.0.1:3004 backup; keepalive 32; } server { listen 443 ssl; location / { proxy_pass http://node_cluster; proxy_http_version 1.1; proxy_set_header Connection ""; } }

Rate limiting & WebSocket support:

Nginxlimit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; location /api/ { limit_req zone=api burst=20 nodelay; proxy_pass http://127.0.0.1:3000; } location /socket.io/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

Managing Nginx:

Bashsudo nginx -t sudo nginx -s reload sudo systemctl restart nginx sudo tail -f /var/log/nginx/access.log sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

6. Docker

What it is: Docker is a containerization platform that packages applications and their dependencies into isolated, portable units called containers. Containers run consistently across any environment (development, staging, production).

Why it matters

  • Environment consistency: "Works on my machine" is no longer an issue
  • Isolation: Each service runs in its own sandbox
  • Portability: Same container runs on any Linux server, cloud, or local machine
  • Reproducibility: Infrastructure as Code (Dockerfile + docker-compose.yml)
  • CI/CD friendly: Build once, deploy anywhere

Key concepts

  • Dockerfile: Recipe for building a container image
  • Image: Snapshot of your application + dependencies
  • Container: Running instance of an image
  • Volume: Persistent storage outside the container
  • Network: Communication between containers

Docker vs PaaS

AspectDocker (Self-hosted)PaaS (Render/Railway)
ControlCompleteLimited
Learning curveSteepGentle
Setup timeHours/daysMinutes
CostVPS cost ($5–20/mo)Usage-based (~$20–80/mo)
Vendor lock-inNoneYes

Basic Dockerfile (multi-stage):

DockerfileFROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:18-alpine WORKDIR /app COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/package*.json ./ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 USER nodejs HEALTHCHECK --interval=30s --timeout=3s CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" EXPOSE 3000 CMD ["node", "dist/index.js"]

Build and run:

Bashdocker build -t my-api:latest . docker run -d --name my-api -p 3000:3000 \ -e NODE_ENV=production \ -e DATABASE_URL=postgresql://... \ --restart unless-stopped my-api:latest docker logs -f my-api

Docker Compose (multi-service):

YAMLversion: '3.8' services: api: build: ./api ports: ["3000:3000"] environment: - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/myapp - REDIS_URL=redis://redis:6379 depends_on: [postgres, redis] networks: [myapp-network] worker: build: ./worker environment: - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/myapp depends_on: [postgres, redis] postgres: image: postgres:15-alpine environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=myapp volumes: [postgres_data:/var/lib/postgresql/data] redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: [redis_data:/data] nginx: image: nginx:alpine ports: ["80:80", "443:443"] volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf depends_on: [api] networks: myapp-network: driver: bridge volumes: postgres_data: redis_data:

Docker Compose commands & PM2 combo:

Bash & Dockerfiledocker-compose up -d --build docker-compose logs -f api docker-compose up -d --scale api=3 docker-compose down # PM2 inside container FROM node:18-alpine RUN npm install -g pm2 WORKDIR /app COPY . . CMD ["pm2-runtime", "start", "ecosystem.config.js"]

Comparison Summary

Aspect Render Railway Vercel PM2 + Nginx Docker
Learning curveLowVery LowLowMediumHigh
Setup time5 min2 min5 min2–4 hours1–2 hours
Cost (prod)$19–50+/mo$10–80/moUsage-based$5–20/mo (VPS)$5–20/mo (VPS)
ControlMediumLowLowFullFull
ScalabilityAutoManual replicasAuto (serverless)Manual (Nginx)Manual (Swarm/K8s)
Database supportBuilt-inBuilt-inMarketplace onlySelf-managedSelf-managed
Best forProduction appsMVPs/prototypesFrontend appsBudget/controlEnterprise/portability

When to Use What

Your ScenarioRecommendation
Weekend project, want it live in 5 minutesRailway
Production full-stack app with moderate trafficRender
Next.js or static site with global CDNVercel
Have a $5 VPS, want full control and cheap hostingPM2 + Nginx
Multi-service app with databases (Postgres, Redis)Docker Compose
Enterprise, need to run anywhere (cloud, on-prem)Docker + Kubernetes
Real-time features (WebSockets, long polling)Render, Railway, or PM2 + Nginx (not Vercel)
Cron jobs / background workersRender, Railway (not Vercel)
46 microservices on a $20 VPSPM2 + Nginx

Pro Tips

  • Start with Render/Railway for prototyping → migrate to Docker/VPS when cost becomes an issue
  • Vercel for frontend only → pair with a separate backend (Render, Railway, or VPS)
  • PM2 + Nginx on a VPS is the most cost-effective for scale ($20 VPS can run 46 services)
  • Docker is worth learning even if you use PaaS (skill transfers to any platform)
  • Combination approach: Develop with Docker Compose locally → deploy to Railway/Render for staging → VPS for production