Why Your Docker Image Is 1 GB (and How to Shrink It)
· Sourabh G Kulkarni
A practical guide to reducing Docker image size with multi-stage builds, smaller base images, and fewer layers — so pulls, builds, and deploys stay fast.

A Docker image that is 1 GB is rarely a mystery. It is usually a full operating system, a compiler, a package cache, and a copy of files the running container never needs. The app itself might be 40 MB. The rest is baggage you pay for on every pull, every CI job, and every deploy.
I started caring about this when a pipeline that used to finish in a few minutes began sitting on docker pull longer than the tests. Reducing Docker image size is one of the highest-leverage changes you can make to a delivery pipeline, and it does not require a new platform.
Why a large Docker image slows you down
Image size shows up in places people do not attribute to Docker:
- CI runners download the same base and app layers on every job that does not have a warm cache.
- A registry in another region makes a Docker image too large feel like a network outage.
- Cluster rollouts wait on the node to pull before the new pod is Ready.
- A fat image has a larger attack surface. More packages means more CVEs you did not mean to ship.
None of that is fixed by a faster registry alone. You have to stop putting unused software in the final artifact.
See what is actually inside the image
Before changing the Dockerfile, look at the layers:
docker history your-image:latest
docker image ls your-image:latest
docker history shows which instruction added the weight. A single apt-get install or COPY . . is often the culprit. If you want a closer look at the filesystem, tools like dive will show which files landed in which layer.
The commands themselves are ordinary Linux. If the shell still feels rusty, the top 10 Linux commands cover the ones you will use while inspecting a container.
Multi-stage builds: compile in one stage, ship in another
A multi-stage Docker build is the default fix for compiled apps and for Node or Python services that need a toolchain only at build time.
FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER node
CMD ["node", "server.js"]
The first stage can be huge. It is not what you push. Only the last stage becomes the image tag your cluster pulls. That split is the whole idea: the compiler, test runner, and dev dependencies never leave the build machine.
Pick a smaller base image
The base image is the floor your size cannot go under.
| Base | When it fits |
|---|---|
| Full Debian or Ubuntu | You need a wide set of OS tools at runtime |
-slim | You need glibc and a shell, but not the whole distro |
| Alpine | The app and its libraries work on musl |
| Distroless | You can run a single binary with no shell |
Alpine is not automatically better. Some native modules are built against glibc and break on musl, and then you spend a day "saving" 80 MB. A slim Docker image based on Debian is often the practical middle. Distroless is excellent when the process is a static binary and you do not need to kubectl exec into a shell to debug. If you still debug that way, ship a slim image until the runbooks no longer depend on bash.
Stop copying what the process does not run
These are the usual leaks into the final layer:
COPY . .after a build, which drags in.git, tests, docs, and local env filesnpm installinstead ofnpm ci --omit=devin the runtime stage- Package manager caches (
aptlists,pipcache,npmcache) left in the same layer as the install - Secrets or
.envfiles copied because they sat in the build context
A .dockerignore is part of the Dockerfile, not an optional extra. Ignore node_modules, .git, test fixtures, and local environment files so they cannot be copied by accident.
Docker layer caching still matters. Copy the lockfile and install dependencies before you copy the source. Then a one-line code change does not bust the dependency layer. That does not shrink the image, but it stops CI from rebuilding the heavy layer on every commit. Size and cache are different problems. Fix both.
A before-and-after that is worth writing down
Measure, change one thing, measure again. I keep the numbers in the pull request:
docker image ls my-service
A typical drop I look for on a Node service is from roughly 1 GB (full Node image, dev dependencies, full source tree) to something in the 150–250 MB range with a slim runtime and a standalone server bundle. A Go or Rust static binary on Distroless can land under 30 MB. The exact number matters less than the habit of treating a regression in size as a failed build.
You can enforce a ceiling in CI with a small check against docker image inspect. If the image grows past the budget, the pipeline fails in the same way a test would.
What a small image does not fix
A tiny image can still run as root, still contain a critical library CVE, and still be configured with no memory limit. Shrinking the image is necessary. It is not the whole security or reliability story.
If the service is one container on one server, chasing Distroless is optional. If that same image is pulled to every node on every release, the size is part of your lead time. Start with a multi-stage build and a .dockerignore. Measure the result. Only then decide whether Alpine or Distroless is worth the operational tradeoff.