← Back to DevBytes

Dockerfile Best Practices: Best Practices and Common Pitfalls

Understanding Dockerfiles and Why Best Practices Matter

A Dockerfile is a text document containing a sequence of instructions that Docker uses to automatically build container images. Each instruction creates a new layer in the image's filesystem, and the final image is the concatenation of all these layers. While writing a Dockerfile seems straightforward, the difference between a naive implementation and an optimized one can mean hundreds of megabytes of bloat, slower build times, security vulnerabilities, and unpredictable behavior in production.

Best practices for Dockerfiles aren't just about writing "clean" code — they directly impact:

This tutorial walks through every major best practice and common pitfall, with practical examples you can apply immediately.

Image Layers and the Build Cache

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

To write efficient Dockerfiles, you must first understand how Docker builds images. Each instruction in a Dockerfile — FROM, RUN, COPY, ADD — produces a distinct layer. Docker caches these layers aggressively: if an instruction and its context haven't changed, Docker reuses the cached layer instead of rebuilding it. This cache is invalidated when any preceding layer changes, forcing a rebuild of that layer and all subsequent layers.

How Cache Invalidation Works

Consider this Dockerfile:

FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build

If you change a line of application code in src/, the COPY . . instruction detects the change, invalidates its cache, and forces RUN npm run build to re-execute. Crucially, the earlier layers — including the expensive RUN npm ci --production — remain cached because package.json and package-lock.json haven't changed. This pattern of copying dependency manifests first, then installing dependencies, then copying application code is the single most impactful optimization you can make.

A naive alternative that copies everything at once would invalidate the dependency install cache on every code change:

# ANTI-PATTERN: everything rebuilds on any code change
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci --production && npm run build

Best Practice #1: Pin Your Base Image Version

Using a floating tag like FROM node:latest or FROM ubuntu:latest means your image may behave differently depending on when it's built. A new major version of the base OS or runtime can introduce breaking changes silently. Always pin to a specific version and, ideally, a digest SHA for absolute determinism.

# Good: specific minor version
FROM node:18.17.1-alpine

# Better: digest pinning (prevents any drift)
FROM node:18.17.1-alpine@sha256:abc123def456...

Digest pinning guarantees you're using the exact same base image bytes every time. Many registries allow you to query the current digest for a tag, which you can then hardcode. Tools like renovate or dependabot can automate keeping these digests up to date.

Best Practice #2: Use Minimal Base Images

The base image you choose sets the floor for your image's size and attack surface. Prefer slim or Alpine-based variants whenever possible:

# Instead of this (full Debian, ~350MB+)
FROM python:3.11

# Use this (Alpine-based, ~50MB)
FROM python:3.11-alpine

# Or even distroless for Go applications (no shell, ~20MB)
FROM gcr.io/distroless/static-debian12:nonroot

Be aware that Alpine uses musl libc instead of glibc, which can cause subtle compatibility issues with some compiled binaries. Test thoroughly if you switch.

Best Practice #3: Leverage .dockerignore

The .dockerignore file sits alongside your Dockerfile and tells Docker which files to exclude from the build context. Without it, Docker sends your entire project directory to the daemon — including node_modules/, .git/, IDE settings, local environment files, and build artifacts. This bloats the context transfer, slows builds, and can accidentally leak secrets into image layers if .env files are copied.

A solid .dockerignore for a Node.js project:

node_modules
.git
.gitignore
.env
.env.local
*.log
dist
coverage
.vscode
.idea
Dockerfile
docker-compose.yml
README.md
*.md

You can validate what Docker is sending in the context with:

docker build --no-cache -t myapp -f Dockerfile . 2>&1 | grep "Sending build context"

Best Practice #4: Minimize the Number of Layers (Within Reason)

Each RUN, COPY, or ADD instruction creates a new layer. While Docker's modern storage drivers handle many layers efficiently, there's still overhead. Combine related commands into a single RUN instruction using shell chaining, and clean up package manager caches in the same layer.

# ANTI-PATTERN: multiple RUN layers, no cleanup
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y vim
RUN apt-get clean

# GOOD: single RUN, cleanup in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl vim && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

The --no-install-recommends flag avoids pulling unnecessary suggested packages, and cleaning /var/lib/apt/lists/* removes the package index cache, saving significant space. The cleanup must happen in the same RUN instruction because each layer's deletions only affect that layer; the overall image size is the sum of all layers. If you delete files in a later layer, they still exist in earlier layers and contribute to the final image size.

Best Practice #5: Order Instructions by Stability

Place instructions that change frequently as late as possible in the Dockerfile. The typical order of stability (most stable first) is:

  1. Base image (FROM)
  2. System dependencies (rarely change)
  3. Language runtime configuration
  4. Dependency manifest files (change occasionally)
  5. Dependency installation
  6. Application code (changes most frequently)
  7. Build steps, tests, final configuration
FROM python:3.11-slim

# System dependencies (stable)
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Dependency files (change occasionally)
COPY requirements.txt ./

# Install dependencies (cached until requirements.txt changes)
RUN pip install --no-cache-dir -r requirements.txt

# Application code (changes frequently)
COPY src/ ./src/

CMD ["python", "src/main.py"]

Best Practice #6: Multi-Stage Builds

Multi-stage builds are one of Docker's most powerful features. They allow you to use multiple FROM statements in a single Dockerfile, each starting a new stage. You can copy artifacts from one stage to another, leaving behind all the heavyweight build tools, SDKs, and intermediate files that aren't needed at runtime.

For a Go application, the build stage needs the Go SDK (~800MB), but the final image can be a tiny distroless container with just the compiled binary:

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o /app/server .

# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder --chown=nonroot:nonroot /app/server /app/server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]

The final image contains only the compiled binary and no Go toolchain, no source code, no package manager. This pattern works for any compiled language (Rust, Java with JLink, C++) and also for interpreted languages when you separate the dependency-install stage from the runtime stage.

For a Node.js application, you can prune development dependencies after the build:

# Stage 1: Install all dependencies and build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production runtime with only production deps
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm ci --production && npm cache clean --force
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Best Practice #7: Use COPY Instead of ADD

The ADD instruction has two features beyond COPY: it can fetch remote URLs and it auto-extracts tar archives. Unless you specifically need tar extraction, always use COPY. ADD fetching URLs is unpredictable (no caching, no retry logic, no integrity verification) and should be replaced with explicit curl or wget in a RUN instruction followed by checksum validation.

# AVOID: ADD with URL — no caching, no checksums
ADD https://example.com/some-binary.tar.gz /tmp/

# PREFER: explicit download with checksum verification
RUN curl -sSL https://example.com/some-binary.tar.gz -o /tmp/binary.tar.gz && \
    echo "expected_sha256_checksum  /tmp/binary.tar.gz" | sha256sum -c - && \
    tar -xzf /tmp/binary.tar.gz -C /usr/local/bin && \
    rm /tmp/binary.tar.gz

Use ADD only when you need automatic tar extraction from a local file and you're confident about the source.

Best Practice #8: Run as a Non-Root User

By default, Docker containers run as root. If a process inside the container is compromised, the attacker gains root access to the container. While container isolation limits the blast radius somewhat, running as root is still a significant risk — especially if you've mounted sensitive host directories or have privileged capabilities.

Create and switch to a non-root user. Many official images already provide a dedicated user (like node for Node.js images or postgres for PostgreSQL). For custom images, create a user explicitly:

FROM alpine:3.18
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --chown=appuser:appgroup app /app
CMD ["/app"]

The --chown flag on COPY sets ownership at copy time, avoiding an extra RUN chown layer. Verify your user works with the port your application listens on — non-root users cannot bind to ports below 1024 unless you adjust capabilities or use a higher port.

Best Practice #9: Avoid Embedding Secrets in Image Layers

Never put credentials, API keys, private certificates, or environment-specific secrets directly into a Dockerfile via ENV, COPY, or RUN. These values become permanently baked into image layers and are trivially extractable by anyone with access to the image (docker history reveals all layers).

# DANGEROUS: secrets baked into layers forever
ENV DATABASE_PASSWORD=super-secret-password
RUN curl -H "Authorization: Bearer abc123" https://api.example.com/data

# CORRECT: pass secrets at runtime via environment variables, secrets mounts, or orchestration
# docker run --env DATABASE_PASSWORD=... or use Docker secrets / Kubernetes secrets
ENV DATABASE_PASSWORD=""  # documented placeholder only

For build-time secrets (like accessing a private npm registry or cloning a private repo), use Docker BuildKit's --secret mount, which exposes the secret only during that specific RUN instruction and leaves no trace in the image:

# Requires DOCKER_BUILDKIT=1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --production

Build with: docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

Best Practice #10: Use HEALTHCHECK for Production Images

The HEALTHCHECK instruction tells Docker how to verify that your container is actually working correctly, not just running. Without it, orchestration platforms can't distinguish between a container that's up-and-running and one that's stuck in a crash loop or deadlocked.

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci --production
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "server.js"]

The flags control: how often the check runs (--interval), how long a single check may take (--timeout), grace period before checks begin (--start-period), and consecutive failures before the container is marked unhealthy (--retries).

Best Practice #11: Label Your Images

Labels attach metadata to images that tools, CI/CD systems, and operators can query. Standard labels from the Open Container Initiative (OCI) include version, revision, vendor, and description. These are invaluable when you're debugging which version of an image is running in production.

FROM alpine:3.18
LABEL org.opencontainers.image.title="my-web-service"
LABEL org.opencontainers.image.version="1.2.3"
LABEL org.opencontainers.image.revision="abc123def456"
LABEL org.opencontainers.image.source="https://github.com/myorg/my-web-service"
LABEL org.opencontainers.image.created="2024-01-15T10:00:00Z"

You can also set labels at build time with docker build --label, which is useful for injecting CI build numbers dynamically.

Best Practice #12: Use ARG for Build-Time Configuration

The ARG instruction defines variables that are only available during the build process. They're ideal for things like version numbers, environment-specific build flags, or enabling optional features. Unlike ENV, ARG values do not persist in the final image (unless you copy them into an ENV).

ARG APP_VERSION=1.0.0
ARG ENABLE_FEATURE_X=false
RUN ./build.sh --version=${APP_VERSION} --feature-x=${ENABLE_FEATURE_X}

Pass values with: docker build --build-arg APP_VERSION=2.0.0 --build-arg ENABLE_FEATURE_X=true -t myapp .

Be careful: ARG values are visible in the image's build history unless you use multi-stage builds and only expose them in the builder stage. For sensitive build-time values, use BuildKit secrets as described earlier.

Common Pitfalls and How to Avoid Them

Pitfall #1: The "Works on My Machine" Dockerfile

A Dockerfile that relies on host-specific paths, user UIDs, or ambient environment variables is not portable. Always use absolute paths inside the container (/app, not ~/projects/app), set the WORKDIR explicitly, and never assume the host's UID/GID mapping.

# FRAGILE: relies on host user's home directory
COPY ~/.npmrc /root/.npmrc

# PORTABLE: uses a BuildKit secret mount instead
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

Pitfall #2: Accidentally Including Large Files

Even with .dockerignore, developers often miss large binaries, database dumps, or media files that sneak into the build context. Use docker build --no-cache and inspect the output for the context size. Tools like dive can analyze existing images and show you exactly which files are taking up space in each layer.

# Install dive and analyze an image
dive my-image:latest

Pitfall #3: Using the Default Bridge Network in Builds

If your Dockerfile's RUN instructions download packages, they use the default bridge network which may be slow or have DNS issues. Use BuildKit's network options to cache downloads or use a faster mirror:

# Use BuildKit cache mounts for package managers
RUN --mount=type=cache,target=/root/.npm \
    npm ci --production

Pitfall #4: Not Specifying a CMD or ENTRYPOINT Signal Handler

Containers receive SIGTERM when they're being stopped. If your application doesn't handle this gracefully, Docker will force-kill it after a timeout. Ensure your process runs as PID 1 (the main process) and handles signals properly. Avoid wrapping your application in shell scripts that don't forward signals:

# GOOD: exec form — your app is PID 1, receives signals directly
CMD ["node", "server.js"]

# BAD: shell form — a bash process is PID 1, your app is a child
CMD node server.js

The exec form (JSON array) ensures your application runs directly without an intermediate shell.

Pitfall #5: Installing Debug Tools in Production Images

Including vim, curl, strace, or a full shell in production images increases the attack surface and image size. If you need debugging capabilities, create a separate debug image variant using a multi-stage build or a different Dockerfile target:

# Production image: minimal
FROM alpine:3.18
COPY --from=builder /app/server /app/server
USER appuser
CMD ["/app/server"]

# Debug variant: includes shell and tools
FROM production AS debug
USER root
RUN apk add --no-cache curl vim strace
USER appuser
CMD ["/app/server"]

Build the debug variant with: docker build --target debug -t myapp:debug .

Pitfall #6: Hardcoding UIDs and Ports

Hardcoding user UIDs (like USER 1000) works until the UID collides with another user on the host or in orchestration. Use named users where possible, or document the expected UID clearly. Similarly, hardcoding ports without EXPOSE makes it harder for orchestration tools to auto-configure networking.

# BETTER: use named user, expose port
USER node
EXPOSE 3000

Pitfall #7: Leaking Build Context Secrets in ARG

As mentioned earlier, ARG values appear in docker history. A common mistake is passing a secret as a build argument:

# LEAKS SECRET: visible in docker history forever
ARG GITHUB_TOKEN
RUN git clone https://token:${GITHUB_TOKEN}@github.com/myorg/private-repo.git

# FIX: use BuildKit secret mount
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) \
    git clone https://token:${GITHUB_TOKEN}@github.com/myorg/private-repo.git

Putting It All Together: A Complete, Production-Ready Dockerfile

Here's a complete example that incorporates the best practices covered in this tutorial for a Python FastAPI application:

# ============================================================
# Stage 1: Build dependencies in a virtual environment
# ============================================================
FROM python:3.11-slim AS builder

# Prevent Python from writing .pyc files and buffering stdout
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Install build tools needed for compiling wheels
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# Create and activate virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copy dependency manifests first for caching
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# ============================================================
# Stage 2: Production runtime
# ============================================================
FROM python:3.11-slim

# Create a non-root user
RUN addgroup --system appgroup && \
    adduser --system --ingroup appgroup appuser

# Copy the virtual environment from the builder stage
COPY --from=builder --chown=appuser:appgroup /opt/venv /opt/venv

# Set PATH to use the venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Copy application code (changes frequently, placed late)
COPY --chown=appuser:appgroup src/ ./src/

# Switch to non-root user
USER appuser

# Document the port
EXPOSE 8000

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

# Metadata labels
LABEL org.opencontainers.image.title="fastapi-service" \
      org.opencontainers.image.version="1.0.0" \
      org.opencontainers.image.source="https://github.com/myorg/fastapi-service"

# Run the application with exec form
ENTRYPOINT ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

This Dockerfile demonstrates: pinning a slim base image, multi-stage builds to exclude build tools, copying dependency files first, creating a non-root user, cleaning package manager caches in the same layer, using the exec form for the entrypoint, setting a health check, and adding OCI labels. The final image contains only the Python runtime, the virtual environment, and the application code — no compilers, no build artifacts.

Conclusion

Writing a production-grade Dockerfile is an exercise in intentional layering. Every instruction you add either contributes to image size, affects rebuild speed, or widens the security surface. The best practices outlined here — pinning base images, ordering layers by stability, leveraging multi-stage builds, running as non-root, cleaning up in the same layer, and never embedding secrets — aren't just cosmetic improvements. They translate directly to faster CI/CD pipelines, smaller attack surfaces, lower bandwidth costs for image distribution, and containers that behave predictably across environments.

Start by auditing your existing Dockerfiles with these principles in mind. Use tools like dive to visualize layer sizes, hadolint to lint your Dockerfiles automatically, and docker build --no-cache to verify your caching strategy works as expected. The investment in learning these patterns pays dividends on every subsequent build.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles