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
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}]`);
});
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.
| Command | What it does |
|---|---|
pm2 start ecosystem.config.js --env production | Start using the production env block |
pm2 stop my-api | Stop (keeps in process list) |
pm2 restart my-api | Graceful restart (zero-downtime with cluster) |
pm2 reload my-api | Rolling restart — each worker restarts one at a time |
pm2 delete my-api | Remove from process list |
pm2 logs my-api | Tail logs |
pm2 monit | Terminal dashboard (CPU, RAM, restarts) |
pm2 startup | Generate systemd/launchd script to auto-start on boot |
pm2 save | Persist 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,
},
}],
};
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);
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"]
| Instruction | Key point |
|---|---|
FROM … AS builder | Named stage — referenced by COPY --from=builder in the next stage |
npm ci | Deterministic install from package-lock.json; fails if it doesn't match |
--omit=dev | Skips devDependencies (jest, supertest, etc.) — smaller image |
COPY . . | Must come after the node_modules copy, not before |
USER appuser | Drop root privileges; container escapes are much less dangerous |
EXPOSE | Documentation only — does not actually publish the port |
CMD vs ENTRYPOINT | CMD 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, 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'));
/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.
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.
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.