Skip to content

Production Deployment

You need to deploy an ergo API to production with process management, a reverse proxy for TLS termination, health checks for load balancer integration, structured logging for observability, Redis-backed rate limiting for multi-instance consistency, and graceful shutdown for zero-downtime deploys. These concerns are documented individually across several recipe and middleware pages — you need a consolidated reference showing how they compose into a complete deployment.

Wire environment-based configuration, structured logging, Redis rate limiting, a health check endpoint, and graceful shutdown into a single entry point:

import pino from 'pino';
import Redis from 'ioredis';
import {
compose,
handler,
logger,
accepts,
url,
rateLimit,
} from '@centralping/ergo';
import {graceful} from '@centralping/ergo-router';
const {
PORT = '3000',
HOSTNAME = '0.0.0.0',
REDIS_URL = 'redis://localhost:6379',
SHUTDOWN_TIMEOUT = '10000',
} = process.env;
const log = pino({level: 'info'});
const redis = new Redis(REDIS_URL);
class RedisStore {
constructor(client) {
this.redis = client;
}
async hit(key, windowMs) {
const now = Date.now();
const windowKey = `rl:${key}`;
const cutoff = now - windowMs;
const pipeline = this.redis.pipeline();
pipeline.zremrangebyscore(windowKey, 0, cutoff);
pipeline.zadd(windowKey, now, `${now}:${Math.random()}`);
pipeline.zcard(windowKey);
pipeline.zrange(windowKey, 0, 0, 'WITHSCORES');
pipeline.pexpire(windowKey, windowMs);
const results = await pipeline.exec();
const count = results[2][1];
const oldest = results[3][1];
const oldestScore = oldest.length >= 2 ? Number(oldest[1]) : now;
const resetMs = Math.max(0, oldestScore + windowMs - now);
return {count, resetMs};
}
}
const proxyKeyGenerator = (req) => {
const forwarded = req.headers['x-forwarded-for'];
return forwarded
? forwarded.split(',')[0].trim()
: req.socket.remoteAddress;
};
let isShuttingDown = false;
const healthPipeline = handler(
(req, res, acc) => {
if (isShuttingDown) {
return {response: {statusCode: 503, body: {status: 'draining'}}};
}
return {response: {body: {status: 'healthy'}}};
},
);
const apiPipeline = handler(
compose(
logger({log: (info) => log.info(info)}),
accepts({types: ['application/json']}),
url(),
rateLimit({
max: 100,
windowMs: 60_000,
store: new RedisStore(redis),
keyGenerator: proxyKeyGenerator,
}),
(req, res, acc) => ({
response: {body: {path: acc.url.pathname}},
}),
),
);
const requestListener = (req, res) => {
if (req.url === '/health') return healthPipeline(req, res);
return apiPipeline(req, res);
};
const {server, shutdown} = await graceful(requestListener, {
port: Number(PORT),
hostname: HOSTNAME,
timeout: Number(SHUTDOWN_TIMEOUT),
log,
onStartup: async ({log}) => {
await redis.ping();
log.info('Redis connected');
},
onShutdown: async ({log}) => {
isShuttingDown = true;
await redis.quit();
log.info('Redis disconnected');
},
});

Use a multi-stage build to keep the production image minimal:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-alpine
RUN apk add --no-cache tini
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]

Compose the application with nginx and Redis:

services:
app:
build: .
init: true
environment:
- PORT=3000
- HOSTNAME=0.0.0.0
- REDIS_URL=redis://redis:6379
- SHUTDOWN_TIMEOUT=10000
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
nginx:
image: nginx:alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
app:
condition: service_healthy
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3

Configure nginx as a reverse proxy with TLS termination:

worker_processes auto;
events {
worker_connections 1024;
}
http {
upstream api {
server app:3000;
}
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://api;
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_read_timeout 60s;
proxy_send_timeout 60s;
}
}
}

When running behind a reverse proxy, enable trustProxy: true on the router so that ergo-router reads client identity from X-Forwarded-For instead of the direct socket address, and X-Forwarded-Proto for protocol detection. This affects:

  • Request IDs — the request-id transport reads X-Request-Id / X-Forwarded-For headers when trusted
  • HSTS — security headers use X-Forwarded-Proto to determine whether to send Strict-Transport-Security

The application reads all deployment-specific values from environment variables with sensible defaults:

VariableDefaultPurpose
PORT3000HTTP listen port
HOSTNAME0.0.0.0Bind address
REDIS_URLredis://localhost:6379Redis connection string
SHUTDOWN_TIMEOUT10000Graceful shutdown deadline (ms)

This keeps the application image immutable — the same image runs in development, staging, and production with different environment values.

The timeout option on graceful() sets the maximum time to wait for in-flight requests to complete after a shutdown signal. Align this value with your load balancer’s drain timeout:

docker stop --time=30 app ← sends SIGTERM, waits 30s
graceful(..., { timeout: 25000 }) ← app drains for 25s, then force-closes

Set the application timeout slightly below the container stop timeout to ensure the process exits cleanly before the container runtime force-kills it. See the Graceful Shutdown recipe for the complete lifecycle flow and configuration options.

nginx handles TLS termination — the application receives plain HTTP on its internal port. This separates certificate management from application code and allows certificate rotation without application restarts.

The X-Forwarded-Proto: https header from nginx tells ergo-router (via trustProxy: true) that the original client connection was encrypted, enabling correct HSTS header generation and protocol-aware redirects.

Docker sends SIGTERM to PID 1 when stopping a container. Node.js processes that are PID 1 do not handle SIGTERM by default — use one of these approaches:

ApproachHowTrade-off
tini (Dockerfile ENTRYPOINT)ENTRYPOINT ["tini", "--"]Adds ~20KB to image; handles zombie reaping
init: true (docker-compose)init: true in service definitionNo image change; Docker provides the init process
--init (docker run)docker run --init ...Per-invocation flag

graceful() listens for SIGINT and SIGTERM by default and initiates the shutdown sequence automatically. The init process ensures the signal reaches the Node.js process.

  • Kubernetes — orchestration, pod lifecycle, and readiness/liveness probes are out of scope. The health check endpoint works with any orchestrator.
  • CI/CD pipelines — image building and deployment automation depend on your platform.
  • Secrets management — environment variables are shown for simplicity. Use your platform’s secret management (Docker secrets, Vault, cloud KMS) for production credentials.
  • Monitoring and alerting — see Structured Logging for log integration patterns and the Graceful Shutdown recipe for OpenTelemetry SDK lifecycle.