Journal — December 28, 2025 · 5 min read
Docker Images That Aren't 2GB
A step-by-step walkthrough for shrinking bloated Node and Python images, ordered by impact — build context, multi-stage builds, base image choice, dependency pruning, and layer caching.
The first container image I shipped to production was north of 1.5GB, for a Node API that was generously four thousand lines of code. It contained a full Debian userland, a C++ toolchain, the entire dev dependency tree, a .git directory, and a node_modules copied in from my laptop before npm install ran again inside the build.
Each is a separate mistake with a separate fix. Here they are, biggest wins first.
1. Fix the build context before anything else
Before Docker reads your Dockerfile it tars up the build directory and sends it to the daemon. Without a .dockerignore, that includes node_modules, .git, dist, local .env files, test fixtures, and whatever else is lying around.
Two consequences. Builds are slow, because you ship hundreds of megabytes over a socket every run. And COPY . . puts all of it inside the image, including your Git history and possibly credentials.
node_modules
.git
dist
.env*
*.log
coverage
Five minutes, and on a bloated repo it's often the biggest single cut. It also kills the bug where a node_modules built on macOS lands in a Linux image and a native module segfaults for reasons that take an afternoon to find.
2. Split the build from the runtime
If your final image can compile C, it's too big. A build toolchain (gcc, make, Python headers, node-gyp's dependencies) is hundreds of megabytes, and none of it runs the process.
Multi-stage builds fix this. The first stage installs everything and builds. The second starts from a clean base and copies only the output.
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Python is the same shape: build wheels in the first stage with pip wheel, install from the wheelhouse in the second, never let build-essential reach the runtime.
This is the biggest reduction after the build context, and a bigger deal than the base image choice people obsess over.
3. Choose the base deliberately
node:22. Full Debian, close to a gigabyte before you add anything. Use it in the build stage where the extra tools are useful. Don't ship it.
node:22-slim / python:3.12-slim. Debian with the bulk stripped out, tens of megabytes for the OS layer. glibc, so every native module works. The correct default for the runtime stage of almost every service.
Alpine. Smaller still, and here's the trap: Alpine uses musl instead of glibc. Any package with a prebuilt native binary (sharp, bcrypt, canvas, most of the Python scientific stack) either has no musl build or has one that behaves differently. So you install build-base to compile from source, which puts hundreds of megabytes back and adds minutes to every build, and you've made a larger image by trying to make a smaller one. There are also long-standing DNS differences in musl that surface as intermittent lookup failures under load, which is horrible to debug. Use Alpine once you've verified your dependency tree has musl builds. Not by default.
Distroless. Google's images: a language runtime and nothing else. No shell, no package manager, no ps. Small, and a real security win because there's no shell for an attacker either. The cost is that docker exec into a broken container gives you nothing; use the :debug variant to look inside. Right for a mature service with settled dependencies, not for something you poke at weekly.
4. Stop installing dev dependencies in the runtime
npm ci --omit=dev. TypeScript, ESLint, Jest, the whole test harness and every type package: none of it runs in production, and together it's frequently larger than your actual dependency tree.
Python: install from a locked requirements file that excludes dev tooling, or use uv/Poetry groups properly. The failure mode is one requirements.txt containing pytest, black, and something heavy a linter dragged in.
5. Clean caches in the same layer that creates them
This catches people who've done everything else right. Layers are additive. Install packages in one RUN and delete the cache in the next, and the cache is still in the earlier layer and still counts toward image size. Deleting a file never shrinks an image unless it happens in the same layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
One RUN, chained. Same for npm cache clean --force, pip install --no-cache-dir and apk add --no-cache. Put --no-install-recommends on every apt call by reflex; the recommends chain drags in a startling amount.
6. Order layers so the cache actually hits
Docker invalidates every layer after the first one that changes. If you COPY . . and then RUN npm ci, editing one source file reinstalls every dependency.
Copy the manifest files first, install, then copy the source. Dependencies change weekly, source changes hourly. This doesn't shrink the image, it shrinks build time and the CI bill, which is often what you cared about anyway.
Why "it's only pull time" is wrong
The usual defence is that the image is pulled once and cached on the node. Four things wrong with that.
Registry storage costs money, and you store every tag of every build. ECR charges around 10¢ per GB-month, which is nothing until you have a year of 2GB images across a dozen services.
Cold starts aren't rare. Every autoscale event, node replacement, spot reclamation and fresh deploy pulls the image. On Cloud Run or Fargate that pull sits in the latency path of a scale-up.
CI pays every time. Pushing and pulling multi-gigabyte layers on every run becomes real minutes on billed runners.
Scan surface. Every package in the image is one your vulnerability scanner reports on. A slim runtime with no compilers means fewer CVEs to triage, and triage is human time. This is the one I care most about.
Know when to stop
Getting from 1.5GB to roughly 200MB is a couple of hours of the work above, and it's worth doing on every service you own.
Getting from 200MB to 120MB means fighting your base image, hand-copying shared libraries, or adopting distroless on a codebase that isn't ready for it. I spent a full day on that stretch once and got back a build I was slightly afraid of. Once the toolchain is gone, the dev dependencies are gone, and the cache hits, take the win and go fix something users can feel.