Dockerfile Best Practices

What Makes an Effective Dockerfile?

A well-crafted Dockerfile improves build speed, reduces image size, enhances security, and makes container maintenance easier. Best practices go beyond just getting code to run-they ensure production-ready, efficient containers.


Layer Optimization

Each RUN, COPY, and ADD instruction creates a layer in the Docker image. Understanding and optimizing layers is fundamental to efficient Dockerfiles.

Why it matters: Docker uses layer caching to speed up builds. When a layer hasn't changed, Docker reuses the cached version instead of rebuilding. However, each layer adds size to the final image. Additionally, package manager caches (like apt-get) accumulate in separate layers, inflating image size unnecessarily. By combining related commands into single layers and cleaning up temporary files within the same layer, developers significantly reduce image size and build time.

graph TD A["Dockerfile Instructions"] -->B["Each Line Creates a Layer"] B -->C["Layers are Cached"] C -->D{Cache Hit?} D -->|Yes| E["Reuse Layer
Fast Build"] D -->|No| F["Rebuild Layer
Slower"] E -->G["Final Image
Stacked Layers"] F -->G style E fill:#90EE90 style F fill:#FFB6C6

Combine Commands

Inefficient approach - creates multiple layers:

# BAD: 3 layers for dependencies RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y git

Why this matters: Each separate RUN command creates a distinct layer. The package manager cache persists in each layer independently, causing the final image to include cumulative cache data. For example, running apt-get update three times means three separate cache layers. Additionally, if a later layer modifies these packages, all previous layers remain in the image, wasting space.

Optimized approach - single layer:

# GOOD: 1 layer for all dependencies RUN apt-get update && \ apt-get install -y curl git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*

This creates a single layer where the package manager cache is created and immediately cleaned up within the same layer, ensuring the cache doesn't persist in the final image.

Order Instructions Strategically

Place frequently changing instructions at the end to maximize cache reuse.

# GOOD: Static files first, dynamic last FROM python:3.9-slim WORKDIR /app # Rarely changes COPY requirements.txt . RUN pip install -r requirements.txt # Changes frequently COPY . . CMD ["python", "app.py"]

Why this matters: Docker caches layers based on instruction content. When a layer's cache is invalidated (because the content changed), all subsequent layers must be rebuilt too. If application code (COPY . .) comes before dependency installation, every code change invalidates the dependencies layer, forcing a complete reinstall-even if dependencies haven't changed. By placing code-related instructions at the end, dependency layers remain cached across builds, dramatically reducing build time during development when dependencies rarely change but code changes frequently.


Use .dockerignore

Exclude unnecessary files from the build context to reduce image size and build time.

Why it matters: When running docker build, Docker sends the entire directory (build context) to the daemon. If the context includes large directories like node_modules/, .git/, or test files, the build process becomes slow because it must transfer and process all these files, even if they're not used in the image. By using .dockerignore, developers exclude unnecessary files, speeding up builds and reducing memory usage. Additionally, excluding sensitive files (like .env) ensures they don't accidentally end up in the final image.

# .dockerignore file __pycache__/ *.pyc .git .gitignore .env node_modules/ .venv/ dist/ build/ *.log .pytest_cache/

Security Best Practices

Security must be a primary concern when containerizing applications. Running containers without proper security hardening creates vulnerabilities that persist in production.

Run as Non-Root User

Never run applications as root. Create a dedicated user.

Why this matters: If a container runs as root and an attacker gains code execution within it, they can perform any action on the container-modify files, install malware, or escape to the host system. Creating and running as a non-root user limits the damage an attacker can do. Even if code execution occurs, the attacker has only the permissions of that user, not system-wide privileges. This follows the principle of least privilege: applications should run with only the minimal permissions required to function.

FROM python:3.9-slim WORKDIR /app RUN useradd -m -u 1000 appuser COPY requirements.txt . RUN pip install -r requirements.txt COPY . . # Switch to non-root user USER appuser CMD ["python", "app.py"]

Scan Images for Vulnerabilities

Why this matters: Base images and dependencies may contain known security vulnerabilities. Scanning identifies these vulnerabilities before deployment, allowing developers to patch or update dependencies. Unscanned images deployed to production create security risks. Docker Scout and Trivy automate vulnerability detection.

# After building image docker scan my-app:latest

Keep Images Updated

Use specific base image versions instead of latest.

Why this matters: Using latest creates unpredictability. When rebuilding an image months later, latest may refer to a completely different version with breaking changes or incompatible dependencies. Specific versions ensure reproducibility-the image builds the same way every time. Additionally, latest tags may include security patches that aren't thoroughly tested with the application, potentially causing runtime issues. Pinned versions provide stability and predictability.

# GOOD: Pinned version FROM python:3.9.17-slim # BAD: Unpredictable version FROM python:latest

Multi-Stage Builds

Separate build and runtime stages to reduce final image size.

Why it matters: Build tools (compilers, build systems, development libraries) are only needed during compilation, not at runtime. Including them in the final image bloats it significantly. For example, a Node.js build might require 500 MB of build tools to create a 10 MB compiled application. Multi-stage builds solve this by using a large build image to create compiled artifacts, then copying only the artifacts into a minimal runtime image. This technique can reduce image size by 80-90%, speeding up deployments and reducing storage/bandwidth costs.

# Stage 1: Build FROM node:18 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: Runtime FROM node:18-alpine WORKDIR /app # Copy only built files from builder COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules EXPOSE 3000 CMD ["node", "dist/index.js"]

Result: Final image ~300 MB instead of ~1.5 GB.


Health Checks

Define how Docker determines if a container is healthy.

Why it matters: Without health checks, orchestrators like Docker Compose, Swarm, or Kubernetes only know if a container is running, not if the application inside is actually responsive. An application might start successfully but hang, crash internally, or deadlock-yet the container keeps running. Health checks enable automated detection of these issues, triggering automatic container restarts or removal from load balancers before users encounter problems. This is especially critical in production environments with automated orchestration.

FROM python:3.9-slim WORKDIR /app COPY . . RUN pip install flask # Health check every 30 seconds HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:5000/health || exit 1 CMD ["python", "app.py"]

Reduce Image Size Strategies

Strategy Impact Example
Use Alpine base High FROM python:3.9-alpine
Combine RUN commands Medium RUN apt-get update && apt-get install -y
Clean package managers Medium rm -rf /var/lib/apt/lists/*
Use multi-stage builds High Separate build and runtime
Remove documentation Low RUN apt-get install -y --no-install-recommends

Common Pitfalls


Real-World Example: Node.js Production Dockerfile

# Stage 1: Dependencies FROM node:18-alpine AS dependencies WORKDIR /app COPY package*.json ./ RUN npm ci --only=production # Stage 2: Build FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 3: Runtime FROM node:18-alpine WORKDIR /app # Non-root user RUN addgroup -g 1000 appgroup && \ adduser -D -u 1000 -G appgroup appuser # Copy production dependencies COPY --from=dependencies --chown=appuser:appgroup /app/node_modules ./node_modules # Copy built application COPY --from=build --chown=appuser:appgroup /app/dist ./dist COPY --chown=appuser:appgroup package.json . USER appuser EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s CMD node healthcheck.js CMD ["node", "dist/index.js"]

Key Takeaways

Next Steps: Audit existing Dockerfiles for layer optimization, implement multi-stage builds where applicable, and enable security scanning in CI/CD pipelines.