Skip to content
Learni
View all tutorials
DevOps

How to Optimize Docker for Production in 2026

Lire en français

Introduction

In 2026, Docker remains the go-to tool for containerization, but in production, a basic image just won't cut it. Key challenges include shrinking image sizes (up to 90% with multi-stage builds), managing secrets without exposure, reliable health checks for orchestration, and security against CVE vulnerabilities. This expert tutorial walks you through deploying an optimized multi-container Node.js API: horizontal scaling, built-in monitoring, and automated scans. You'll evolve from a dev setup to a production-ready stack that's robust, scalable, and secure—dodging the pitfalls that sink 70% of enterprise Docker deployments. Bookmark this for your CI/CD pipelines.

Prerequisites

  • Docker 27+ and Docker Compose 2.29+ installed
  • Node.js 22+ for the example app
  • Advanced knowledge of Linux, networking, and YAML
  • Access to a private registry (Docker Hub or Harbor)
  • Tools: Trivy for security scans

Create the Base Node.js App

setup-app.sh
mkdir docker-prod-app && cd docker-prod-app
npm init -y
npm install express cors helmet morgan
cat > package.json << 'EOF'
{
  "name": "docker-prod-app",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "cors": "^2.8.5",
    "helmet": "^7.1.0",
    "morgan": "^1.10.0"
  }
}
EOF
cat > server.js << 'EOF'
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');

const app = express();
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));

app.get('/health', (req, res) => res.status(200).json({ status: 'OK' }));
app.get('/api/users', (req, res) => res.json([{ id: 1, name: 'User1' }]));

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server on port ${port}`));
EOF

This Bash script sets up a secure Express API with Helmet (security headers), CORS, Morgan logging, and a health endpoint. It's production-ready from the start, skipping manual configs. Run it to generate the test app; note the /health endpoint for Docker checks.

Optimized Multi-Stage Dockerfile

A multi-stage Dockerfile slashes final image size by 80-90% by separating build and runtime phases. Think of it like a builder (build stage) assembling tools and handing off only the essentials to a lightweight delivery van (runtime stage): just the bare necessities make the trip.

Multi-Stage Dockerfile

Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci --only=production && npm cache clean --force
COPY . .

FROM node:22-alpine AS runtime
WORKDIR /app
RUN addgroup --gid 1001 --system appgroup && \
    adduser --uid 1001 --system appuser --ingroup appgroup
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/server.js .
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["node", "server.js"]

This Dockerfile uses two stages: builder for npm ci (production only), and runtime for a minimal container with a non-root user (security boost). The HEALTHCHECK enables automatic restarts. Final size <100MB vs 1GB native; pitfall: skipping --only=production bloats the image.

.dockerignore for Fast Builds

.dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
*.md
coverage
.nyc_output

This .dockerignore excludes unnecessary files, speeding builds by 50% and cutting vulnerabilities. Like carry-on luggage: only essentials board. Forgetting node_modules exposes local binaries.

Orchestration with Docker Compose

Docker Compose handles multi-service setups: API + Redis + Nginx reverse proxy. In production, use overrides to separate dev/prod configs and secrets to prevent leaks.

Base docker-compose.yml

docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
    volumes:
      - ./logs:/app/logs
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    depends_on:
      redis:
        condition: service_healthy
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      api:
        condition: service_healthy

This Compose file orchestrates API, Redis, and Nginx with conditional health checks (depends_on). Persistent log volumes included. Pitfall: without condition: service_healthy, services start asynchronously and crash.

docker-compose.prod.yml with Secrets

docker-compose.prod.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000"
    environment:
      - NODE_ENV=production
    secrets:
      - db_password
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
      restart_policy:
        condition: on-failure
  redis:
    deploy:
      replicas: 1
secrets:
  db_password:
    external: true

Production override for scaling (replicas), resource limits, and external secrets (docker secret create). Use with docker compose -f docker-compose.yml -f docker-compose.prod.yml up. Avoids .env exposure; pitfall: non-external secrets leak in plaintext.

Build and Security Scan Scripts

Automate builds, pushes, and scans with Bash for CI/CD. Trivy catches CVEs before pushing.

Build and Push Script

build-push.sh
#!/bin/bash
set -e
TAG=prod-app:$(date +%Y%m%d)
REGISTRY=yourregistry.com

# Build multi-arch
DOCKER_BUILDKIT=1 docker buildx create --use
DOCKER_BUILDKIT=1 docker buildx build --platform linux/amd64,linux/arm64 \
  -t $REGISTRY/api:$TAG -t $REGISTRY/api:latest --push .

# Scan sécurité
trivy image --exit-code 1 --no-progress --severity HIGH,CRITICAL $REGISTRY/api:$TAG

echo "Image $TAG pushed and scanned"

This script uses buildx for multi-arch builds (amd64/arm64), pushes, and scans with Trivy (exits 1 on HIGH/CRIT). Install Trivy via brew/apt. Pitfall: skipping --platform causes failures on different cloud arches (Lambda, ECS).

Initialize Docker Swarm for Scaling

swarm-deploy.sh
#!/bin/bash
docker swarm init --advertise-addr $(hostname -i)

# Stack deploy
docker stack deploy -c docker-compose.prod.yml prod-stack \
  --with-registry-auth

# Scale API à 5 replicas
docker service scale prod-stack_api=5

# Inspect
docker service ls
docker service ps prod-stack_api

Initializes Swarm mode for native orchestration (no Kubernetes needed). Deploys stack with registry auth. Dynamic scaling; pitfall: without --advertise-addr, nodes can't join the cluster.

Best Practices

  • Always non-root: USER in Dockerfile limits blast radius (99% of attacks).
  • Multi-stage + .dockerignore: <100MB images, builds <1min.
  • Health checks + conditions: Zero-downtime restarts.
  • External secrets: Never in env vars or volumes.
  • Mandatory CI scans: Trivy/Snyk before push.

Common Mistakes to Avoid

  • Forgetting npm cache: Builds 10x slower; use npm ci --cache.
  • Unnecessary exposed ports: EXPOSE doesn't bind; use selective -p.
  • No resource limits: OOM kills in prod; always set cpus/memory.
  • Swarm without labels: Loses traceability; add deploy/labels.

Next Steps

Level up to Kubernetes with Helm (Learni tutorial), add Prometheus/Grafana for monitoring, or try rootless Podman. Check out our Learni DevOps training for Docker EE certifications and advanced CI/CD.