Express.js Deployment
Deploy with Render, Railway, Vercel, PM2, Nginx, and Docker — PaaS to production VPS
Render
Railway
Vercel
PM2
Nginx
Docker
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
| Aspect | Docker (Self-hosted) | PaaS (Render/Railway) |
| Control | Complete | Limited |
| Learning curve | Steep | Gentle |
| Setup time | Hours/days | Minutes |
| Cost | VPS cost ($5–20/mo) | Usage-based (~$20–80/mo) |
| Vendor lock-in | None | Yes |
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 curve | Low | Very Low | Low | Medium | High |
| Setup time | 5 min | 2 min | 5 min | 2–4 hours | 1–2 hours |
| Cost (prod) | $19–50+/mo | $10–80/mo | Usage-based | $5–20/mo (VPS) | $5–20/mo (VPS) |
| Control | Medium | Low | Low | Full | Full |
| Scalability | Auto | Manual replicas | Auto (serverless) | Manual (Nginx) | Manual (Swarm/K8s) |
| Database support | Built-in | Built-in | Marketplace only | Self-managed | Self-managed |
| Best for | Production apps | MVPs/prototypes | Frontend apps | Budget/control | Enterprise/portability |
When to Use What
| Your Scenario | Recommendation |
| Weekend project, want it live in 5 minutes | Railway |
| Production full-stack app with moderate traffic | Render |
| Next.js or static site with global CDN | Vercel |
| Have a $5 VPS, want full control and cheap hosting | PM2 + 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 workers | Render, Railway (not Vercel) |
| 46 microservices on a $20 VPS | PM2 + 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