Container Best Practices — Beyond the Basics
Your Dockerfile Is Probably Broken
Not broken as in “won’t build.” Broken as in: bloated, insecure, slow to build, impossible to debug, and running as root in production. Most teams copy a Dockerfile from Stack Overflow in 2019 and never touch it again. Here’s how to fix that.
Multi-Stage Builds Are Non-Negotiable
If your final image contains gcc, npm, or go build toolchains, you’re shipping your workshop along with the product. Multi-stage builds separate build-time dependencies from runtime.
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
The build stage pulls in the entire Go toolchain — roughly 300MB. The final image? Under 15MB. That’s not a micro-optimization. It’s the difference between a 3-second and a 45-second pull on cold nodes. Multiply that by 50 pods scaling up during a traffic spike and the math gets real.
The same pattern works for Node.js, Rust, Java — any compiled or bundled output. The principle: build in one stage, copy artifacts to a minimal runtime.
Stop Running as Root
The Docker documentation has said it for years, yet most production containers still run as root. One container escape, and the attacker owns the host.
# Bad: implicit root
FROM node:22-alpine
COPY . /app
CMD ["node", "/app/index.js"]
# Better: explicit non-root user
FROM node:22-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "index.js"]
Even better: use distroless images that ship with a nonroot user and contain zero shell utilities. No shell means no shell exploits.
Image Size Actually Matters
“Storage is cheap” is the wrong mental model for container images. Image size affects:
- Pull time. Every new node, every scale-up event, every rollout pulls the image. In autoscaling scenarios, pull time directly impacts response latency.
- Attack surface. Every binary in your image is a potential vulnerability. The Sysdig 2025 Container Security Report found that over 80% of container CVEs come from packages that the application never uses.
- Build cache efficiency. Smaller layers mean faster cache invalidation and less bandwidth between CI and registry.
Size targets that work in practice:
| Stack | Target Size | Base Image |
|---|---|---|
| Go | < 20MB | distroless/static or scratch |
| Node.js | < 150MB | node:22-alpine |
| Java | < 200MB | eclipse-temurin:21-jre-alpine |
| Python | < 150MB | python:3.12-slim |
Layer Order Is a Build Cache Strategy
Docker caches layers top-down. When a layer changes, everything below it rebuilds. This means dependency installation should come before source code:
# Dependencies first (changes rarely)
COPY package.json package-lock.json ./
RUN npm ci --production
# Source code second (changes often)
COPY src/ ./src/
Reverse the order and every code change triggers a full npm ci. On a project with 800 dependencies, that’s 90 seconds wasted per build.
Scan Before You Ship
Container scanning isn’t optional anymore. It’s table stakes. Tools worth using:
- Trivy — open-source, fast, covers OS packages and language dependencies. Run it in CI:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
- Grype — Anchore’s open-source scanner, good alternative to Trivy with SBOM integration.
- Snyk Container — commercial but has a free tier. Integrates with GitHub and provides fix suggestions.
The key: fail the build on HIGH/CRITICAL CVEs. Don’t just generate reports that nobody reads. Wire it into CI as a gate.
Local Dev Shouldn’t Mirror Production
A common mistake: using the same Dockerfile for local development and production. Production wants minimal, immutable images. Local dev wants hot reload, debuggers, and fast feedback.
Use a docker-compose.override.yml for local:
# docker-compose.yml (base)
services:
app:
image: myapp:latest
ports:
- "8080:8080"
# docker-compose.override.yml (local dev, auto-loaded)
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./src:/app/src
environment:
- NODE_ENV=development
Docker Compose automatically merges the override file. Production uses the base. Dev gets volume mounts and a dev-friendly Dockerfile. No conditional logic, no build args, no “if development then” in your Dockerfile.
.dockerignore Is Your First Line of Defense
Without a .dockerignore, Docker sends your entire project directory as build context. That includes .git (potentially hundreds of MB), node_modules, test fixtures, local env files, and anything else lying around.
.git
node_modules
*.md
.env*
coverage/
dist/
.vscode/
On a medium-sized project, a proper .dockerignore can reduce build context from 500MB to 5MB. That’s not just faster builds — it’s also fewer secrets accidentally baked into layers.
Health Checks Belong in the Image
Don’t rely solely on Kubernetes liveness/readiness probes. A HEALTHCHECK instruction in the Dockerfile provides a baseline that works everywhere — Docker Compose, Swarm, ECS, and plain docker run:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["/app/healthcheck"]
Use a compiled binary for the health check, not curl or wget. Those tools might not exist in your minimal image, and they add attack surface you don’t need.
Pin Your Base Images
# Bad: floating tag, different image every build
FROM node:22-alpine
# Better: pin to digest
FROM node:22-alpine@sha256:<64-char-sha256-digest>
Floating tags mean your Monday build and Friday build might use different base images. Pinning to a digest guarantees reproducibility. Update the digest intentionally, as part of a dependency update workflow — not accidentally because Docker Hub pushed a new 22-alpine.
Tools like Renovate can automate digest pinning updates, giving you reproducibility without staleness.
The Checklist
Before shipping any container to production:
- Multi-stage build separating build and runtime
- Running as non-root user
- Base image pinned to digest
-
.dockerignorecovering.git,node_modules, env files - Image scanned for HIGH/CRITICAL CVEs in CI
- Health check defined
- Final image under target size for your stack
- No secrets in build args or layers
None of this is revolutionary. It’s hygiene. But hygiene is what separates containers that run reliably for years from containers that become security incidents.