Deploying a Production Node.js API with Docker and Nginx
The health check, process-resilience strategy, free TLS, and zero-downtime CI/CD pipeline most Docker + Nginx tutorials stop short of.
Based on the same Docker, Nginx, and CI/CD pattern used to deploy this site's own NestJS backend, Kuybi โ not a from-scratch tutorial project.
Deploying a Node.js API with Docker and Nginx takes more than a Dockerfile and a reverse-proxy block. Production also needs a health check the orchestrator can actually trust, a process strategy that survives a crash, real TLS instead of a placeholder, and a deploy path that doesn't drop in-flight requests. This guide covers all four, plus the GitHub Actions pipeline that ties them together.
1. A Production-Ready Multi-Stage Dockerfile
Most Docker-for-Node.js tutorials stop at a single-stage Dockerfile that copies everything, runs npm install, and runs as root. None of those three choices belong in production: a single stage drags devDependencies and build tooling into the final image, npm install can silently drift from package-lock.json, and root execution turns any future remote-code-execution bug into a much worse one.
.dockerignore first
Before the Dockerfile, add a .dockerignore โ without it, COPY . . ships your local node_modules, .env, and .git history straight into the image:
node_modules
npm-debug.log
.git
.env
Dockerfile
.dockerignoreThe Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Dedicated non-root system user โ never run the app process as root
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 apiuser
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=apiuser:nodejs . .
USER apiuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]Two details worth calling out: npm ci --omit=dev replaces the older (and now deprecated) npm ci --only=production โ it installs exactly what package-lock.json specifies, dependencies only, and fails outright if the lockfile is out of sync with package.json rather than silently resolving a different tree. And wget in the HEALTHCHECK works without installing anything extra, because Alpine's BusyBox ships it by default.
To audit your own Dockerfile and compose setup for the root-user and resource-limit issues this pattern avoids, run it through the [Docker Compose Security Linter](/tools/aws/docker-compose-linter).
2. A Health Check Endpoint Your Orchestrator Can Actually Trust
HEALTHCHECK is only as useful as what it checks. A route that just returns 200 OK proves the Node.js process is listening โ it says nothing about whether that process can reach its database, which is usually the failure mode that actually takes an API down.
app.get("/health", async (req, res) => {
try {
await db.query("SELECT 1");
res.status(200).json({ status: "ok", uptime: process.uptime() });
} catch (err) {
res.status(503).json({ status: "error", error: err.message });
}
});Wire the same check into Docker Compose so the orchestrator โ not just the container itself โ knows when a replica is actually ready:
services:
api:
build: .
restart: unless-stopped
stop_grace_period: 30s
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3stop_grace_period matters as much as the healthcheck itself: it's how long Docker waits after sending SIGTERM before force-killing the container, giving in-flight requests time to finish during a deploy instead of getting cut off mid-response.
3. Process Resilience: Cluster, PM2, or Docker Replicas?
A crashed node server.js process with nothing watching it takes the whole container down. There are three common ways to prevent that, and they're not equivalent once Docker is already in the picture.
| Approach | Restarts on crash | Uses multiple cores | Zero-downtime reload | Best fit |
|---|---|---|---|---|
Plain node server.js | No | No | No | Never in production |
restart: unless-stopped only | Yes, container-level | No | No | Bare minimum |
Node.js cluster module | Yes, in-process | Yes | Manual | Single container, no orchestrator |
PM2 (exec_mode: cluster) | Yes | Yes | Yes, via pm2 reload | Single host, not containerized |
| Compose replicas + Nginx upstream | Yes, per replica | Yes, across containers | Yes, via rolling redeploy | Already-containerized deployments |
The common mistake is running PM2 *inside* a container that Docker is already restarting and scaling โ the two systems end up doing the same job at two layers. Once you're on Docker Compose, scale the service directly instead:
docker compose up -d --scale api=3 --no-recreateFor Nginx to route to all three replicas โ and keep routing correctly after any of them restart with a new container IP โ point it at Docker's embedded DNS resolver instead of a static upstream block, which would otherwise cache the original IPs at startup and go stale:
resolver 127.0.0.11 valid=10s;
location / {
set $upstream_api api:3000;
proxy_pass http://$upstream_api;
}The set + variable proxy_pass combination forces Nginx to re-resolve api through Docker's internal DNS every 10 seconds, rather than resolving it once and holding onto stale IPs for the life of the worker process.
4. Nginx Reverse Proxy: The Headers and Rate Limits the Tutorial Skipped
A reverse proxy that only forwards requests is leaving most of Nginx's value on the table. Security headers, a rate-limiting zone, and gzip compression cost a few lines and belong in every production config:
server_tokens off;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
listen 80;
server_name api.example.com;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_types application/json application/javascript text/css;
resolver 127.0.0.11 valid=10s;
location / {
limit_req zone=api_limit burst=20 nodelay;
proxy_next_upstream error timeout http_502 http_503;
set $upstream_api api:3000;
proxy_pass http://$upstream_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_next_upstream error timeout http_502 http_503 is doing real work here, not just boilerplate: when one replica is mid-restart and rejects or times out a connection, Nginx retries the same request against a different replica (resolved fresh via the resolver directive above) instead of surfacing the error to the client.
Run your own nginx.conf through the [Nginx Config Security Analyzer](/tools/security/nginx-config-analyzer) to check for missing headers or weak TLS settings, and verify what a live site is actually sending with the [Security Header Analyzer](/tools/security/security-header-analyzer).
5. Free TLS with Certbot โ No Manual Certificate Renewal
Let's Encrypt via Certbot is the standard way to get free, auto-renewing TLS certificates, and it fits cleanly into the same Compose file as a dedicated service sharing two volumes with Nginx: one for the ACME HTTP-01 challenge files, one for the issued certificates.
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- certbot-webroot:/var/www/certbot
- certbot-certs:/etc/letsencrypt
ports:
- "80:80"
- "443:443"
certbot:
image: certbot/certbot
volumes:
- certbot-webroot:/var/www/certbot
- certbot-certs:/etc/letsencrypt
entrypoint: >
/bin/sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h; done"
volumes:
certbot-webroot:
certbot-certs:Add the ACME challenge location to Nginx's port-80 server block so Certbot's verification requests reach the shared volume:
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}The first certificate has to be requested once, manually, before the renewal loop has anything to renew:
docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
-d api.example.com --email you@example.com --agree-tos --no-eff-emailAfter that, the certbot service's while loop checks every 12 hours and silently no-ops until the certificate is within 30 days of expiring โ Let's Encrypt's own renewal window โ at which point it reissues automatically.
6. Zero-Downtime Deploys with GitHub Actions
The last piece is closing the loop: pushing to main should build the image, publish it, and redeploy โ without taking the API down while it happens.
name: Deploy API
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/api
docker compose pull
docker compose up -d --no-deps --build api--no-deps --build api is the important part โ it rebuilds and recreates only the api service, leaving nginx and certbot untouched. Combined with everything from the sections above โ the health check gating readiness, stop_grace_period letting in-flight requests drain, and proxy_next_upstream retrying against a still-healthy replica โ a plain git push becomes a deploy that doesn't drop a request, without reaching for Kubernetes or Swarm to get there.
Summary Checklist
- โ Multi-stage Dockerfile:
npm ci --omit=dev, non-rootUSER, and a realHEALTHCHECK. - โ
/healthendpoint that checks the database, not just process liveness. - โ Compose replicas + Nginx dynamic
resolver, not PM2 duplicating what Docker already does. - โ Nginx security headers,
limit_req_zone, andproxy_next_upstreamfor retry-on-restart. - โ Certbot as its own Compose service, renewing automatically every 12 hours.
- โ CI/CD that redeploys only the changed service, with a grace period to drain requests.
Frequently Asked Questions
Do I still need PM2 if I'm scaling with Docker Compose replicas?
Generally no. PM2's cluster mode and Docker Compose replicas solve the same problem โ surviving a crash and using multiple cores โ at two different layers. Once you're already running multiple containers behind Nginx, adding PM2 inside each container is redundant work that also complicates log collection and signal handling. PM2 still earns its place on a single host with no container orchestration at all.
How do I get free HTTPS for a Dockerized Node.js API?
Run Certbot as its own Docker Compose service sharing two volumes with Nginx โ one for the ACME HTTP-01 challenge files, one for the issued certificates. Request the first certificate once with certbot certonly --webroot, then let a background loop in the Certbot container call certbot renew every 12 hours; Let's Encrypt certificates are valid for 90 days and Certbot only actually renews within the last 30.
Does docker compose up -d --build guarantee zero downtime?
Not by itself. Open-source Nginx has no active health checking, so a request can still hit a container mid-restart. Pairing multiple replicas with proxy_next_upstream (to retry a different replica on a connection error), Nginx's resolver-based dynamic upstream (so it never routes to a stale container IP), and a stop_grace_period long enough to drain in-flight requests gets you very close to zero downtime โ but a hard guarantee needs an orchestrator with health-gated rolling updates, like Swarm or Kubernetes.