Skip to content

DevOps Cluster

Docker Configuration Guide: Dockerfiles, Compose, Security

Published April 11, 2026 · 11 min read

Docker makes it easy to ship a container that runs on your laptop and breaks on production. Usually the failure is not dramatic — the container just runs as root, weighs 1.2 GB for a 50 MB app, rebuilds from scratch on every commit, and ships a stack of CVEs from a base image that nobody has updated since the project started. None of that is Docker's fault. It is the natural outcome of copying a Dockerfile from a tutorial and never revisiting it. A handful of good habits prevent all of it, and they cost nothing to adopt.

This guide walks through the Dockerfile and compose configurations that actually matter in 2026: multi-stage builds that shrink images dramatically, layer caching that makes rebuilds fast, non-root users, health checks, secrets handling, and the compose patterns for multi-service local development. We will also cover the security scanning step that should be non-negotiable before anything hits production.

Picking the base image

The base image choice determines almost everything downstream: image size, attack surface, supply chain risk, and whether your container will still build in six months. Three defensible options for most stacks:

  • Distroless (Google's gcr.io/distroless/*): the smallest safe option. No shell, no package manager, just the runtime and your app. Hard to debug with docker exec, but that is the security feature not the bug.
  • Alpine: tiny images based on musl libc. Excellent size, but musl occasionally breaks glibc-targeted binaries and some libraries misbehave. Test carefully.
  • Debian slim (debian:bookworm-slim or the language-specific python:3.12-slim, node:20-slim): bigger than Alpine, smaller than the full image, uses glibc, and is the safest default for most teams.

Always pin the tag. python:latest is a recipe for "the build worked last week." Pin to python:3.12.8-slim-bookworm or better yet pin by digest (python:3.12.8-slim@sha256:...), which is immutable. The Docker build best practices guide has the authoritative list of recommendations.

Generate a starter Dockerfile for your stack with Dockerfile Generator, which scaffolds a multi-stage file with sensible defaults for common runtimes. You will edit it, but starting from a working template beats starting from a blank page.

Multi-stage builds

A multi-stage build uses multiple FROM statements in one Dockerfile. The first stage (or stages) does the heavy work — installing build tools, compiling, running tests — and the final stage copies only the artifacts it needs from the earlier stages. The earlier stages are discarded from the final image. The result: your production image contains the compiled binary, not the compiler. For a Go app the image can drop from 800 MB to 15 MB. For a Node app from 1.2 GB to 150 MB.

A template for a Go app:

# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

The final image contains the binary and nothing else — no shell, no compiler, no source. The nonroot user comes from the distroless image. This pattern transfers almost unchanged to Rust, C++, and any other compiled language. For interpreted languages (Python, Node) you still use multi-stage to install dev dependencies separately from runtime dependencies, which keeps the final image lean.

Layer caching that actually works

Every RUN, COPY, and ADD command creates a layer. Docker caches each layer and reuses it on the next build if the inputs have not changed. The order of your Dockerfile commands determines how often you get cache hits. Get the order wrong and every build reinstalls all your dependencies from scratch.

The rule: copy dependency manifests first, install dependencies, then copy the rest of the source. For Node:

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

This way, a source code change only invalidates the last two layers. The dependency install layer stays cached. Contrast with the naive version where COPY . . comes first — a one-character change in any source file invalidates everything downstream.

Use .dockerignore to exclude files that would break the cache without contributing to the image. Exclude .git/, node_modules/ on the host, test fixtures, and anything else that changes frequently and is not needed in the image. A missing .dockerignore is often the difference between fast and slow CI.

Running as non-root and other hardening

By default, containers run as root. This is a problem: if an attacker escapes the container (a real vulnerability class, however rare), they land on the host as root. The fix is three lines:

RUN addgroup -S app && adduser -S app -G app
USER app
WORKDIR /home/app

Now the process inside the container runs as an unprivileged user. This does not stop a kernel exploit, but it makes every other attack path dramatically harder, and it is free.

Other hardening steps the OWASP Docker Security Cheat Sheet recommends: set a specific USER (never rely on the base image default), drop unnecessary Linux capabilities with --cap-drop ALL, mount the root filesystem read-only, and set explicit resource limits. Most of these apply at runtime (docker run flags or compose options), not in the Dockerfile itself.

Never bake secrets into the image. Pass them at runtime via environment variables, bind-mounted files, or a secret manager. If a secret ends up in a layer, it is in the image forever, even if a later layer deletes it — image layers are additive. Password Generator produces high-entropy strings for runtime secrets you do need to create, using the browser's Web Crypto API so the values stay on your device during standard processing.

docker-compose for local stacks

Compose is the right tool for running a multi-service stack locally. The file describes every service, network, and volume; docker compose up starts them all wired together. The typical local stack has an app service, a database, maybe a cache, maybe a queue. Write it once and anyone on the team can spin up a working environment in one command.

Generate a compose scaffold with Docker Compose Generator, which covers the common patterns: app plus Postgres, app plus Redis, app plus both. For authoring YAML by hand, YAML Validator catches indentation errors before they turn into mysterious "service not found" errors at runtime.

A few compose patterns worth using:

  • Environment overrides: .env files for local secrets, never checked in, plus compose's built-in variable interpolation.
  • Health checks: declare a healthcheck for each service and use depends_on with condition: service_healthy so the app waits for the database to be ready.
  • Named volumes: for databases, use named volumes, not bind mounts — bind mounts to host directories cause permission issues across platforms.
  • Profiles: compose profiles let you mark services as optional, so docker compose up starts the core stack and docker compose --profile dev up adds the dev extras.

For scheduled tasks inside the container or on the host, Cron Expression Builder builds the correct cron syntax so your scheduled jobs run exactly when you intend. Missing a cron schedule expression by one field is the classic 3am Sunday surprise.

Scanning before you ship

The last step before pushing an image to production should be a vulnerability scan. Tools like Trivy, Grype, and Snyk check your image layers against CVE databases and tell you which packages have known vulnerabilities. The output is usually sobering the first time: a "small" Debian-slim image ships with dozens of known CVEs, most low severity, some not. You triage, patch what matters, and rebuild.

Integrate scanning into CI. A failing scan (say, any High or Critical vulnerability) should block the deploy, not just warn. This is how you avoid the "base image hasn't been updated in a year" problem that quietly accumulates in every organization. The CNCF reports on supply chain security document the patterns for organizations that handle this well.

Also scan your compose file. Tools like docker compose config catch syntax errors, and static analyzers flag configurations like "bind-mounted Docker socket into a container," which is a common privilege escalation vector. A spot-check on the resulting YAML with YAML Validator prevents trailing-whitespace or tab-mix issues from silently changing behavior.

Adjacent tools worth bookmarking

Related developer utilities that fit this workflow: Kubernetes YAML Generator for when you outgrow compose, Nginx Config Generator for reverse-proxy sidecars, JSON Formatter for inspecting Docker API responses and registry manifests, and UUID Generator for unique container identifiers in multi-instance deployments.

Related pillar guide

This cluster post is part of the developer tools track. For the broader foundation on choosing and using free developer tools, see The Complete Guide to Free Online Tools.

FAQ

Should I use Alpine or Debian slim?

Debian slim for most things. Alpine for cases where size is critical and you have tested your stack end-to-end on musl. Alpine's smaller size is real but so are the occasional library incompatibilities. Start with slim; switch to Alpine only with evidence.

How do I debug a distroless container?

You cannot docker exec -it into it because there is no shell. Options: use the -debug variant of the distroless image during development (it includes busybox), or attach a sidecar debug container sharing the process namespace. In production, rely on logs, metrics, and remote tracing instead.

What is the right image size?

As small as it can be while still working. There is no magic number. "Our app binary plus the minimum runtime dependencies" is the goal. Multi-stage builds usually get you 5-10x smaller than the naive build.

Do I need Kubernetes if I am using Docker Compose?

Not for local development. Compose is the simpler and better tool for "run these five services on my laptop." Kubernetes is for production orchestration across many hosts. Many teams use compose locally and Kubernetes (or a managed alternative) in production, sharing none of the deployment files between them.

Should I commit my Dockerfile to version control?

Yes. The Dockerfile is part of your application and should be reviewed, tested, and versioned like any other source file. It should live at the repo root or in a clearly named docker/ directory, and changes to it should go through the same PR process as code.

Closing thought

A good Dockerfile is a boring Dockerfile. It pins its base image, builds in stages, copies dependencies before source, runs as non-root, and passes its vulnerability scan on every build. That is almost all there is to it. The rest is domain-specific details, and the details are much easier once the foundation is right.