Anti-Patterns

Docker Anti-Patterns: How Not to Build Production Containers

The worst Docker mistakes in production — bloated images, running as root, no health checks, secrets baked into layers. A catalog of container anti-patterns.

Docker has revolutionized how we build, ship, and run applications. But with great power comes great responsibility—poorly configured containers can lead to security vulnerabilities, bloated images, and production headaches. Here’s how to do Docker right.

Use Multi-Stage Builds

Multi-stage builds are the single most impactful optimization for your Docker images.

# Bad - includes build tools in final image
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
# Final image: ~1GB

# Good - multi-stage build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
# Final image: ~150MB

Benefits:

  • Smaller images (10x reduction is common)
  • No build tools or dev dependencies in production
  • Faster deployments and lower storage costs

Use Specific Base Image Tags

Never use latest—it’s a recipe for non-reproducible builds.

# Bad - unpredictable
FROM node:latest

# Better - specific version
FROM node:20

# Best - specific version with digest
FROM node:20.11.0-alpine3.19@sha256:abc123...

Minimize Layers and Leverage Caching

Order your Dockerfile instructions from least to most frequently changing.

# Bad - cache invalidated on any code change
FROM node:20-alpine
COPY . .
RUN npm install

# Good - dependencies cached separately
FROM node:20-alpine
WORKDIR /app

# Dependencies change less frequently
COPY package*.json ./
RUN npm ci --only=production

# Code changes more frequently
COPY . .

Run as Non-Root User

Running as root inside containers is a security risk.

FROM node:20-alpine

# Create app user
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup . .

# Switch to non-root user
USER appuser

CMD ["node", "index.js"]

Use .dockerignore

Just like .gitignore, a .dockerignore keeps unnecessary files out of your image.

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
!README.md
Dockerfile*
docker-compose*
.dockerignore
coverage
.nyc_output
tests
__tests__
*.test.js
*.spec.js

This:

  • Reduces build context size
  • Speeds up builds
  • Prevents secrets from being copied

Health Checks

Let Docker know when your application is actually ready.

FROM node:20-alpine
WORKDIR /app
COPY . .

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "index.js"]

Health checks enable:

  • Automatic container restarts on failure
  • Load balancer integration
  • Proper rolling deployments

Security Best Practices

Scan for Vulnerabilities

# Docker Scout
docker scout cves myimage:latest

# Trivy
trivy image myimage:latest

# Snyk
snyk container test myimage:latest

Don’t Store Secrets in Images

# Never do this
ENV DATABASE_PASSWORD=supersecret

# Use runtime secrets instead
# Docker Swarm: docker secret
# Kubernetes: Secrets/ConfigMaps
# Runtime: environment variables from secure sources

Use Read-Only File System

docker run --read-only --tmpfs /tmp myimage

Optimize for Production

Use Alpine-Based Images

# Standard image: ~1GB
FROM node:20

# Alpine image: ~180MB
FROM node:20-alpine

Copy Only What’s Needed

# Bad - copies everything
COPY . .

# Good - explicit about what's needed
COPY package*.json ./
COPY src ./src
COPY config ./config

Set Resource Limits

# docker-compose.yml
services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M

Logging Best Practices

Write logs to stdout/stderr, not files.

// Good - logs to stdout
console.log('Application started');

// Bad - logs to file inside container
fs.writeFileSync('/var/log/app.log', 'Application started');

Let the container runtime handle log aggregation.

Example Production Dockerfile

Here’s a complete example putting it all together:

# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS base
RUN apk add --no-cache tini
WORKDIR /app

FROM base AS dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

FROM base AS build
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM base AS production
ENV NODE_ENV=production

RUN addgroup -g 1001 -S nodejs && \
    adduser -u 1001 -S nodejs -G nodejs

COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nodejs:nodejs /app/dist ./dist

USER nodejs

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

Conclusion

Production-ready Docker images require attention to:

  • Size - Multi-stage builds, Alpine base images
  • Security - Non-root users, vulnerability scanning
  • Reliability - Health checks, resource limits
  • Reproducibility - Pinned versions, .dockerignore

Take the time to optimize your Dockerfiles—your future deployments will thank you.


What Docker practices have made the biggest difference in your production deployments?