CI that day felt like a Friday night elevator: everyone waiting. A 2GB image turns every build into moving a fridge. Someone joked, “I’ll grab a coffee while it pulls.” They came back to a cold coffee, and the pull was still going. Since I own this thing, I had to make it lean.

Goals and Outcome

The bottom line: no magic, just craft. Use a smaller box, only pack the essentials, and put the tools back when you’re done. It sounds like a platitude, but I learned while stumbling through it. We went from 2GB down to 50MB. I didn’t learn everything, but I learned to keep things simple.

Step 1: Pick the right base image

We started with a “fully furnished apartment” image. Switching to a lean base trims fat immediately — like taking a scooter instead of an SUV for soy sauce. First cut: 2GB → ~900MB. I was happy — until a production alert the next day: a native module misbehaved on musl. Lesson learned: slimming isn’t starving; it’s eating right.

Step 2: Multi-stage builds — jobsite vs. home

I moved to a two-phase lifestyle: noisy at the jobsite, quiet at home.

In Docker terms: multi-stage builds. Stage one is the jobsite with all the tools (compilers, build/debug utilities). Stage two is the polished home — only the artifacts and the minimal runtime. This dropped us from ~900MB to ~300MB. You could finally see the floor grain.

Step 3: Clean after install — no caches

Time to clean. Clear caches and temps when installing dependencies. Apt lists, pip cache, npm cache — they’re the renovation dust. After cleaning, the image trimmed down another notch. It’s like cleaning as you cook; the next meal is quicker.

Step 4: Minimal runtime (distroless/static)

Final cut: declutter the runtime. Static link when you can, use distroless when it fits. Those “just the food, no pots and pans” images are always light. When I pushed a 50MB image for the first time, I stared at the number. Maybe I can be a slimming consultant after all.

To be concrete, here are two real changes I made. Not a formal tutorial — a practical postmortem.

Example: Go — static binary + distroless

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app

FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /src/app /app/app
USER nonroot:nonroot
ENTRYPOINT ["/app/app"]

This is low-drama: static build, a single binary, toss it into distroless and run. Like packing one pan that does everything. For troubleshooting, I build an extra debug variant — alpine with a tiny shell — switch to debug when needed, then back to clean.

Example: Node — multi-stage + cache hits

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist
CMD ["node", "dist/index.js"]

Copying package*.json first lets the dependency layer cache properly. Dev deps stay at the jobsite. Alpine is light, but I’ve tripped on musl with some native modules. My compromise: compile in the build stage and avoid those modules at runtime; if pain persists, use a slim base. Don’t be stubborn — time is more expensive.

Only pack what you need: .dockerignore and install flags

I wrote a .dockerignore to keep .git, test data, and scaffolding leftovers out. Install in one go and clean immediately:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

For pip I add --no-cache-dir --no-compile; for npm I use npm ci --omit=dev. This reduces fragmentation and nitpicky layers. If you want extra speed, enable BuildKit and give dependencies a cache mount — like keeping the spices by the stove.

Keep a toolbox: an enterable debug variant

Reality checks happen. Distroless is clean, but you can’t shell into it. My rule now: keep a debug variant for each service; switch to it when trouble hits, then switch back. Certificates are another gotcha — bungle TLS and you’ll tumble.

Here’s the debug variant idea, keeping the same build but swapping the runtime for something enterable:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

# Debug variant: minimal shell environment for troubleshooting
FROM alpine:3.20 AS debug
RUN apk add --no-cache nodejs-current npm bash curl ca-certificates
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist
CMD ["node", "dist/index.js"]

Default to clean in production; switch to debug when you need to investigate. It often saves you a long night.

Final checklist

  1. Start with a smaller base image; don’t chase “tiny” blindly — make it run first.
  2. Use multi-stage builds: separate the jobsite from the home.
  3. Clean after installs; don’t carry caches home.
  4. Keep .dockerignore honest; it prevents a lot of trouble.
  5. Keep a debug variant you can enter; don’t lock yourself out.

Results and wrap-up

We went from 2GB to 50MB. Pulls feel like an express elevator. CI time dropped by nearly half; disk alerts vanished. More importantly, new projects copying this pattern are “born clean” — no crash diet later.

If your images are getting chunky, don’t starve them (and don’t whittle them to bones either). Start with the base image, use multi-stage builds, clean after install. Check the scale after a couple of days. If the number doesn’t move, come yell at me; if it drops, you’ll likely agree — it’s just craft.