Why Size Matters
When I started with Docker, I treated images like VMs: install everything, run everything, ship it. My first production image was over 1.5 GB. Deploys were slow, registry costs were climbing, and pulling an image on a bad connection was a nightmare. I learned the hard way that image size isn't just a vanity metric. It directly affects build time, push/pull speed, disk usage, and attack surface. Smaller images are faster to ship and easier to secure.
The good news: cutting bloat is mostly about discipline and a few clever tricks. Here's what actually worked for me.
Start with the Right Base
Your base image sets the floor. Don't grab ubuntu:latest when you need just a runtime. For Node.js, I use node:alpine (about 50 MB vs 300+ MB for the full image). For Python, python:slim is a solid middle ground. Alpine uses musl instead of glibc, which can break some native packages, so test early.
If you're really minimal, you can even use scratch as a base for static binaries. I've built Go apps that run on scratch with just the binary and a CA certificate file. That's a 10 MB image.
Use Multi-Stage Builds
This is the single biggest win. Instead of one giant Dockerfile, split it into stages: a build stage with all the compilers and dev dependencies, and a runtime stage that only copies the artifacts.
Here's a typical Node example:
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
Notice how the runtime stage doesn't include the source code or dev dependencies. The final image only has what's needed to run.
For a Go app, it's even cleaner:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app .
FROM scratch
COPY --from=builder /app/app /app
CMD ["/app"]
Static linking means no libc, no shell, nothing extra. That's the dream.
Clean Up After Yourself
Package managers leave caches. apt-get install leaves /var/lib/apt/lists. npm install leaves ~/.npm. pip install leaves ~/.cache/pip. Always clean up in the same RUN layer to avoid bloating the image.
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
For pip:
RUN pip install --no-cache-dir -r requirements.txt
For npm, use npm ci --omit=dev and then npm cache clean --force.
Mind the .dockerignore
If you're not using a .dockerignore file, you're probably copying node_modules, .git, and other junk into your build context. That slows down builds and can accidentally leak secrets. Here's a minimal one:
node_modules
dist
.git
.env
*.log
Combine RUN Commands
Each RUN creates a new layer. Layers are cached, but every file that's deleted in a later layer still exists in the previous one. If you delete a temp file in a separate RUN, the image still contains that file in the layer below. So chain commands with && and clean up in the same layer.
Instead of:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
Do:
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
Use --squash (or Not)
Docker has an experimental --squash flag that merges all layers into one. It can help, but it also destroys layer caching and makes rebuilds slower. I rarely use it in CI. Instead, I rely on multi-stage builds and careful layer design. If you're desperate, you can use docker-slim or similar tools, but they're not magic.
Check What's Actually In There
After you build, inspect the image with docker history to see layer sizes:
docker history my-image:latest
You'll quickly spot the huge layers. Also use docker image inspect to see entrypoint, exposed ports, etc. For a deeper dive, dive is a great tool (though I can't link it here, just search for it). It shows you exactly what each layer adds and flags unused files.
Realistic Example
I once had a Python service that was 900 MB. After switching to python:slim, using multi-stage, and removing pip caches, it dropped to 180 MB. Then I moved to a custom build that only copied the virtualenv and the app code, and it went to 120 MB. Not bad for a service that uses pandas and numpy.
Final Thoughts
Image bloat isn't inevitable. It's a habit. Start with a small base, use multi-stage builds, clean caches, and ignore junk. Your CI will thank you, your registry costs will drop, and your deployments will feel snappy. Plus, fewer packages means fewer vulnerabilities to patch. It's a win on every axis.
Next time you're about to apt-get install something, ask yourself: do I really need this in the final image? Most of the time, the answer is no.
Top comments (0)