Module 11: Deployment, DevOps & Production Readiness

Process Management with PM2

In non-containerized production environments (bare-metal, VMs, VPS), a Node.js process crashing on an unhandled exception will terminate the entire service. PM2 is an enterprise-grade Production Process Manager that provides automatic restarts, built-in cluster mode without code modifications, log rotation, zero-downtime reloads, and system startup hooks.

Ecosystem Configuration (ecosystem.config.cjs)

Avoid running raw CLI commands in production. Declare execution topology via an ecosystem manifest:

JavaScript

module.exports = {
  apps: [
    {
      name: 'api-service',
      script: './dist/server.js',
      instances: 'max',              // Scales across all available CPU cores automatically
      exec_mode: 'cluster',          // Utilizes Node.js native cluster module under the hood
      watch: false,                  // Never enable watch in production
      max_memory_restart: '1G',      // Kills and re-spawns worker if it leaks over 1GB RAM
      autorestart: true,
      exp_backoff_restart_delay: 100,// Prevents restart thrashing if crashing on boot
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000
      },
      // Centralized PM2 Log aggregation
      error_file: './logs/pm2-err.log',
      out_file: './logs/pm2-out.log',
      merge_logs: true,
      time: true
    }
  ]
};

Core PM2 Commands

Bash

# Start or reload via ecosystem configuration
pm2 start ecosystem.config.cjs --env production

# Zero-downtime rolling reload (workers reboot sequentially)
pm2 reload api-service

# Real-time resource dashboard (CPU, Memory, Event Loop latency)
pm2 monit

# Save current running process list to survive OS reboots
pm2 save
pm2 startup systemd # Generates and registers systemd boot script

Containerization: Production Docker Patterns

Running Node.js securely in Docker requires mitigating common vulnerabilities: running as root, leaking build secrets, bloating image sizes with dev dependencies, and failing to handle OS signals (SIGINT/SIGTERM).

1. Multi-Stage Production Dockerfile

This multi-stage Dockerfile uses a lean Alpine base, isolates native build dependencies, strips development tooling, and drops root privileges.

Dockerfile

# ---------------------------------------------------------------------
# Stage 1: Build Dependencies & Source Compilation
# ---------------------------------------------------------------------
FROM node:22-alpine AS builder

WORKDIR /usr/src/app

# Install native dependencies required for node-gyp, bcrypt, or canvas
RUN apk add --no-cache python3 make g++

# Copy package manifests first to leverage Docker layer caching
COPY package*.json ./

# Install ALL dependencies (including devDependencies like TypeScript)
RUN npm ci

# Copy source and build artifacts
COPY . .
RUN npm run build

# Prune devDependencies to keep only production packages
RUN npm prune --omit=dev

# ---------------------------------------------------------------------
# Stage 2: Minimal Production Runtime
# ---------------------------------------------------------------------
FROM node:22-alpine AS runner

# Tini acts as PID 1 to handle signal forwarding (SIGTERM) and reap zombie processes
RUN apk add --no-cache tini

WORKDIR /usr/src/app

ENV NODE_ENV=production
ENV PORT=3000

# Security: Never run containers as root
# 'node' user is built directly into official Node Alpine images (UID/GID 1000)
USER node

# Copy production dependencies and compiled code with non-root ownership
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/dist ./dist

EXPOSE 3000

# Wrap execution with tini init process
ENTRYPOINT ["/sbin/tini", "--"]

CMD ["node", "dist/server.js"]

2. Multi-Container Orchestration (docker-compose.yml)

A self-contained production-mirror environment linking the Node app, a persistent PostgreSQL database, and a Redis caching layer with health dependencies and resource constraints.

YAML

services:
  app:
    build:
      context: .
      target: runner
    container_name: node_api
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DATABASE_URL=postgresql://app_user:strong_password@postgres:5432/app_db?schema=public
      - REDIS_URL=redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: '1.5'
          memory: 1024M
    networks:
      - internal_network

  postgres:
    image: postgres:16-alpine
    container_name: postgres_db
    restart: unless-stopped
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: strong_password
      POSTGRES_DB: app_db
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal_network

  redis:
    image: redis:7-alpine
    container_name: redis_cache
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - internal_network

volumes:
  pgdata:
  redisdata:

networks:
  internal_network:
    driver: bridge

Logging & Observability

1. Structured Logging with Pino

Standard console.log() executes synchronously when writing to stdout in terminal configurations, which can block the event loop. Pino is an asynchronous, high-throughput JSON logger designed to produce machine-parseable log lines for aggregation tools like Datadog, Grafana Loki, or AWS CloudWatch.

Bash

npm install pino pino-http

JavaScript

// src/utils/logger.js
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Redact sensitive payload properties automatically to protect PII
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie', 'password', 'token', '*.creditCard'],
    censor: '[REDACTED]'
  },
  base: {
    env: process.env.NODE_ENV,
    pid: process.pid
  },
  timestamp: pino.stdTimeFunctions.isoTime
});

JavaScript

// Register HTTP Request Logging Middleware (src/app.js)
import express from 'express';
import { pinoHttp } from 'pino-http';
import crypto from 'node:crypto';
import { logger } from './utils/logger.js';

const app = express();

app.use(
  pinoHttp({
    logger,
    genReqId: (req) => req.headers['x-request-id'] || crypto.randomUUID(),
    customLogLevel: (req, res, err) => {
      if (res.statusCode >= 500 || err) return 'error';
      if (res.statusCode >= 400) return 'warn';
      return 'info';
    }
  })
);

2. Health-Check Endpoints: Liveness vs. Readiness

Container orchestrators (Kubernetes, AWS ECS) use probes to evaluate container state:

  • Liveness Probe (/health/live): Is the Node process running? If this fails, the orchestrator immediately kills and restarts the container.
  • Readiness Probe (/health/ready): Is the application able to handle incoming traffic? Checks database connections, Redis caches, and message brokers. If this fails, traffic routing pauses without restarting the container.

JavaScript

// src/routes/health.routes.js
import { Router } from 'express';
import { prisma } from '../config/db.js';
import { redis } from '../config/redis.js';

const router = Router();

// Liveness Probe
router.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'live', uptime: process.uptime() });
});

// Readiness Probe
router.get('/health/ready', async (req, res) => {
  const checks = {
    database: false,
    redis: false
  };

  try {
    // 1. Verify DB connection
    await prisma.$queryRaw`SELECT 1`;
    checks.database = true;

    // 2. Verify Redis cache connection
    const redisPing = await redis.ping();
    checks.redis = (redisPing === 'PONG');

    const isHealthy = Object.values(checks).every(Boolean);

    res.status(isHealthy ? 200 : 503).json({
      status: isHealthy ? 'ready' : 'unhealthy',
      timestamp: new Date().toISOString(),
      dependencies: checks
    });
  } catch (err) {
    req.log.error({ err }, 'Readiness check failed');
    res.status(503).json({
      status: 'unhealthy',
      dependencies: checks
    });
  }
});

export default router;

3. Graceful Shutdown Signal Handling

When an orchestrator scales down or deploys a new container revision, it sends a SIGTERM signal. The server must close active TCP listeners, finish in-flight requests, close database connection pools, and exit cleanly.

JavaScript

// src/server.js
import { app } from './app.js';
import { prisma } from './config/db.js';
import { redis } from './config/redis.js';
import { logger } from './utils/logger.js';

const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
  logger.info(`Server operational on port ${PORT}`);
});

function gracefulShutdown(signal) {
  logger.info(`Received ${signal}. Starting graceful shutdown sequence...`);

  // 1. Stop accepting new connections
  server.close(async () => {
    logger.info('HTTP server closed. Disconnecting persistent client pools...');

    try {
      // 2. Drain and disconnect database and Redis clients
      await prisma.$disconnect();
      await redis.quit();
      logger.info('Database & Cache connections closed safely.');

      process.exit(0);
    } catch (err) {
      logger.error({ err }, 'Error during resource teardown');
      process.exit(1);
    }
  });

  // Force kill if requests do not clear within a 15-second grace period
  setTimeout(() => {
    logger.error('Forced shutdown: Timed out waiting for lingering connections.');
    process.exit(1);
  }, 15000).unref();
}

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

Deployment Targets & Architectures

Deployment TargetParadigmInfrastructure ModelBest Suited ForTradeoffs
AWS ECS (Fargate)ContainerizedServerless ContainersProduction microservices, sustained trafficRequires VPC, ALB, IAM setup
AWS LambdaServerlessFunction-as-a-ServiceSporadic APIs, webhooks, cron jobsCold starts, 15m run limit, connection limits
AWS EC2Virtual MachineRaw Compute (IaaS)Custom OS needs, legacy monolithsManual OS patching, scaling, security groups
DigitalOcean (App Platform/Droplets)PaaS / VPSManaged runtime or VMSmall/Mid teams, cost-efficient scalingLimited custom low-level networking
Render / RailwayPaaSGit-driven DeploymentsStartups, staging, fast prototypingBandwidth/compute costs scale steeper at volume

CI/CD Pipeline: GitHub Actions

A production CI/CD pipeline validates code quality, runs test suites with real services, builds an optimized Docker image, and triggers an automated rollout.

Create .github/workflows/deploy.yml:

YAML

name: Continuous Integration & Deployment

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  validate-and-test:
    name: Code Quality & Automated Tests
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout Source Code
        uses: actions/checkout@v4

      - name: Setup Node.js Runtime
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Static Code Analysis (Linter & Type Check)
        run: |
          npm run lint --if-present
          npm run typecheck --if-present

      - name: Execute Automated Test Suites
        env:
          DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
          REDIS_URL: redis://localhost:6379
          JWT_ACCESS_SECRET: github_actions_dummy_secret_key_32bytes_long
        run: npm run test:coverage

  build-and-ship:
    name: Build Multi-Stage Docker Image
    needs: validate-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Source Code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Authenticate to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runner
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # Deployment dispatch step (e.g., Render, Railway, AWS ECS task update)
      - name: Trigger Remote Deployment Webhook
        env:
          DEPLOY_WEBHOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK }}
        run: |
          if [ -n "$DEPLOY_WEBHOOK_URL" ]; then
            curl -f -X POST "$DEPLOY_WEBHOOK_URL"
          fi

Recommended Portfolio Projects

  1. REST API with Full Auth & RBAC: Multi-tenant blog or e-commerce API with JWTs, refresh tokens, role checks, and pagination.
  2. Real-Time Collaborative App: Chat application or collaborative document editor with Socket.io and Redis adapters.
  3. High-Throughput File Processor: Video/image transcoding queue or CSV batch importer using streams and background worker queues.