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
- Creating layers for every instruction - Combine related commands with
&& - Not cleaning package caches - Always clean
apt,yum, orapkcaches after installation - Using root user in production - Always create and switch to non-root user
- Large .dockerignore files - Excludes valuable files; be selective
- Ignoring image vulnerabilities - Regularly scan and update base images
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
- Combine related RUN commands to reduce layers and build time
- Order Dockerfile instructions from least-changing to most-changing for optimal caching
- Always use multi-stage builds to separate build environments from runtime environments
- Run applications as non-root users for enhanced security
- Use .dockerignore to exclude unnecessary files and reduce build context
- Implement health checks to help Docker detect container issues automatically
- Keep base images updated with specific versions, not
latest - Regularly scan images for security vulnerabilities
Next Steps: Audit existing Dockerfiles for layer optimization, implement multi-stage builds where applicable, and enable security scanning in CI/CD pipelines.