Shrink Docker Images by 72% Without Tears

docker

Shrink Docker Images by 72% Without Tears
Practical patterns to build faster, safer docker containers.

Let’s Agree on the Real Problem, Not the Tool

Docker isn’t our problem. Slow builds, bloated images, and grumpy security scanners are. We’ve all met “the 1.4 GB alpine container” that somehow includes five JDKs, two shells, and the entire aptitude cache. Docker makes this easy to do because it’s honest about whatever we tell it to pack. That’s a feature and a trap. If we structure our builds carelessly, the registry becomes a gym locker for forgotten dependencies and surprise binaries—and every pull wastes bandwidth, every push wastes time, and every deploy carries unknown risk.

When we talk about shrinking images “by 72%,” we’re not selling fairy dust. It’s very common to cut an everyday service from 800 MB to ~200 MB just by picking a lean base, splitting build and runtime, and trimming layers. In the process, we also speed up CI by shaving minutes off caching and improve cold-starts in Kubernetes. The bonus: smaller images have fewer moving parts to patch, which means less noise in vulnerability reports.

Let’s set expectations. We’re not chasing microscopic images at the expense of practicality. Distroless is great until you need a shell; Alpine is amazing until glibc bites you. We’ll aim for “small enough, secure enough, and fast enough” with patterns that stick. Think of this as a kitchen reset: we’ll label the containers, toss suspicious leftovers, and establish rules for what goes where. Then we’ll show how to keep it that way—without turning every Dockerfile into a maze of clever tricks.

Start With the Image: Base, Tags, and Trust

Our image size outcome is mostly decided in the first line of the Dockerfile. Base matters. For interpreted stacks (Node, Python), the official “-slim” or minimal variants are often a sweet spot. For compiled apps (Go, Rust, Java’s jlink), a scratch or distroless base can be fantastic—if we actually copy only what we need. What we must avoid is grabbing “latest” and hoping for the best. Pin the major version and, ideally, the digest. That gives us repeatability and a sane upgrade path.

Tags express intent; digests provide immutability. Combining them looks like: FROM debian:bookworm-slim@sha256:… This way we know the family and the exact bytes. It also helps when we’re debugging: we can see which distro lineage we’re on without trusting the registry’s mood that day. Understanding how layers and metadata work is worth five minutes with the OCI Image Spec. It explains why a careless RUN tar xzf … can balloon storage for years.

There’s also the trust angle. Stick to well-maintained, official images or vendor-maintained ones with clear update cadence. Random “helpful” images from public registries may be abandoned, out-of-date, or quietly mutated. If we bake our own bases, keep them boring: patch upstream, add only what multiple services require, and publish a changelog. The goal is a base that’s predictable and easy to maintain. Meanwhile, let’s keep an eye on CVE noise. Sometimes “high severity” in a package we don’t even run isn’t worth an emergency Tuesday. Smaller, purpose-built bases cut that noise before it starts.

Multi-Stage Builds That Don’t Feel Like Origami

Multi-stage builds are how we stop dragging toolchains into production. We compile inside a “builder” stage, then copy only the artifacts into a minimal runtime stage. With BuildKit, we can also cache module downloads and build outputs without polluting layers. Here’s a compact Go example that compiles to a static binary and runs as a non-root user:

# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS build
WORKDIR /src
RUN --mount=type=cache,target=/go/pkg/mod go env -w GOPROXY=https://proxy.golang.org,direct
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

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

This approach usually drops image sizes from hundreds of MB to a few dozen, while also reducing the surface area. Note the use of caches: we speed up rebuilds in CI without hardcoding caches into layers. If we need certs or timezones, we can copy just those artifacts rather than hauling a whole distribution. And if we’re in Node or Python land, the same principle holds: build or install dependencies in one stage, prune dev packages, then copy only production files into a minimal runtime image.

If multi-stage feels like origami, we’re overcomplicating it. Keep stages few and purpose-driven: builder, tester (optional), runtime. Nothing more unless there’s a measurable payoff.

Cut the Fat: Layer Hygiene and Caching

Layers are like sedimentary rock: they remember everything we put down. That’s why a single RUN apt-get install … followed by apt-get remove … doesn’t actually remove installed files from previous layers. The fix is to do all related operations in one layer and clean up before that layer ends. For example: RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* keeps caches from fossilizing in your image. Order matters too: put the most frequently changing files (app source) near the end so earlier layers cache well.

Two low-effort wins: a tight .dockerignore and deliberate COPY. If we mindlessly COPY ., we risk dragging .git, node_modules, test fixtures, and other nonsense. That’s extra MB, slower build context upload, and broken caching. A clean .dockerignore pays dividends every single build. Also, prefer ENV and ARG sparingly; invalidating the cache because we sprinkled ARGs across the file is a slow burn.

Before we ship, let’s lint. Tools like hadolint catch common Dockerfile mistakes—shell quoting, missing cleanups, and unsafe instructions. When in doubt, the official Dockerfile best practices are short, sensible, and frequently updated. And try to resist “RUN curl | bash” unless you pin a checksum. Supply-chain gremlins love a good pipe.

One last point: BuildKit’s secret mounts help us avoid baking credentials into layers. Use –mount=type=secret to fetch private dependencies at build time without leaving breadcrumbs.

Reproducible Builds and SBOMs in CI

Reproducibility isn’t just “it builds on my machine.” We want a trace: what source, what base digest, which dependencies, and a signed provenance that tools can verify. Thankfully, Docker Buildx makes this straightforward. We can produce an SBOM, SLSA-style provenance, and push a multi-arch image with build cache—all in one go. In GitHub Actions, that looks like:

name: build-and-publish
on: { push: { branches: ["main"] } }

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/org/app:1.2.3
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: mode=max
          sbom: true

Now we’ve got artifacts that scanners and policy engines can trust. SBOMs also cut through noise: if a CVE lands in an unused package, we can prove it’s not in our runtime layer. For details on attestations and provenance, the Docker Build documentation is clear and practical. If we’re signing, wire up cosign with keyless OIDC to avoid key sprawl. The outcome is boring on purpose: reproducible, traceable images that roll through promotion without mysterious differences.

Runtime Sanity: Health, Limits, and Drop Privileges

Small images won’t save us if the container runs as root with every capability under the sun. Let’s trim runtime privileges by default. Locally, docker run supports easy wins: –read-only, –cap-drop=ALL, –cap-add=NET_BIND_SERVICE when needed, and –user 65532 for a non-root UID. If the app needs to write, mount a specific volume or tmpfs. Don’t give it the whole filesystem just because it asked nicely.

In Kubernetes, we can codify this behavior. Add liveness and readiness probes, constrain resources, and lock down the securityContext. The probes help us avoid “it’s running but not healthy” incidents, and resource limits keep noisy neighbors in check. A minimal deployment snippet:

containers:
- name: app
  image: ghcr.io/org/app:1.2.3
  ports: [{ containerPort: 8080 }]
  readinessProbe:
    httpGet: { path: /healthz, port: 8080 }
    initialDelaySeconds: 3
    periodSeconds: 5
  livenessProbe:
    httpGet: { path: /livez, port: 8080 }
    initialDelaySeconds: 10
    periodSeconds: 10
  securityContext:
    runAsNonRoot: true
    allowPrivilegeEscalation: false
    readOnlyRootFilesystem: true
    capabilities: { drop: ["ALL"] }
  resources:
    requests: { cpu: "100m", memory: "128Mi" }
    limits: { cpu: "500m", memory: "256Mi" }

If probes are new territory, the Kubernetes docs on probes are concise and battle-tested. The guiding principle: design for failure modes we expect, not the happy path. Let the orchestrator help us, but give it the right signals and constraints.

Debugging and Observability Without Shipping a Shell

We love distroless until we need a shell to debug a gnarly production issue. Good news: we don’t have to ship bash to every container “just in case.” Locally, we can spin an ephemeral helper container with the same network namespace to curl endpoints, sniff traffic, or tail logs. In Kubernetes, ephemeral containers let us attach tooling to a running pod without changing the original image. That’s easier to justify in a security review than sneaking /bin/sh into every runtime.

Observability is the longer-term play. If the app exposes /metrics, plug it into Prometheus and keep dashboards honest. If it logs in JSON, wire it to the collector. And for tracing, instrument the hot path so we can follow a request across services without SSH adventures. These basics reduce the need for on-container shells in the first place. For really stubborn cases, we keep a “debug image” per language stack—tools only, no app—and attach it when necessary. Then we delete it. No long-term shells lurking in production.

One more tip: ship a minimal health and debug endpoint in the app. A /healthz that checks dependencies (not just “I’m alive”) and a /ready that reflects readiness saves us a hundred mystery restarts. Small effort, big payoff. When we mix that with ephemeral tooling, we keep runtime images lean while still solving Friday incidents before dinner, not after midnight.

What We’ll Fix First on Monday

Let’s make this extremely actionable. First, pick one service and rewrite its Dockerfile to a two-stage build. If it’s Go or Rust, move to scratch or distroless and run as non-root. If it’s Node or Python, split dev dependencies from runtime, use a “-slim” base, and prune. Pin the base tag and digest. Add a .dockerignore that actually ignores things. Run hadolint and fix what it complains about without arguing; it’s usually right.

Second, introduce Buildx in CI and turn on cache-to/cache-from with provenance and SBOM. Save five minutes of every developer’s life per build; we’ll be heroes by Thursday. Third, set runtime flags: read-only root, drop caps, and a non-root user. Convert those flags into Kubernetes securityContext if we’re on a cluster. Wire liveness and readiness probes based on what the app can honestly report. The Kubernetes docs are short, and they prevent slow-motion outages we’d rather not explain to anyone.

Fourth, watch the metrics. Images should get smaller; bills for egress and registry storage should tick down. Builds should get faster and more predictable. Vulnerability scans should get quieter—not because we hid things, but because we shipped fewer things. If those numbers don’t move, we revisit base selection and what we’re copying into the final stage.

Finally, be boring on purpose. The point of all this docker work is fewer surprises and faster deliveries. When we cut the image by 72%, nobody claps at standup. But everyone notices that deploys roll faster, pages stop chirping, and we use our Fridays for feature work instead of forensic archaeology.

Share