Docker makes it easy to containerize applications. Docker makes it equally easy to create bloated, insecure, slow-to-build images. The difference is discipline.

These practices come from running containers in production—where image size affects deployment speed, security vulnerabilities get exploited, and build times multiply across teams.

Start With the Right Base Image

Your base image choice cascades through everything else.

The options:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Full OS - 900MB+
FROM ubuntu:22.04

# Slim OS - 80MB
FROM debian:bookworm-slim

# Minimal - 5MB
FROM alpine:3.19

# Language-specific slim - varies
FROM python:3.12-slim
FROM node:20-alpine

# Distroless - minimal runtime only
FROM gcr.io/distroless/python3

General guidance:

  • Start with slim or alpine variants
  • Use distroless for production when possible
  • Full OS images only when you need specific tools
1
2
3
4
5
6
7
8
9
# Instead of
FROM python:3.12
# Use
FROM python:3.12-slim

# Instead of
FROM node:20
# Use
FROM node:20-alpine

Multi-Stage Builds

Build in one stage, run in another. Keep build tools out of production images.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

Results:

  • Build image: ~800MB (has dev dependencies, source)
  • Production image: ~150MB (only runtime needs)

For compiled languages, even better:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# Production - just the binary
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]

Final image: ~10MB (just a static binary).

Layer Caching

Docker caches layers. Order your Dockerfile to maximize cache hits.

Bad order (cache invalidated on every code change):

1
2
3
4
5
FROM node:20-alpine
WORKDIR /app
COPY . .                    # Everything copied first
RUN npm ci                  # Reinstalls every time
CMD ["node", "server.js"]

Good order (dependencies cached separately):

1
2
3
4
5
6
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./       # Dependencies first
RUN npm ci                  # Cached unless package.json changes
COPY . .                    # Code changes don't bust dep cache
CMD ["node", "server.js"]

Layer caching rules:

  1. Put things that change rarely at the top
  2. Put things that change often at the bottom
  3. Combine related commands to reduce layers

Reduce Image Size

Smaller images = faster pulls, smaller attack surface, cheaper storage.

Combine RUN commands:

1
2
3
4
5
6
7
8
9
# Bad - 3 layers, apt cache retained
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good - 1 layer, clean in same command
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

Use .dockerignore:

#.n.td.go.eeog.idmnscidtedvtsto_*shcmukobed/ruilgensore

Remove unnecessary files:

1
2
RUN pip install --no-cache-dir -r requirements.txt
RUN npm ci --omit=dev

Check what’s taking space:

1
2
3
4
5
# Analyze image layers
docker history myimage:latest

# Detailed breakdown
docker run --rm -it wagoodman/dive myimage:latest

Security Hardening

Don’t Run as Root

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set ownership
COPY --chown=appuser:appgroup . .

# Switch to non-root
USER appuser

CMD ["./app"]

Use Specific Tags

1
2
3
4
5
6
7
8
# Bad - "latest" changes unpredictably
FROM python:latest

# Better - major version
FROM python:3.12

# Best - specific digest
FROM python:3.12-slim@sha256:abc123...

Scan for Vulnerabilities

1
2
3
4
5
6
7
8
# Docker Scout (built-in)
docker scout cves myimage:latest

# Trivy
trivy image myimage:latest

# Snyk
snyk container test myimage:latest

Build scanning into CI:

1
2
3
4
5
6
7
# GitHub Actions
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myimage:${{ github.sha }}
    exit-code: 1
    severity: CRITICAL,HIGH

Don’t Store Secrets in Images

1
2
3
4
5
6
# NEVER do this
ENV DATABASE_PASSWORD=secret123
COPY .env /app/.env

# Instead, pass at runtime
# docker run -e DATABASE_PASSWORD=$SECRET myimage

For build-time secrets:

1
2
3
# Docker BuildKit secrets
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
1
docker build --secret id=npm_token,src=.npmrc .

Health Checks

Let Docker know if your container is healthy:

1
2
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

For containers without curl:

1
2
HEALTHCHECK --interval=30s --timeout=3s \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

Or use a dedicated health check binary:

1
2
COPY healthcheck /usr/local/bin/
HEALTHCHECK CMD ["healthcheck"]

Proper Signal Handling

Containers receive SIGTERM on shutdown. Your app must handle it.

The problem:

1
2
3
# Shell form - runs through /bin/sh
CMD npm start
# SIGTERM goes to sh, not your app

The solution:

1
2
3
# Exec form - runs directly
CMD ["node", "server.js"]
# SIGTERM goes to node

For scripts that must run through shell:

1
2
# Use exec to replace shell with your process
CMD ["sh", "-c", "exec node server.js"]

Or use tini as init:

1
2
3
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Build Arguments vs Environment Variables

ARG: Build-time only, not in final image ENV: Build-time and runtime, persists in image

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Build configuration
ARG NODE_ENV=production
ARG BUILD_VERSION

# Runtime configuration
ENV NODE_ENV=${NODE_ENV}
ENV PORT=8080

# ARG values don't persist
RUN echo "Building version: ${BUILD_VERSION}"

# ENV values do persist
# PORT will be 8080 in running container
1
2
docker build --build-arg BUILD_VERSION=1.2.3 .
docker run -e PORT=3000 myimage  # Override ENV at runtime

Logging

Write to stdout/stderr, not files:

1
2
3
4
5
# Bad - logs trapped in container
CMD ["./app", "--log-file=/var/log/app.log"]

# Good - logs accessible via docker logs
CMD ["./app"]
1
2
3
4
5
6
7
8
9
# In your app
import sys
import logging

logging.basicConfig(
    stream=sys.stdout,
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(message)s'
)

Docker captures stdout/stderr and makes them available via docker logs and logging drivers.

Complete Example

Putting it all together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# syntax=docker/dockerfile:1

# Build stage
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# Production stage
FROM python:3.12-slim

# Security: Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser

WORKDIR /app

# Install runtime dependencies only
RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq5 curl && \
    rm -rf /var/lib/apt/lists/*

# Install Python packages from wheels
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels

# Copy application
COPY --chown=appuser:appgroup . .

# Security: Switch to non-root user
USER appuser

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

# Runtime configuration
ENV PORT=8080
EXPOSE 8080

# Run with exec form for proper signal handling
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

Quick Reference

PracticeWhy
Use slim/alpine baseSmaller images
Multi-stage buildsExclude build tools
Order for cacheFaster builds
.dockerignoreSmaller context
Non-root userSecurity
Specific tagsReproducibility
Health checksOrchestration
Exec form CMDSignal handling
Stdout loggingLog aggregation

Build images like they’re going to production—because eventually, they will.


A container is only as good as its Dockerfile. Make it count.