TL;DR
Our Node.js service shipped in a 1.9 GB container image, which made every deploy a coffee break and every CI run slower than it needed to be. Instead of asking Claude Code for "a better Dockerfile," I gave it a measure → change → verify loop with a real number to optimize, and two days later the image was 240 MB and deploys dropped from 6m10s to 1m50s. Here's the loop, the actual Dockerfile diff, and the five things I'd do differently.
The Problem
I inherited a backend service that had grown the way most services grow: someone wrote a Dockerfile in 2023 that worked, and nobody touched it again for two years.
The symptoms were annoying rather than catastrophic, which is exactly why nobody fixed them:
-
docker buildon a cold cache: 11 minutes - Image size: 1.94 GB
- Deploy (registry push + node pull + start): 6m10s
- Every CI job that needed the image paid the pull cost, ~15 times a day
Nobody was going to get promoted for fixing this. But I was doing four or five deploys a day during a feature push, and I was spending real minutes staring at a progress bar. Registry egress wasn't free either.
Here's the original Dockerfile, lightly anonymized:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
If you've done this before you can already see three or four problems. That's actually the interesting part of this story: I could see the problems too. What I couldn't easily do was work out which fixes were worth the risk, in what order, on a service I hadn't written, without breaking a production deploy on a Friday.
That's the shape of task I've found AI coding agents are genuinely good at — not "invent something clever," but "grind through a search space I don't have the patience for, while I hold the safety rails."
How I Solved It
The mistake I made first
My first prompt was basically:
Optimize this Dockerfile to reduce image size.
I got back a beautiful multi-stage Dockerfile with a distroless base, a non-root user, and a tidy little comment on every line. It looked like something from a conference talk.
It also didn't boot. The service used a native module that needed a shared library the distroless image didn't have, and the healthcheck failed instantly.
The output was plausible, and plausible is the failure mode you have to design against. A one-shot answer from an agent is a guess dressed up as a solution. What it was missing wasn't intelligence — it was feedback.
The loop that actually worked
So I stopped asking for a Dockerfile and started giving it a measurable target plus the tools to check its own work.
flowchart LR
A[Measure baseline] --> B[Propose ONE change]
B --> C[docker build]
C --> D{Build OK?}
D -- no --> E[Read error, revert, retry]
E --> B
D -- yes --> F[Boot + smoke test]
F --> G{200 OK?}
G -- no --> E
G -- yes --> H[Record size delta]
H --> I{More ideas?}
I -- yes --> B
I -- no --> J[Done]
Concretely, I wrote a tiny script the agent was told to run after every single change:
#!/usr/bin/env bash
# verify.sh — build, boot, smoke test, report size. Exit non-zero on any failure.
set -euo pipefail
docker build -t svc:candidate . > /tmp/build.log 2>&1
CID=$(docker run -d -p 3000:3000 --env-file .env.test svc:candidate)
trap 'docker rm -f "$CID" > /dev/null' EXIT
for i in $(seq 1 30); do
if curl -fsS http://localhost:3000/healthz > /dev/null; then break; fi
[ "$i" -eq 30 ] && { echo "FAIL: never became healthy"; docker logs "$CID"; exit 1; }
sleep 1
done
npm run test:smoke -- --base-url http://localhost:3000
SIZE=$(docker image inspect svc:candidate --format '{{.Size}}')
echo "PASS size_bytes=$SIZE"
Then the prompt became roughly:
Baseline is 1.94 GB. Run
./verify.shto check any change. Make one change at a time. If verify fails, revert that change and try a different approach. Log every attempt tonotes.mdwith the size delta and whether it passed. Don't change application code — Dockerfile,.dockerignore, and build config only.
That last constraint mattered more than I expected. Without it, the agent will happily start "simplifying" your imports to shave a dependency, and now you're reviewing an application diff you never asked for.
What it actually found
Over about 40 iterations across two afternoons, here's what stuck:
| Change | Size after | Delta |
|---|---|---|
| Baseline | 1.94 GB | — |
Add a real .dockerignore
|
1.71 GB | −230 MB |
| Multi-stage: build deps stay in builder | 940 MB | −770 MB |
npm ci --omit=dev in the runtime stage |
690 MB | −250 MB |
node:22-slim instead of node:22
|
310 MB | −380 MB |
| Drop cached build artifacts + npm cache | 240 MB | −70 MB |
The .dockerignore one is embarrassing and I'd bet money it applies to your repo too. There wasn't one at all, so COPY . . was shipping node_modules, the .git directory, and a fixtures/ folder with 180 MB of sample data into the build context — and then npm install was overwriting the copied node_modules anyway.
The final Dockerfile:
# ---- builder ----
FROM node:22-slim AS builder
WORKDIR /app
# Copy manifests first so this layer caches across source changes.
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ---- runtime ----
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD node -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.js"]
And the .dockerignore:
node_modules
.git
dist
coverage
fixtures
*.log
.env*
Note what is not in there: distroless. The agent tried it twice, verify.sh failed both times on the native module, it logged "distroless: rejected, missing libstdc++ at runtime" in notes.md, and moved on. That rejection is worth as much as any of the wins — it's a documented dead end I no longer have to re-litigate in code review.
The numbers after
- Image size: 1.94 GB → 240 MB (−87%)
- Cold build: 11m → 3m40s
- Warm build after a source-only change: 28s (manifests-first layer ordering)
- Deploy: 6m10s → 1m50s
Lessons Learned
1. Give the agent a number, not an adjective. "Optimize this" produces confident prose. "Baseline is 1.94 GB, here's the script that measures it" produces a search. The single highest-leverage thing I did in this whole project was write verify.sh before writing the prompt.
2. Make failure cheap and observable. The loop only works because a bad idea costs one failed build and gets automatically reverted. If your verification takes 20 minutes or needs a human to eyeball it, the agent can't iterate and you're back to one-shot guessing.
3. "One change at a time" is a real constraint, not politeness. When it batched four optimizations together and the build broke, neither of us knew which one did it. Serializing the changes turned a debugging problem into a table of clean attributions — which is also what made the summary table above possible.
4. Fence off what it may not touch. "Dockerfile, .dockerignore, and build config only" kept the diff reviewable. Scope constraints are the cheapest form of code review: the changes you never have to read are free.
5. The rejected attempts are half the deliverable. notes.md ended up with 14 entries, 6 of them failures. That file answered "why aren't we on distroless?" and "did anyone try Alpine?" (yes — the native module again) before anyone asked. Make the agent write down what didn't work, or you'll pay for the same experiment twice.
The meta-lesson: none of the individual fixes here were clever. Multi-stage builds and .dockerignore are 2019-era advice. What the agent supplied wasn't insight, it was patience — 40 build-and-measure cycles that I would have abandoned after four.
What's Next
Two follow-ups I'm working on:
- A CI size budget. A job that fails the build if the image grows more than 10% over the last tagged release. Wins like this rot silently; the only way to keep 240 MB is to make regressions loud.
- Pointing the same loop at cold-start time. Same structure — a script that measures the real number, a constraint on what may change, one change per iteration. I suspect the loop generalizes better than the Dockerfile does.
If you try this, the setup that matters is: one script that returns a number and a pass/fail, and a hard boundary on what the agent may edit. Everything else is detail.
Versions used: Claude Code CLI v2.x, Docker 27.x, Node.js 22.x, on macOS 15 and Ubuntu 24.04 CI runners.
Wrap-Up
If you've got a Dockerfile nobody has opened since 2023, go check for a .dockerignore right now. I'll wait. That one took me 90 seconds and saved 230 MB.
Your turn: what's the biggest image you've shrunk, and what was the single change that did the most work? Drop it in the comments — I'm collecting the rejected approaches as much as the wins.
If build-loop patterns like this are useful to you, follow me here on Dev.to — I write up one of these agent-in-the-loop experiments regularly, failures included. 🚀
Top comments (0)