Skip to content
Home
Dockerfile Best Practices: Building Efficient and Secure Containers

Dockerfile Best Practices: Building Efficient and Secure Containers

Developer Tools Developer Tools 7 min read 1456 words Beginner ExcellentWiki Editorial Team

A Dockerfile is a text document that contains all the commands a user could call on the command line to assemble a Docker image. Writing efficient Dockerfiles is both an art and a science — the difference between a well-crafted Dockerfile and a naive one can be orders of magnitude in build time, image size, and security posture.

Optimizing Layer Cache

Every instruction in a Dockerfile creates a layer. Docker caches each layer and reuses it if nothing has changed. Understanding this caching mechanism is key to fast builds.

Order Instructions by Change Frequency

Place instructions that change less frequently at the top of the Dockerfile. This maximizes cache reuse.

# Good: dependencies rarely change, code changes often
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ ./src/
CMD ["node", "src/index.js"]

When you change source code, Docker reuses cached layers for the base image, work directory, and dependency installation — only the COPY src/ step needs to re-run.

Combine RUN Commands

Each RUN instruction creates a new layer. Combine related commands with && to reduce layers:

# Bad — three layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good — one layer
RUN apt-get update && \
    apt-get install -y curl && \
    rm -rf /var/lib/apt/lists/*

Reducing Image Size

Smaller images mean faster deployments, lower storage costs, and reduced attack surface.

Use Slim Base Images

Alpine-based images are dramatically smaller than full distributions:

# 1.2 GB vs 180 MB
FROM node:18          # ~1.2 GB
FROM node:18-slim     # ~250 MB
FROM node:18-alpine   # ~180 MB

Multi-Stage Builds

Multi-stage builds let you use different base images for building and running your application. Build tools and dependencies are discarded in the final image.

# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server

# Run stage — tiny final image
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

The final image contains only the compiled binary and essential runtime dependencies — no Go compiler, no build tools, no source code.

Remove Unnecessary Files

Clean up package manager caches, temporary files, and documentation in the same layer that creates them:

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential \
        python3-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Security Best Practices

Run as Non-Root

The default user in Docker is root. Running containers as root is a major security risk — if an attacker compromises the container, they have root access.

FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --chown=appuser:appgroup . /app
WORKDIR /app
CMD ["node", "server.js"]

Pin Base Image Versions

Using latest or no tag creates non-deterministic builds. Pin to specific digest or version:

# Bad — unpredictable
FROM node:latest

# Good — reproducible
FROM node:18.20.0-alpine@sha256:abc123def456

Scan Images for Vulnerabilities

Integrate security scanning into your CI/CD pipeline:

# Using Trivy
trivy image myapp:latest

# Using Docker Scout
docker scout cves myapp:latest

Secrets Management

Never hardcode secrets in Dockerfiles. Use build secrets or environment variables at runtime:

# Bad — secret in image layers
RUN echo "api_key=12345" > /app/config.json

# Good — build secrets
RUN --mount=type=secret,id=api_key \
    export API_KEY=$(cat /run/secrets/api_key) && \
    ./configure.sh

# Better — runtime injection
ENV API_KEY=""
# Pass via docker run -e API_KEY=xxx or Docker Swarm/Kubernetes secrets

Read-Only Root Filesystem

For production containers, mount the root filesystem as read-only and writeable volumes only where needed. This prevents an attacker from modifying system binaries even if they gain code execution:

RUN chmod a-w /  # Option at image build time
# Or at runtime: docker run --read-only --tmpfs /tmp myapp

Production-Ready Patterns

Health Checks

Add a HEALTHCHECK instruction so Docker can monitor container health:

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

Proper Signal Handling

Ensure your application handles SIGTERM gracefully for clean shutdowns:

# Use exec form of CMD — it receives signals directly
CMD ["node", "server.js"]

# Avoid shell form — signals go to /bin/sh, not your app
CMD node server.js  # bad

Use .dockerignore

Prevent unnecessary files from being sent to the Docker daemon:

node_modules/
.git/
*.md
.env
Dockerfile
.gitignore
.dockerignore

Set Resource Limits

While not part of the Dockerfile, always set resource constraints at runtime:

docker run --memory="512m" --cpus="1.0" myapp

Label Your Images

Add metadata labels for organization and automation:

LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.description="API service"
LABEL org.opencontainers.image.version="1.2.3"

Labels help with image inventory, cost allocation, and automated policy enforcement in production environments.

Common Anti-Patterns

Installing Unnecessary Packages

Every package in the image is a potential vulnerability. Install only what your application needs at runtime. The --no-install-recommends flag prevents apt from pulling recommended but unnecessary packages.

COPY Before RUN

If you COPY . before running npm install, every source code change invalidates the dependency cache. Always copy dependency manifests first.

Using Latest Tags

FROM python:latest will give you different versions on different days. Pin to specific versions for reproducible builds.

Mixing Build and Runtime Dependencies

If you need gcc to compile native modules, use multi-stage builds so build tools don’t end up in the final image.

Adding Debug Tools in Production Images

Never include debuggers, compilers, or package managers in production images. Every extra tool is a potential attack vector. If you need debugging access, use ephemeral sidecar containers or docker exec with a separate debug image.

FAQ

What is the difference between COPY and ADD?

COPY copies files from the build context into the image. ADD does the same but also supports URL downloads and automatic tar extraction. Use COPY by default — ADD has surprising behaviors that can introduce security risks.

How small can a production container be?

With multi-stage builds and scratch base images, you can create images as small as a few megabytes for compiled languages (Go, Rust, C). Interpreted language images (Python, Node.js) are typically 100-300 MB with slim base images.

Why does my Docker build take so long?

Common causes: incorrect layer ordering (code before dependencies), missing .dockerignore (sending large node_modules to the daemon), and not using build cache (--no-cache). Place rarely-changing instructions first and use BuildKit for parallel builds.

How do I debug a Docker build failure?

Use DOCKER_BUILDKIT=1 docker build --progress=plain . for detailed output. For runtime debugging, run the image locally with docker run -it image-name sh to inspect the filesystem.

Should I use Alpine or slim base images?

Alpine (based on musl libc) is smaller but can cause compatibility issues with native modules that expect glibc. Slim images (Debian-based with only essential packages removed) are generally more compatible. Start with slim and move to Alpine only if you need the extra size reduction.


Related: See our Docker beginners guide and Docker Compose guide.

Build Performance Optimization

Layer caching is the most impactful optimization for Docker builds. Order Dockerfile commands from least to most frequently changing: base image, system dependencies, application dependencies, source code, build step, runtime configuration. This ensures that npm install or pip install reuses cache as long as package.json or requirements.txt hasn’t changed. Use BuildKit (# syntax=docker/dockerfile:1) for concurrent builds and better caching. BuildKit’s --mount=type=cache preserves /var/cache/apt, /root/.npm, or /root/.cache/pip across builds. In CI, pull the previous image as a cache source: docker build --cache-from $CI_REGISTRY_IMAGE:latest .. The --target flag builds only specific stages in multi-stage builds, speeding up development iteration: docker build --target dev .. Minimize build context size with .dockerignore — include only files needed for the build. A bloated context (e.g., including node_modules or .git) slows the initial build step. For monorepos, use Docker’s context and dockerfile options in Compose to build subdirectory projects efficiently.

CI/CD Integration Patterns

Integrating Docker builds into CI/CD pipelines requires attention to caching, security scanning, and registry management. GitHub Actions, GitLab CI, and Jenkins all support Docker build caching with BuildKit. Use docker build --cache-from type=registry,ref=myapp:latest to reuse layers from the registry. Tag images with both the Git commit SHA and semantic version for traceability: myapp:1.2.3 and myapp:a1b2c3d4. Scan images before pushing: trivy image --severity CRITICAL --exit-code 1 myapp:latest. Sign images with Docker Content Trust or Cosign for supply chain security. Store secrets like registry credentials in the CI provider’s secret manager rather than in build configuration files. For multi-architecture builds, use docker buildx build --platform linux/amd64,linux/arm64 --push to create manifest lists.

Language-Specific Patterns

Node.js: Use node:20-alpine, copy package*.json first, run npm ci --only=production. Python: Use python:3.12-slim-bookworm, pin deps with pip freeze > requirements.txt. Go: Multi-stage from golang:1.22-alpine builder to scratch or alpine:3.19 runtime. Java: Use maven:3-eclipse-temurin-21 for builder, eclipse-temurin:21-jre for runtime. Rust: Compile with --release in builder, copy binary to debian:bookworm-slim. Always create a non-root user and switch to it before running the application.

Section: Developer Tools 1456 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top