Most Docker tutorials teach you enough to get something running. This guide covers what it takes to run reliably in production.
Multi-Stage Builds
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Build the app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production image (minimal)
FROM node:20-alpine AS runner
WORKDIR /app
# Don't run as root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
The result: a 150MB image instead of 1.2GB, and no dev dependencies or build tools in production.
Security Hardening
# Use specific versions, never 'latest' in production
FROM node:20.15.1-alpine3.20
# Drop all capabilities, add only what's needed
# (done via docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE)
# Read-only filesystem where possible
# (done via docker run --read-only --tmpfs /tmp)
# Scan for vulnerabilities
# Run: docker scout cves myimage:latest
# docker-compose.yml production hardening
services:
app:
image: myapp:latest
user: "1001:1001"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
Health Checks
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
// /health endpoint in your app
app.get('/health', (req, res) => {
// Check database connection
db.query('SELECT 1').then(() => {
res.json({ status: 'ok', uptime: process.uptime() });
}).catch(() => {
res.status(503).json({ status: 'degraded' });
});
});
Without health checks, Docker marks containers as running even when they're serving 500 errors.
Zero-Downtime Rolling Updates
# docker-compose.yml
services:
app:
image: myapp:${VERSION}
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
order: start-first # Start new container before stopping old one
failure_action: rollback
rollback_config:
parallelism: 1
order: stop-first
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 10s
retries: 3
start_period: 15s
With order: start-first, Docker starts the new container, waits for it to pass health checks, then removes the old one. Zero-downtime.
Resource Limits
services:
app:
image: myapp:latest
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
Without limits, a memory leak in one container can take down the entire host.
Logging
services:
app:
image: myapp:latest
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Or send to a centralized logging system:
logging:
driver: fluentd
options:
fluentd-address: localhost:24224
tag: myapp
.dockerignore
node_modules
.git
.env
.env.*
*.test.js
coverage
docs
.github
*.md
A missing .dockerignore can add gigabytes of unnecessary context and leak secrets in build context.
Image Scanning in CI
# In GitHub Actions
- name: Scan image for vulnerabilities
uses: anchore/scan-action@v3
with:
image: myapp:${{ github.sha }}
fail-build: true
severity-cutoff: high
The difference between "it works in Docker" and "it runs reliably in production" is mostly these details: health checks, resource limits, non-root user, and zero-downtime update config.