Scenario: The 2GB Hello-World
A small Node service produced a 2.2GB image and a twelve-minute pipeline. The investigation, the fix, and what interviewers really test here.
The Symptom
A team ships a Node API — maybe 4,000 lines of application code. Nothing exotic.
The complaints arrive in this order, over about two months:
- CI takes twelve minutes, and most of it is "pushing image".
- The registry bill has become a line item someone asks about.
- Deploys to a new host take several minutes because the pull is enormous.
- A rollback — which should be the fastest operation you own — takes just as long.
Someone finally runs docker images and finds the number: 2.2GB.
Nothing is broken. Every test passes. This is the kind of problem that never triggers an alert and quietly taxes every single thing the team does.
The Investigation
The instinct is to start deleting things from the Dockerfile. Don't. Find out where the bytes are first:
docker history myapp:latestThis lists layers newest-first with the instruction that created each and the size it contributed. The output tells a clear story:
| Size | Created by |
|---|---|
| 1.1GB | FROM node:22 |
| 680MB | RUN npm install |
| 412MB | COPY . . |
| 8MB | RUN npm run build |
Three findings, each a different mistake:
The base image is 1.1GB. node:22 is the full Debian-based image with a complete build toolchain — compilers, headers, Python, git. All of it needed to build certain native modules, none of it needed to run the application.
npm install brought 680MB. That's the full dependency tree including devDependencies — TypeScript, the test framework, ESLint, type definitions. None of it runs in production.
COPY . . added 412MB. That's the giveaway. The source is a few megabytes. So what else is in the build context?
docker build . 2>&1 | head -1
# => transferring context: 412.3MBNo .dockerignore. The build was shipping node_modules from the host, the .git directory with full history, local dist/ output, and test fixtures.
The .git inclusion is worse than a size problem. Git history contains every version of every file — including any credential ever committed and later removed. A COPY . . without a .dockerignore can ship a secret that was deleted from the working tree two years ago.
The Fix
Three changes, applied in order of impact.
1. A .dockerignore — the cheapest win, and it goes first because it also makes the cache more stable:
.git
node_modules
dist
coverage
.env
.env.*
.vscode2. A multi-stage build — build with the full toolchain, ship without it:
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]3. Instruction ordering — the dependency install sits above the source copy, so a code change no longer reinstalls the dependency tree.
Result: 2.2GB → roughly 145MB, and rebuilds that only touch application code complete in seconds instead of minutes.
Verifying It Actually Worked
Don't trust the diff — measure:
docker images myapp # the new size
docker history myapp:latest # where the remaining bytes are
docker build . 2>&1 | head -1 # context size, post-.dockerignore
time docker build -t myapp . # rebuild with a warm cacheThat last one matters most day to day. Change one source file, rebuild, and confirm the dependency layer reports CACHED. If it doesn't, your ordering is still wrong.
Check yourself
A teammate proposes fixing the image size by adding `RUN npm prune --omit=dev && rm -rf /usr/share/doc /var/lib/apt/lists/*` as the last line of the existing single-stage Dockerfile. How much will this shrink the image?
In an Interview
"Your image is 2GB. Walk me through what you'd do." This is one of the most common Docker questions, and the weak answer is a list of tips. The strong answer is a method.
What's actually being tested
- Do you measure before changing things? Reaching for
docker historyfirst separates people who have done this from people who have read about it. - Do you understand layers? The delete-doesn't-shrink point is the fastest way to demonstrate real understanding.
- Can you reason about tradeoffs? Anyone can say "use Alpine". Knowing why that's sometimes wrong is the differentiator.
How to structure the answer
- Measure —
docker historyto find which layers carry the weight, and the build output for context size. - Separate build from runtime — multi-stage, so the toolchain never reaches production.
- Right-size the base —
-slimor distroless, with the tradeoffs stated. - Fix the context —
.dockerignore. - Verify — re-measure, and confirm the cache still works on a code-only change.
The follow-ups to expect
"Why not just use Alpine everywhere?" Because it uses musl libc rather than glibc, so precompiled binaries and wheels may not be usable and get compiled from source — slower builds, occasional outright failures, and documented performance differences for some workloads. It's a good choice you should test, not a default you should assume.
"How does this affect build time?" Different axis, and worth being explicit: instruction ordering and cache mounts govern build time, multi-stage and base choice govern image size. They're related but not the same lever, and optimising only for the size number can make builds slower.
"What's the downside of distroless?" No shell, so docker exec -it container sh fails. You need docker debug, a debug build target, or a sidecar sharing the namespaces. Naming that tradeoff unprompted signals you've actually run these in production.
A detail worth mentioning if it's true for you: image size affects rollback speed. When you're mid-incident and need the previous version back, a 145MB pull and a 2.2GB pull are a very different experience. Framing size as an availability concern rather than a tidiness concern tends to land well.
Check yourself
An interviewer asks why you'd put COPY package.json before COPY . . in a Dockerfile. What's the most complete answer?
The Transferable Lesson
The bytes had been there from the first commit. Nobody noticed because nothing failed — the pipeline was just a bit slower each month, and slow is easy to normalise.
The habit worth taking from this: run docker history on any image you're responsible for, once. It takes ten seconds and it very often surfaces something surprising.
Next Scenario
An image that's merely large is an annoyance. The next scenario is the one that ends with a postmortem — a database container that lost every row, despite a volume that was declared, attached, and visibly present.