Deployment

Express.js Intermediate/Advanced
Course 2 · Chapter 8 · Deployment

Running node app.js is a development convenience — not a production deployment. A real deployment needs: environment secrets outside the codebase, a process manager that restarts the app on crash, a reverse proxy that terminates SSL and routes traffic, and ideally a container so the app runs identically everywhere. This chapter covers all four layers.

The Production Stack

Client → Internet
nginx — SSL termination, gzip, static files, rate limiting
PM2 — process management, clustering, restarts, logs
Express.js — your application (multiple worker processes)
MySQL / Redis — data layer

Each layer has a clear responsibility. nginx never runs application code; PM2 never touches HTTP; Express never manages its own restart. Keeping concerns separated means each piece can be scaled, replaced, or debugged independently.

Environment Management

Secrets and environment-specific config (database credentials, JWT secrets, API keys) must never be committed to source control. The standard pattern: store them in a .env file (gitignored), load them with dotenv in development, and inject them via the host environment or a secrets manager in production.

# .env  (gitignored — NEVER commit this)
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_USER=myapp
DB_PASS=supersecret
DB_NAME=myapp_dev
JWT_SECRET=dev-secret-replace-in-prod


// src/config/env.js — validate at startup, fail loudly if anything is missing
const REQUIRED = [
  'NODE_ENV', 'PORT',
  'DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME',
  'JWT_SECRET',
];

const missing = REQUIRED.filter((key) => !process.env[key]);
if (missing.length) {
  throw new Error(
    `Missing required environment variables: ${missing.join(', ')}\n` +
    `Add them to your .env file or the host environment.`
  );
}

module.exports = {
  nodeEnv:   process.env.NODE_ENV,
  port:      Number(process.env.PORT),
  db: {
    host:     process.env.DB_HOST,
    user:     process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
  },
  jwtSecret: process.env.JWT_SECRET,
};

Require dotenv at the very top of your entry point (before any other require) so the values are in process.env when other modules load. In production, don't use dotenv at all — set the variables directly in the host environment or via a secrets manager.

// src/server.js — entry point
if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config();
}

const config = require('./config/env');  // throws if anything is missing
const app    = require('./app');

app.listen(config.port, () => {
  console.log(`Listening on :${config.port} [${config.nodeEnv}]`);
});
Validate at startup, not at request time
If a required secret is missing and you only discover it when a request hits the route that needs it, the app has been silently broken for every request before that. Validating all required variables during startup makes the failure immediate, obvious, and impossible to miss.

PM2 — Process Manager

PM2 keeps your app alive. If the process crashes, PM2 restarts it. In cluster mode it forks multiple worker processes — one per CPU core — so a single machine can handle load proportional to its CPU count, and a crash in one worker doesn't take down the others.

CommandWhat it does
pm2 start ecosystem.config.js --env productionStart using the production env block
pm2 stop my-apiStop (keeps in process list)
pm2 restart my-apiGraceful restart (zero-downtime with cluster)
pm2 reload my-apiRolling restart — each worker restarts one at a time
pm2 delete my-apiRemove from process list
pm2 logs my-apiTail logs
pm2 monitTerminal dashboard (CPU, RAM, restarts)
pm2 startupGenerate systemd/launchd script to auto-start on boot
pm2 savePersist current process list across reboots
// ecosystem.config.js
module.exports = {
  apps: [{
    name:         'my-api',
    script:       'src/server.js',
    instances:    'max',       // one worker per CPU core
    exec_mode:    'cluster',   // share the port across all workers
    watch:        false,        // true in dev only — restarts on file change
    max_memory_restart: '500M',  // kill and restart if worker leaks memory
    merge_logs:   true,         // single log stream across workers
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    error_file:   'logs/err.log',
    out_file:     'logs/out.log',

    env: {                        // development defaults
      NODE_ENV: 'development',
    },
    env_production: {             // used with --env production flag
      NODE_ENV: 'production',
      PORT:     3000,
    },
  }],
};
Cluster mode and session state
In cluster mode, each request may land on a different worker. If your sessions or rate limiter state lives in process memory (a plain JS Map), workers won't share it — worker A's rate limit counter is invisible to worker B. Fix: move shared state to Redis (use ioredis as the store for express-rate-limit, Socket.IO's adapter, and sessions).

nginx as Reverse Proxy

nginx sits in front of Node, handling what Node shouldn't have to: SSL/TLS termination, gzip compression, static file serving, connection buffering, and coarse rate limiting. Node only sees decrypted HTTP on localhost — simpler, faster, and more secure.

upstream node_app {
    server    127.0.0.1:3000;
    keepalive 64;       # reuse connections to Node — avoids TCP handshake per request
}

# HTTP → HTTPS redirect
server {
    listen      80;
    server_name example.com;
    return      301 https://$host$request_uri;
}

server {
    listen      443 ssl http2;
    server_name example.com;

    # SSL — managed by Certbot / Let's Encrypt
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;

    # Compression
    gzip       on;
    gzip_types application/json text/plain application/javascript text/css;

    # Security headers
    add_header X-Frame-Options         DENY;
    add_header X-Content-Type-Options   nosniff;
    add_header Referrer-Policy          strict-origin-when-cross-origin;
    add_header Strict-Transport-Security "max-age=63072000" always;

    # Connection-level rate limit (nginx zone; defined in http block)
    limit_req  zone=api burst=20 nodelay;

    location / {
        proxy_pass         http://node_app;
        proxy_http_version 1.1;

        # Required for WebSocket upgrade
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        'upgrade';

        # Forward real client info to Node
        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;
    }
}

With nginx forwarding X-Forwarded-For, Express must be told to trust the proxy so that req.ip returns the real client IP rather than nginx's loopback address:

// app.js — trust first proxy (nginx)
app.set('trust proxy', 1);
Never set trust proxy without nginx
app.set('trust proxy', 1) tells Express to read the real IP from X-Forwarded-For. Without a reverse proxy, any client can spoof that header to bypass IP-based rate limiting. Only set this when nginx (or another trusted proxy) is actually in front.

Docker

Docker packages the app and its exact runtime (Node version, npm dependencies) into an image that runs identically on any machine. A multi-stage build keeps the final image small: the builder stage installs all dependencies, then the production stage copies only what's needed to run the app.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# --omit=dev skips devDependencies; ci is deterministic (reads package-lock.json)
RUN  npm ci --omit=dev


FROM node:20-alpine AS production
ENV  NODE_ENV=production
WORKDIR /app

# Copy only the production node_modules from the builder stage
COPY --from=builder /app/node_modules ./node_modules
# Copy the application source
COPY . .

# Never run as root inside a container
RUN  addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000

# Container-level health check — Docker orchestrators use this
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "src/server.js"]
InstructionKey point
FROM … AS builderNamed stage — referenced by COPY --from=builder in the next stage
npm ciDeterministic install from package-lock.json; fails if it doesn't match
--omit=devSkips devDependencies (jest, supertest, etc.) — smaller image
COPY . .Must come after the node_modules copy, not before
USER appuserDrop root privileges; container escapes are much less dangerous
EXPOSEDocumentation only — does not actually publish the port
CMD vs ENTRYPOINTCMD is the default command, easily overridden. ENTRYPOINT is the fixed executable. For Node apps, CMD ["node", "src/server.js"] is the standard choice.

The .dockerignore file prevents large or sensitive directories from being sent to the build context:

node_modules
.env
.env.*
logs
*.log
.git
coverage
.nyc_output
*.md

docker-compose — App + Database Together

services:
  app:
    build: .
    ports:
      - "3000:3000"
    env_file: .env.docker        # separate env file for Docker env
    environment:
      DB_HOST: db                # service name = hostname inside Docker network
    depends_on:
      db:
        condition: service_healthy  # wait until MySQL passes its health check
    restart: unless-stopped

  db:
    image: mysql:8.0
    env_file: .env.docker
    volumes:
      - db_data:/var/lib/mysql   # named volume — data survives container restart
    healthcheck:
      test:     ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout:  5s
      retries:  5
    restart: unless-stopped

volumes:
  db_data:
DB_HOST is the service name, not localhost
Inside a Docker Compose network, containers reach each other by service name. If your database service is named db, the app must connect to host db, not localhost. This is set in the environment block above and overrides whatever is in the env file.

Health Check Endpoint

A health check route lets Docker, load balancers, and monitoring systems verify the app is not just running but actually working — database connected, dependencies reachable. Returning 200 means healthy; 503 means the app is up but not ready to serve traffic.

// src/routes/health.js
const router = require('express').Router();
const pool   = require('../db/pool');

router.get('/', async (_req, res) => {
  try {
    await pool.execute('SELECT 1');
    res.json({ status: 'ok', db: 'ok', uptime: process.uptime() });
  } catch {
    res.status(503).json({ status: 'error', db: 'unreachable' });
  }
});

module.exports = router;


// app.js — register before auth middleware (health checks should always respond)
app.use('/health', require('./routes/health'));
Liveness vs Readiness
Kubernetes distinguishes two types of health checks. A liveness probe checks whether the process is still alive (if it fails, restart the container). A readiness probe checks whether it's ready to receive traffic (if it fails, stop routing to this pod). In Express you can implement both on separate paths — /health/live (just res.json({ ok: true })) and /health/ready (includes DB check).

Coding Challenges

Challenge 1 — Environment Validation & PM2

Build a src/config/env.js module that validates all required environment variables on startup, throws a descriptive error listing every missing key, and exports a typed config object. Write Jest tests: all vars present returns the config; each missing var produces an error message naming it; the error lists multiple missing vars at once; the exported types are correct (port is a number, not a string). Also write a complete ecosystem.config.js with cluster mode, memory restart threshold, and production env block.

View solution

Challenge 2 — nginx Config & Trust Proxy

Write a production-ready nginx.conf: HTTP → HTTPS redirect, SSL placeholders, upstream block, proxy_pass, all required proxy headers, gzip, security headers, and a rate limit zone. In Express, implement a src/middleware/realIp.js module that reads the real client IP from X-Forwarded-For when app.set('trust proxy', 1) is active. Write tests: correct IP extracted from forwarded header, falls back to req.socket.remoteAddress when header absent, and a config file test that reads nginx.conf and asserts required directives are present.

View solution

Challenge 3 — Dockerfile, docker-compose & Health Check

Write a multi-stage Dockerfile (builder + production), a .dockerignore, and a docker-compose.yml with app + MySQL + named volume + depends_on: condition: service_healthy. Implement GET /health that returns {status:'ok', db:'ok', uptime} on 200 when the DB query succeeds, and {status:'error', db:'unreachable'} on 503 when it fails. Write supertest tests for both states using a mocked pool, plus a test that reads the Dockerfile and asserts the non-root USER instruction is present.

View solution