# 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.

```dockerfile
# 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](https://docs.docker.com/build/building/best-practices/#user) has said it for years, yet most production containers still run as `root`. One container escape, and the attacker owns the host.

```dockerfile
# 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](https://github.com/GoogleContainerTools/distroless) 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](https://sysdig.com/2025-cloud-native-security-and-usage-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:

```dockerfile
# 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**](https://github.com/aquasecurity/trivy) — open-source, fast, covers OS packages and language dependencies. Run it in CI:

```bash
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
```

- [**Grype**](https://github.com/anchore/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:

```yaml
# 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.

```text
.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`:

```dockerfile
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

```dockerfile
# 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](https://docs.renovatebot.com/docker/) 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
- [ ] `.dockerignore` covering `.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.
