Dockerfile Best Practices

Khimananda Oli 8 min read Database
Dockerfile Best Practices

By Khimananda Oli | Last reviewed: August 2026

Bloated containers and insecure defaults remain the primary causes of failed deployments and slow CI pipelines. Implementing strict Dockerfile best practices transforms a fragile development artifact into a production-grade, auditable component that satisfies both performance SLOs and compliance requirements. This guide distills fifteen years of infrastructure experience into actionable patterns for building efficient, secure container images in 2026.

How do multi-stage builds optimize Dockerfile best practices?

Multi-stage builds are the single most impactful technique for reducing image size and attack surface. In traditional single-stage builds, compilers, header files, and package managers persist in the final artifact, often inflating images to over 1GB when they should be under 100MB. By separating the build environment from the runtime environment, you ensure only the compiled binary or necessary application files exist in production.

This approach directly supports security compliance frameworks like SOC 2 and ISO 27001 by minimizing the software bill of materials (SBOM). Fewer packages mean fewer CVEs to patch and a smaller blast radius if a vulnerability is exploited. For teams managing Kubernetes resource limits, smaller images translate to faster pod scheduling, reduced network egress costs, and more predictable autoscaling behavior.

Stage 1: BuilderGo Compiler / Node ModulesBuild Tools & HeadersSource Code~800MB Build ContextCOPY --from=builderStage 2: RuntimeAlpine / Distroless BaseCompiled Binary OnlyCA Certificates & TZ Data~25MB Final ImageProduction ClusterFaster Pod SchedulingReduced Attack SurfaceLower Egress Costs
Multi-stage Dockerfile best practices isolate build tools from the runtime image to minimize size and vulnerability exposure.

Implementing a Go multi-stage build

The following pattern compiles a Go application statically and copies only the binary to a minimal distroless image. This eliminates shell access and package managers entirely, making remote code execution significantly harder.

# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /server .

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

Note the use of -ldflags="-w -s" to strip debug information and symbol tables, further reducing binary size. The nonroot tag enforces user restrictions at the image level rather than relying solely on Dockerfile directives.

How does instruction ordering affect Docker layer caching?

Docker caches each instruction as an immutable layer. When a line changes, that layer and every subsequent layer must be rebuilt. Poor ordering forces expensive operations like dependency installation to re-run on every code commit, turning five-minute builds into twenty-minute bottlenecks. Understanding this mechanism is fundamental to applying Dockerfile best practices effectively.

The rule is simple: order instructions from least frequently changing to most frequently changing. Base image versions change rarely. System packages change occasionally. Dependency manifests change sometimes. Application source code changes constantly. Align your Dockerfile structure to this frequency gradient.

Optimized instruction sequence

  1. FROM: Pin exact versions with digests for reproducibility.
  2. LABEL: Metadata rarely changes and adds negligible size.
  3. ENV: Set stable environment variables early.
  4. RUN apt-get/install: System dependencies change infrequently.
  5. COPY package.json/go.mod: Copy only manifest files first.
  6. RUN npm install/go mod download: Install dependencies while cache is warm.
  7. COPY . .: Copy source code last since it changes most often.
  8. RUN build/test: Compile or test against fresh source.
  9. EXPOSE/CMD: Configuration metadata at the end.

A common mistake is copying the entire source directory before installing dependencies. This invalidates the dependency cache on every commit. Always copy manifests separately, install, then copy remaining source files. For monorepos or projects with multiple dependency files, use .dockerignore aggressively to prevent unrelated file changes from busting the cache.

Why must containers run as non-root users?

Running containers as root is the most dangerous anti-pattern in containerization. If an attacker exploits an application vulnerability, they inherit full root privileges within the container namespace. While namespaces provide isolation, kernel vulnerabilities or misconfigured capabilities can allow escape to the host. Compliance auditors flag root containers immediately during SOC 2 and ISO 27001 assessments because the risk is well-documented and mitigation is straightforward.

Creating a dedicated user takes three lines and prevents entire classes of privilege escalation attacks. Combined with read-only filesystem mounts and dropped Linux capabilities, non-root execution forms the foundation of defense-in-depth container security. Teams adopting DevSecOps practices enforce this policy through admission controllers like OPA Gatekeeper or Kyverno, preventing non-compliant images from reaching production.

Root Container (Insecure)UID 0✓ Full filesystem write access✓ Can modify system binaries✓ Kernel exploit = host escape✗ Fails SOC 2 / ISO 27001 audit✗ Blocked by PodSecurityPolicyNon-Root Container (Secure)UID 1000✓ Read-only root filesystem✓ Cannot install packages✓ Contained breach impact✓ Passes compliance audits✓ Allowed by default policiesUSER directive
Non-root execution is a core Dockerfile best practice that limits blast radius and satisfies security compliance requirements.

Creating and switching to a non-root user

FROM node:22-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]

Always create a dedicated group and user rather than reusing existing system accounts. Set ownership explicitly before switching users; otherwise, permission errors will crash the application at startup. For applications requiring privileged ports below 1024, use ambient capabilities or reverse proxies instead of running as root.

What base image selection criteria matter for production?

Choosing the right base image determines your maintenance burden, security posture, and operational reliability. Using latest tags is unacceptable in production because builds become non-reproducible and vulnerable to supply chain attacks. Always pin to specific version tags or, better yet, SHA256 digests for cryptographic verification.

Base Image TypeSizeSecurityUse CaseTrade-off
Full (debian/ubuntu)~300MB+ModerateLegacy apps needing glibc/toolsLarger attack surface, slower pulls
Slim (-slim variants)~80MBGoodMost web applicationsMay need manual dependency installs
Alpine~5MBExcellentGo/Rust static binariesmusl libc compatibility issues
Distroless~20MBBestProduction workloadsNo shell for debugging
Scratch0MBBestStatically compiled binariesRequires fully static linking

For interpreted languages like Python or Ruby, slim variants offer the best balance. For compiled languages targeting Kubernetes, distroless or scratch images eliminate unnecessary tooling. Remember that smaller images also reduce costs when using managed registries like ECR or Artifact Registry, where storage and data transfer fees accumulate at scale. Teams practicing container image scanning find fewer false positives with minimal bases.

Pinning images with digests

# Bad: Mutable tag, non-reproducible
FROM python:3.12-slim

# Good: Immutable digest, verified content
FROM python@sha256:a1b2c3d4e5f6...

# Acceptable: Specific version tag with regular updates
FROM python:3.12.4-slim-bookworm

Automate digest updates using tools like Renovate or Dependabot. These tools monitor upstream releases and submit pull requests with updated hashes, maintaining reproducibility without manual tracking. Integrate this into your CI pipeline to prevent drift between development and production environments.

How do you handle secrets and sensitive data safely?

Never embed secrets in Dockerfiles. Environment variables passed via ENV persist in image layers and are visible to anyone with image access. Even if deleted in a later layer, the secret remains in intermediate layers accessible through docker history or layer inspection. This violates every major compliance framework and exposes credentials during routine security scans.

Use BuildKit's secret mounting feature for build-time secrets like API keys or private repository tokens. For runtime secrets, inject them through orchestrator mechanisms like Kubernetes Secrets, AWS Secrets Manager, or HashiCorp Vault. The image should contain zero sensitive data; configuration belongs to the deployment platform, not the artifact.

Using BuildKit secret mounts

# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token,target=/root/.npmrc \
    npm ci --only=production
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

Build with DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=.npmrc . The secret never touches the filesystem permanently and exists only during the specific RUN instruction. This pattern works identically for SSH keys, certificates, and proprietary package registry credentials.

Insecure PatternENV API_KEY=secret123Persisted in Layer CacheVisible in docker historyCredential Leak RiskSecure Pattern--mount=type=secretTemporary Memory MountNever Written to LayerAudit CompliantRuntime InjectionKubernetes SecretsVault / AWS SMEnvironment VariablesZero Image Secrets
Secure secret management separates build-time mounts from runtime injection, keeping credentials out of image layers.

Conclusion

Adopting these Dockerfile best practices transforms containerization from a convenience into a competitive advantage. Multi-stage builds, intelligent caching, non-root execution, and proper secret handling collectively produce images that deploy faster, cost less, and pass audits without remediation tickets. Start by auditing your current Dockerfiles against these patterns; even incremental improvements compound across hundreds of daily builds.

If your team needs help implementing these patterns at scale or preparing infrastructure for compliance certification, reach out to discuss your container strategy. Production-grade containerization requires discipline, but the operational payoff justifies every line of optimized configuration.

Frequently Asked Questions

Use multi-stage builds to separate build dependencies from runtime artifacts. This keeps final images small by discarding compilers and source code after compilation.

Pinning exact versions ensures reproducible builds across environments. The latest tag changes unpredictably, causing deployment failures when upstream images update without notice.

Docker caches each instruction result. Placing frequently changing commands like COPY source code near the bottom prevents invalidating expensive earlier layers like package installations.

Use official slim or distroless variants. Alpine reduces attack surface but may cause glibc compatibility issues. Debian slim offers better compatibility with minimal overhead.

Always create and switch to a non-root user using USER directive. Running as root inside containers creates security risks if container isolation is compromised.

Never embed secrets directly. Use BuildKit secret mounts or pass them at runtime via environment variables. Secrets in image layers remain accessible through inspection tools.

ENTRYPOINT defines the executable that always runs. CMD provides default arguments that users can override. Combine both for flexible yet predictable container behavior.

Minimize instructions by combining related RUN commands with && operators. Fewer layers reduce image size and build time while maintaining readability and debuggability.

Excluding unnecessary files prevents bloated build contexts and accidental secret inclusion. Add node_modules, git directories, and local config files to speed up transfers.

Use hadolint or docker buildx check to catch common mistakes. These tools flag deprecated instructions, missing version pins, and security anti-patterns automatically.

Missing cache mounts for package managers forces redundant downloads. Configure BuildKit cache mounts for apt, npm, or pip to persist dependencies between builds.

Yes, BuildKit supports heredoc syntax since 2024. This improves readability for multi-line scripts and configuration files embedded directly in RUN instructions.

Enable BuildKit progress output with --progress=plain. Inspect intermediate containers using docker commit or add temporary echo statements to isolate problematic layers.

No. Never bake SSH keys into images. Use agent forwarding during build or mount credentials temporarily via BuildKit secrets for private repository access.

ARG values exist only during build time. ENV persists in running containers. Use ARG for build-time configuration and ENV for runtime application settings.