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 {
#local = new Map();
#maxKeys;
constructor(redis, {maxKeys = 10_000} = {}) {
if (!Number.isInteger(maxKeys) || maxKeys < 1) {
throw new TypeError('maxKeys must be a positive integer');
}
this.redis = redis;
this.#maxKeys = maxKeys;
}
hit(key, windowMs) {
const now = Date.now();
const cutoff = now - windowMs;
const windowKey = `rl:${key}`;
let entries = this.#local.get(windowKey);
if (!entries) {
entries = [];
this.#local.set(windowKey, entries);
}
while (entries.length > 0 && entries[0] <= cutoff) entries.shift();
// Delete-then-set refreshes Map insertion order for FIFO eviction
if (entries.length === 0) {
this.#local.delete(windowKey);
entries = [now];
this.#local.set(windowKey, entries);
} else {
entries.push(now);
}
// FIFO eviction — Map insertion order; silent like MemoryStore
if (this.#local.size > this.#maxKeys) {
const oldest = this.#local.keys().next().value;
this.#local.delete(oldest);
}
const count = entries.length;
const resetMs = entries.length > 0
? Math.max(0, entries[0] + windowMs - now)
: windowMs;
const resetAt = now + resetMs;
this.#sync(windowKey, now, cutoff, windowMs);
return {count, resetMs, resetAt};
}
#sync(windowKey, now, cutoff, windowMs) {
const pipeline = this.redis.pipeline();
pipeline.zremrangebyscore(windowKey, 0, cutoff);
pipeline.zadd(windowKey, now, `${now}:${Math.random()}`);
pipeline.pexpire(windowKey, windowMs);
pipeline.exec().catch(() => {});
}
}
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:

Variable Default Purpose
PORT 3000 HTTP listen port
HOSTNAME 0.0.0.0 Bind address
REDIS_URL redis://localhost:6379 Redis connection string
SHUTDOWN_TIMEOUT 10000 Graceful 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:

Approach How Trade-off
tini (Dockerfile ENTRYPOINT) ENTRYPOINT ["tini", "--"] Adds ~20KB to image; handles zombie reaping
init: true (docker-compose) init: true in service definition No 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.