Skip to content

Deployment

Development Express shuts down when you close the terminal. Production Express needs to survive reboots , traffic spikes , and your worst code
PM2 keeps your app alive. Docker makes it portable. Nginx handles SSL and static files in front of Express. Health checks tell your load balancer you're alive. Graceful shutdown tells your database you're leaving. Skip any of these and your deployment is fragile

PM2 for production process management

npm install -g pm2
// ecosystem.config.js - PM2 config
module.exports = {
  apps: [{
    name: 'my-express-app',
    script: 'server.js',
    instances: 'max',          // use all CPU cores
    exec_mode: 'cluster',      // cluster mode for load balancing
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    env_file: '.env.production',
    max_memory_restart: '500M',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    error_file: './logs/error.log',
    out_file: './logs/output.log',
    merge_logs: true,
    watch: false,
    max_restarts: 10,
    restart_delay: 4000,
    autorestart: true
  }]
}

PM2 commands:

pm2 start ecosystem.config.js      # start app
pm2 list                            # list all apps
pm2 logs                            # view logs
pm2 restart my-express-app          # restart
pm2 reload my-express-app           # zero-downtime reload
pm2 stop my-express-app             # stop
pm2 delete my-express-app           # remove from PM2
pm2 startup                         # auto-start on reboot
pm2 save                            # save process list

Docker multi-stage build

# Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 appgroup && \
    adduser -D -u 1001 -G appgroup appuser

# copy only production dependencies
COPY --from=builder /app/node_modules ./node_modules
COPY . .

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node" , "server.js"]
# .dockerignore
node_modules/
npm-debug.log
.env
.env.*
.git
.gitignore
tests/
logs/
*.md

Build and run:

docker build -t my-express-app .
docker run -p 3000:3000 \
  -e DATABASE_URL=postgres://... \
  -e JWT_SECRET=... \
  my-express-app

Nginx reverse proxy

Express should never face the internet directly. Nginx handles SSL , static files , and load balancing

# /etc/nginx/sites-available/myapp
upstream express_app {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    keepalive 64;
}

server {
    listen 80;
    server_name api.yourapp.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.yourapp.com;

    ssl_certificate /etc/ssl/certs/yourapp.crt;
    ssl_certificate_key /etc/ssl/private/yourapp.key;

    # security headers (that Express Helmet doesn't control)
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "0" always;

    # proxy to Express
    location / {
        proxy_pass http://express_app;
        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;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;

        # rate limiting at Nginx level
        limit_req zone=mylimit burst=20 nodelay;
    }

    # static files - serve directly , bypass Express
    location /static/ {
        alias /var/www/myapp/public/;
        expires 7d;
        add_header Cache-Control "public , immutable";
    }

    # health check - no rate limiting , no auth
    location /health {
        proxy_pass http://express_app;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
    }
}

environment-specific config

// src/config/index.js
const config = {
  development: {
    port: 3000,
    database: {
      host: 'localhost',
      port: 5432,
      name: 'myapp_dev'
    },
    cors: { origin: '*' },
    logLevel: 'dev'
  },
  test: {
    port: 0,                     // random port for tests
    database: {
      host: 'localhost',
      port: 5432,
      name: 'myapp_test'
    },
    cors: { origin: '*' },
    logLevel: 'silent'           // no logs in tests
  },
  production: {
    port: process.env.PORT || 3000,
    database: {
      host: process.env.DB_HOST,
      port: process.env.DB_PORT,
      name: process.env.DB_NAME
    },
    cors: {
      origin: process.env.ALLOWED_ORIGINS?.split(',')
    },
    logLevel: 'combined',
    trustProxy: true             // Express behind Nginx
  }
}

module.exports = config[process.env.NODE_ENV || 'development']

health check endpoint

// routes/health.js
const express = require('express')
const router = express.Router()
const { getPool } = require('../config/database')

router.get('/' , async (req , res) => {
  const health = {
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    memory: process.memoryUsage()
  }

  try {
    // check database connectivity
    const pool = getPool()
    await pool.query('SELECT 1')
    health.database = 'connected'
  } catch (err) {
    health.status = 'degraded'
    health.database = 'disconnected'
  }

  // check memory pressure
  if (health.memory.rss > 500 * 1024 * 1024) {
    health.status = 'degraded'  // high memory usage
  }

  const statusCode = health.status === 'ok' ? 200 : 503
  res.status(statusCode).json(health)
})

module.exports = router
// app.js
app.use('/health' , require('./routes/health'))

graceful shutdown

When your process receives SIGTERM (from Docker , PM2 , or your cloud provider), shut down cleanly

// server.js
const app = require('./src/app')
const { getPool } = require('./src/config/database')

const server = app.listen(process.env.PORT || 3000 , () => {
  console.log(`Server running on port ${process.env.PORT || 3000}`)
})

// graceful shutdown
const gracefulShutdown = async (signal) => {
  console.log(`${signal} received - shutting down gracefully`)

  // stop accepting new requests
  server.close(async () => {
    console.log('HTTP server closed')

    // close database connections
    try {
      const pool = getPool()
      await pool.end()
      console.log('Database pool closed')
    } catch (err) {
      console.error('Error closing database pool:', err)
    }

    process.exit(0)
  })

  // force shutdown after 30 seconds
  setTimeout(() => {
    console.error('Forced shutdown after timeout')
    process.exit(1)
  }, 30000)
}

process.on('SIGTERM' , () => gracefulShutdown('SIGTERM'))
process.on('SIGINT' , () => gracefulShutdown('SIGINT'))

prerequisites

express_16_testing.md - Supertest , Jest , mocking , integration tests , coverage


end of Express curriculum - 18 files covering Express from zero to production