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
- Start with a smaller base image; don’t chase “tiny” blindly — make it run first.
- Use multi-stage builds: separate the jobsite from the home.
- Clean after installs; don’t carry caches home.
- Keep
.dockerignorehonest; it prevents a lot of trouble. - 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.
