How to Reduce Docker Image Size (Techniques That Work)
To reduce Docker image size, start with a smaller base image (alpine, -slim, or distroless), use multi-stage builds so compilers and build tools never ship in the final image, and clean up package caches in the same RUN layer that created them. Those three moves alone routinely cut an image by more than half. Everything else is refinement.
Big images cost you real money and real time: slower docker pull on deploy, slower CI, more storage on every registry and host, and a larger attack surface to scan. Below is what actually moves the number, roughly in order of impact.
Measure before you cut
Don't optimize blind. Check what you're shipping first.
docker images myapp:latest
docker history myapp:latest --human --no-trunc
docker history shows the size added by each layer, which usually points straight at the culprit. If you want a proper breakdown of wasted space and duplicated files across layers, dive is the tool worth installing:
dive myapp:latest
It gives you an efficiency score and flags files that get added in one layer and deleted in another, which still costs you (more on that below). Write down the starting number. You want to know whether a change helped or just felt like it did.
Pick a smaller base image
The base image is usually the single biggest lever. A full node:20 image is around 1GB. node:20-slim is a few hundred MB. node:20-alpine is smaller still.
# Heavy
FROM node:20
# Lighter
FROM node:20-slim
# Lightest (musl libc, watch for native module issues)
FROM node:20-alpine
A word of warning on Alpine. It uses musl instead of glibc, so native modules and some binaries that expect glibc can break or behave differently, and DNS resolution has historically had edge cases. If your build compiles native dependencies, -slim is often the safer default, and the size difference is smaller than people assume.
For compiled languages like Go, Rust, or C++, you can go further with distroless or even scratch, because the final image needs no shell and no package manager at all:
FROM gcr.io/distroless/static-debian12
Use multi-stage builds to reduce Docker image size
This is the technique that matters most for compiled languages and anything with a heavy build toolchain. You build in one stage with all the tools you need, then copy only the finished artifact into a clean, tiny final stage. The compilers, headers, and dev dependencies stay behind.
Here's a Go example that produces a final image measured in single-digit MB:
# Build stage
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# Final stage
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]
The same pattern works for Node. Install and build with dev dependencies, then copy only the built output into a slim runtime stage:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
Running npm ci --omit=dev in the final stage keeps build-only packages out of production. You get the compiled output without the toolchain that made it.
Clean up in the same layer
Each RUN, COPY, and ADD creates a layer. Once a file exists in a layer, deleting it in a later layer does not shrink the image. The bytes are still there in the earlier layer's history. So cleanup has to happen in the same RUN that created the mess.
Wrong (the cache is baked into the image forever):
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
Right:
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends skips optional packages you almost never need. For Alpine, use --no-cache so nothing is written to disk in the first place:
RUN apk add --no-cache curl
And for pip, skip the wheel cache:
RUN pip install --no-cache-dir -r requirements.txt
Add a .dockerignore
This one takes thirty seconds and people forget it constantly. Without a .dockerignore, COPY . . drags your entire working directory into the build context and often into the image: .git, node_modules, local env files, test fixtures, build artifacts. It bloats the image and it can leak secrets.
.git
node_modules
dist
*.log
.env
.env.*
Dockerfile
.dockerignore
coverage
tmp
A smaller build context also means faster builds, since Docker doesn't have to send all those files to the daemon.
Order layers so the cache actually helps
Cache ordering doesn't shrink the final image, but it makes rebuilds fast, which matters when you're iterating. Copy your dependency manifests and install dependencies before you copy the rest of your source. That way a code change doesn't bust the dependency cache.
COPY package*.json ./
RUN npm ci
COPY . .
If you copied everything first, every source edit would reinstall every dependency. You feel this pain most while debugging a container that won't build or start, where slow rebuild loops turn a five-minute fix into an hour.
Squash and combine sparingly
You'll see advice to chain every command into one giant RUN to minimize layers. Do it where it makes sense (package install plus cleanup), but don't turn your whole Dockerfile into one unreadable line. Layers are cached independently, so over-combining hurts your rebuild times for little size benefit. The real wins are the base image and the multi-stage build, not shaving a layer here and there.
BuildKit also lets you mount caches that never end up in the image, which is great for package managers:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
The downloaded packages live in a build cache, not a layer, so they speed up rebuilds without adding a byte to the final image.
Verify on the box, not just locally
After you rebuild, confirm the real deployed size on the host, not just what your laptop reports. Registry compression and platform differences mean the number on the server is the one that counts. If you manage remote Docker hosts, you can check image sizes, layer counts, and disk usage across every host from your phone with Docker HQ, which is handy when you're away from your desk and a deploy is chewing up disk. It also ties into broader Docker container monitoring so you catch a host filling up before it takes services down with it.
Running low on disk from oversized images and dangling layers is a common cause of hosts falling over. A periodic prune keeps it in check:
docker image prune -a
docker system df
docker system df shows how much space images, containers, volumes, and build cache are each using, so you know where the space actually went. When a host does run out of room and services start failing, our guide to incident response when a Docker host goes down walks through the recovery steps.
Frequently asked questions
Does a smaller Docker image run faster?
The container doesn't execute faster, but it deploys faster. Smaller images pull quicker, so rollouts and autoscaling events complete sooner, and cold starts on a fresh node are shorter. The runtime CPU and memory of your app are unaffected by image size.
Is Alpine always the best base image for small images?
No. Alpine is small, but its musl libc can break native modules, cause subtle bugs, and slow down some workloads compared to glibc. For compiled binaries, distroless or scratch are usually smaller and safer. For interpreted languages with native dependencies, -slim variants often give you most of the savings without the compatibility headaches.
Why is my image still huge after deleting files in the Dockerfile?
Because deleting a file in a later layer doesn't remove it from the earlier layer where it was added. The bytes stay in the image history. You have to delete in the same RUN command that created the files, or use a multi-stage build so the unwanted files never make it into the final stage.
How do I find what's taking up space in an image?
Run docker history myapp:latest --human to see the size added by each layer, then use dive myapp:latest for a file-level breakdown and a wasted-space report. Start with the largest layer and work down.
Where to start
If you only do one thing this week, add a multi-stage build and switch to a slim or distroless final image. Measure with docker history before and after so you can prove the change worked. Then add a .dockerignore, because it's free and it plugs the most common source of accidental bloat.