I’ve been containerizing applications for six years now, and I still see teams making the same Docker mistakes I made back in 2019. Bloated 2GB images, 15-minute builds that could be 30 seconds, running as root in production, storing secrets in image layers — the list goes on.

After optimizing Docker builds for everything from Node.js APIs to machine learning pipelines, here’s my collected wisdom. These aren’t theoretical best practices — they’re patterns I’ve battle-tested in production environments handling millions of requests.

Multi-Stage Builds: The Non-Negotiable Foundation

If you’re not using multi-stage builds, you’re probably shipping an image 3-10x larger than necessary. Here’s the pattern I use for every Node.js/TypeScript application:

# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false

# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npm prune --production

# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

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

COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

Size Comparison

Approach Image Size Build Time
Single-stage (no optimization) 1.2 GB 4 min
Multi-stage (basic) 320 MB 2.5 min
Multi-stage (alpine + pruned) 145 MB 2 min
Multi-stage (distroless) 89 MB 2.5 min

Pro Tip: For even smaller images, use Google’s distroless base images instead of Alpine. They contain only your app and its runtime dependencies — no shell, no package manager, nothing an attacker could exploit.

# Distroless for minimum attack surface
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
CMD ["dist/index.js"]

Layer Caching: Make Your Builds 10x Faster

Docker caches layers, and the order of your instructions determines how effectively that cache works. The rule is simple: put things that change least often at the top.

# ❌ Bad: Any source change invalidates everything
FROM node:22-alpine
WORKDIR /app
COPY . .                    # Source changes invalidate this + all below
RUN npm install             # Reinstalls ALL deps every time
RUN npm run build

# ✅ Good: Dependencies are cached separately from source
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # Only changes when deps change
RUN npm ci                                # Cached unless package.json changes
COPY . .                                  # Source changes only rebuild from here
RUN npm run build

Advanced Caching with BuildKit

# syntax=docker/dockerfile:1.5

FROM node:22-alpine AS builder
WORKDIR /app

# Mount cache for npm
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    npm ci

COPY . .
RUN npm run build

Enable BuildKit with:

export DOCKER_BUILDKIT=1
# or in Docker Desktop, it's enabled by default

Pro Tip: For monorepos, use COPY package.json packages/*/package.json ./ patterns with a .dockerignore to only copy what’s needed. This prevents cache invalidation when unrelated packages change.

.dockerignore: The Forgotten Performance Win

Your .dockerignore file is as important as your Dockerfile. Without it, you’re copying node_modules, .git, test files, and documentation into your build context — wasting time and potentially leaking secrets.

# .dockerignore
node_modules
.git
.gitignore
*.md
!README.md
docker-compose*.yml
.env*
.vscode
coverage
tests
__tests__
*.test.ts
*.spec.ts
.next
dist
logs
tmp

Build Context Size Impact

With .dockerignore Without
12 MB sent to daemon 890 MB sent to daemon
Build starts instantly 15+ second upload delay

Security Best Practices

1. Never Run as Root

# Create a dedicated user
RUN addgroup -g 1001 -S app && \
    adduser -S app -u 1001 -G app

# Set ownership of app files
COPY --chown=app:app . .

# Switch to non-root user
USER app

2. Scan for Vulnerabilities

# Scan your image with Docker Scout
docker scout cves myapp:latest

# Or use Trivy (my preference)
trivy image myapp:latest

# In CI/CD (GitHub Actions)
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:latest
    severity: 'CRITICAL,HIGH'
    exit-code: '1'

3. Pin Your Base Image Versions

# ❌ Bad: "latest" could change unexpectedly
FROM node:latest

# ⚠️ Okay: Major version pinned
FROM node:22-alpine

# ✅ Best: Specific digest for reproducibility
FROM node:22-alpine@sha256:abc123...

# ✅ Also good: Specific minor version
FROM node:22.5.1-alpine3.20

4. Don’t Store Secrets in Images

# ❌ NEVER do this - secrets persist in image layers
ENV DATABASE_URL=postgres://user:password@host/db
COPY .env .

# ✅ Use runtime secrets
# Pass via environment at runtime:
# docker run -e DATABASE_URL=... myapp

# Or use Docker secrets:
# docker service create --secret db_password myapp

5. Use Read-Only Filesystem

# docker-compose.yml
services:
  api:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/logs
    security_opt:
      - no-new-privileges:true

Docker Compose for Development

Here’s my go-to development setup for a full-stack application:

# docker-compose.yml
version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
      target: development
    ports:
      - "3000:3000"
      - "9229:9229"  # Debug port
    volumes:
      - ./src:/app/src:delegated
      - /app/node_modules  # Anonymous volume prevents host override
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: npm run dev

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Pro Tip: Use depends_on with condition: service_healthy instead of just depends_on: [db]. Without health checks, your API container starts before PostgreSQL is ready to accept connections, causing startup crashes.

For teams using GitHub Actions for CI/CD, Docker Compose is perfect for integration testing — spin up the full stack, run tests, tear it down.

Health Checks

Always include health checks in your Dockerfiles:

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

For Node.js applications, I prefer a dedicated health endpoint:

app.get('/health', (req, res) => {
  const health = {
    status: 'ok',
    uptime: process.uptime(),
    timestamp: Date.now(),
    checks: {
      database: dbPool.totalCount > 0 ? 'connected' : 'disconnected',
      redis: redisClient.isOpen ? 'connected' : 'disconnected',
    }
  };
  
  const isHealthy = Object.values(health.checks).every(s => s === 'connected');
  res.status(isHealthy ? 200 : 503).json(health);
});

Common Mistakes I Still See in 2025

1. Using npm install Instead of npm ci

# ❌ npm install can modify package-lock.json
RUN npm install

# ✅ npm ci gives reproducible builds from lockfile
RUN npm ci

2. Not Leveraging Build Arguments for Flexibility

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine

ARG APP_VERSION=unknown
ENV APP_VERSION=${APP_VERSION}

# Pass at build time:
# docker build --build-arg APP_VERSION=1.2.3 .

3. Copying the Entire Source When Only dist Is Needed

If your app compiles/transpiles, only copy the build output to the final stage. Never ship source code in production images.

4. Not Using .dockerignore with Build Secrets

If you have a .env file for development, it might accidentally get copied into your image without a proper .dockerignore.

5. Running One Process Per Container (Usually)

The “one process per container” rule has exceptions, but generally:

# ✅ Good: Separate containers for separate concerns
services:
  api:
    image: myapp-api
  worker:
    image: myapp-worker
  scheduler:
    image: myapp-scheduler

# ❌ Avoid: Multiple processes in one container
# (unless they're tightly coupled, like nginx + php-fpm)

Production Optimization Checklist

Optimization Impact Effort
Multi-stage builds 70-90% size reduction Low
Alpine base images 50-80% size reduction Low
.dockerignore Faster builds Very Low
Layer ordering 80% cache hit rate Low
Non-root user Security hardening Low
Health checks Better orchestration Low
BuildKit cache mounts 50% faster installs Medium
Distroless base Maximum security Medium
Image scanning Vulnerability detection Medium
Read-only filesystem Attack surface reduction Medium

Advanced Patterns

Conditional Stages

# Build different targets for different environments
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS development
COPY . .
CMD ["npm", "run", "dev"]

FROM base AS production
COPY . .
RUN npm run build && npm prune --production
CMD ["node", "dist/index.js"]
# Build specific target
docker build --target development -t myapp:dev .
docker build --target production -t myapp:prod .

Docker Init (New in Docker 2025)

Docker now includes docker init which generates Dockerfiles based on your project:

$ docker init
? What application platform does your project use? Node
? What version of Node do you want to use? 22
? Which package manager do you use? npm
? What command do you use to start your app? npm start
? What port does your app listen on? 3000

 Created Dockerfile
 Created .dockerignore
 Created docker-compose.yml

This generates a solid starting point that follows most of the best practices in this article.

For performance-critical deployments, container optimization directly impacts cold start times and scaling speed.

FAQ

Should I use Alpine or Debian-based images?

Use Alpine for most applications — it’s 5-10x smaller than Debian Slim. Switch to Debian when you need native packages that aren’t available in Alpine’s musl-based ecosystem (rare for Node.js/Python, more common for C/C++ dependencies). If you encounter mysterious segfaults or DNS issues, Alpine’s musl libc might be the culprit — switch to Debian Slim.

How often should I rebuild and update base images?

Rebuild weekly at minimum to pick up security patches in base images. Set up automated rebuilds in your CI pipeline that trigger on base image updates. I use Renovate Bot to monitor and auto-PR base image updates. For critical security patches, rebuild immediately.

Docker Desktop vs Podman vs OrbStack — which should I use?

For macOS development, OrbStack is dramatically faster than Docker Desktop (VM boot in <1 second, significantly less RAM usage). Docker Desktop remains the safe enterprise choice with good support. Podman is excellent for Linux and rootless containers. I switched to OrbStack six months ago and haven’t looked back.

How do I keep my Docker images secure?

Four layers: (1) Use minimal base images (Alpine or distroless), (2) Scan images with Trivy/Scout in CI, (3) Run as non-root user, (4) Keep base images updated. Additionally, use --no-new-privileges in your runtime config and consider read-only filesystems. Never store secrets in image layers — use runtime environment variables or Docker secrets.

What’s the best way to handle environment-specific configuration?

Build one image, configure at runtime. Use environment variables for anything that changes between environments (database URLs, API keys, feature flags). For complex configuration, mount config files as volumes or use a config service. Never bake environment-specific values into your image — the same image should run in dev, staging, and production.