Back to posts
Post

Shrink Docker Image Size Up to 10x With Multi-Stage Builds

Reduce Docker image size up to 10x using multi-stage builds and Alpine-based images. Practical Dockerfile patterns, commands, and pitfalls from real ops work.

DevopsDockerMulti-stage BuildAlpine LinuxContainer OptimizationDockerfile

Docker image boyutu küçültme is one of the most impactful things you can do for build times, registry storage, and deployment speed. In my environment, I regularly take 1.2 GB images down to 120 MB — sometimes even smaller — without changing app functionality. The two biggest levers are multi-stage builds and switching to Alpine-based images. Everything else (layer ordering, .dockerignore, cleaning cache) helps, but those two are where the real savings live.

If you've been pulling fat Ubuntu-based images into production and wondering why your registry is bloating, this is the fix.

Why Your Docker Image Is Too Large

Most bloated images come from the same pattern: you start with ubuntu:22.04 or node:18, install build tools and compilers, copy your source, run npm install or pip install, and ship the whole thing. The problem is that your final image carries everything — the compiler, the build cache, the dev dependencies, the apt cache, even man pages nobody reads.

Here's a typical fat Dockerfile I see in e-commerce projects:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

This produces an image around 1.1–1.3 GB. The app itself is maybe 50 MB. The rest is dead weight.

The node:18 base image alone is ~900 MB because it includes a full Debian installation, build-essential, and every system library Node might ever need. Your node_modules folder adds another 200–400 MB, including dev dependencies that only exist for testing.

Multi-Stage Builds: The Single Biggest Win

Multi-stage builds let you use one image for building and a completely different (smaller) image for running. The build stage compiles your code and produces artifacts. The final stage copies only those artifacts into a minimal base image. Everything from the build stage gets discarded.

Here's the same Node app rewritten with multi-stage:

# Build stage
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build

# Final stage
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
CMD ["node", "dist/server.js"]

This alone drops the image from ~1.2 GB to ~180 MB. The trick is that the final stage never sees the build tools, the full Node SDK, or the source files — only the compiled output and production node_modules.

A few things I've learned the hard way:

  • Always copy package*.json separately and run install before copying source. Docker caches the layer, so rebuilds skip npm ci when only your code changes.
  • Use npm ci instead of npm install in CI. It's faster, deterministic, and respects the lockfile.
  • If you have native dependencies, you may need build tools in the Alpine stage too. Use apk add --no-cache python3 make g++ in the builder stage.

Switching to Alpine-Based Images

Alpine Linux is built around musl libc and busybox, which makes it tiny — the base image is ~5 MB compared to Debian's ~80 MB. For most interpreted languages (Node, Python, Go), Alpine works great as a final stage.

Here's a Python example:

FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.11-alpine
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]

The python:3.11-slim builder gives you a full environment for compiling C extensions, while the Alpine final stage stays around 50–70 MB.

Warning: Alpine uses musl libc, not glibc. Some Python packages with pre-built wheels assume glibc and may fail or need recompilation. If you hit musl import errors, either build from source in the Alpine stage or use python:3.11-slim for both stages. Test thoroughly before pushing to production.

Clean Up Inside Each Layer

Even with multi-stage builds, you can still bloat individual layers. Package managers cache downloaded files by default. Always clean up in the same RUN instruction:

RUN apk add --no-cache curl && \
    curl -sSL https://example.com/tool.tar.gz | tar xz && \
    rm -rf /var/cache/apk/* tool.tar.gz

If you run apk add in one layer and rm in another, Docker keeps both layers. The cache file lives in an intermediate layer even though it's deleted in the final one.

For apt-based images:

RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

The --no-install-recommends flag alone saves 20–40 MB by skipping recommended-but-not-required packages.

Use a .dockerignore File

This one is easy to miss. Without a .dockerignore, your COPY . . statement copies everything in your project directory — including .git, node_modules, test fixtures, log files, and your IDE config.

Here's a .dockerignore I use for most Node projects:

node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
tests
*.md
coverage
.vscode
.idea

This can shave 50–200 MB off your build context, which also speeds up the docker build step itself since Docker doesn't have to send all that junk to the daemon.

Go Binaries: The Ultimate Size Reduction

If you're building Go services, you can get absurdly small images. Go compiles to a static binary, so the final stage doesn't even need an OS:

FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .

FROM scratch
COPY --from=builder /app/app /app
CMD ["/app"]

The -s -w flags strip debug symbols and DWARF tables. The scratch base image is literally empty — your final image is just the binary, usually 10–20 MB.

Note: scratch has no shell, no /tmp, and no CA certificates. If your app makes HTTPS calls, you need to copy /etc/ssl/certs/ca-certificates.crt from the builder stage. If your app writes temp files, create /tmp manually.

Measuring and Verifying Image Size

Don't guess — measure. After every Dockerfile change, check the actual size:

docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | grep myapp

For a deeper breakdown of what's inside each layer, use dive:

dive myapp:latest

dive shows you layer by layer what files were added, modified, or deleted. It's the fastest way to find that one layer where you accidentally copied a 300 MB log file.

As I mentioned before in my GitOps post (https://furkanikkan.com/urun/gitops-ily-altyapi-yonetimi-manuel-deploy-lara-son-58), smaller images also mean faster pull times on your CI/CD runners and production nodes — which directly affects your deployment frequency and rollback speed.

Common Pitfalls to Avoid

  • Don't use latest tags for base images. A rebuild six months later pulls a different base and may break your app. Pin versions: node:18.19.0-alpine3.19.
  • Don't forget --no-cache for package managers. apk add --no-cache and pip install --no-cache-dir prevent cache files from ending up in your image.
  • Don't run as root. It doesn't affect size, but it's a security baseline. Add RUN adduser -D appuser && USER appuser in your final stage.
  • Don't mix dev and production dependencies. If you need devDependencies for testing, do it in the builder stage and copy only production node_modules to the final image.

Quick Checklist

Here's my routine when optimizing a new Dockerfile:

  1. Switch base image to Alpine or slim variant
  2. Add a multi-stage build (builder + final)
  3. Copy dependency manifests and install before copying source
  4. Add a .dockerignore file
  5. Clean package manager cache in the same RUN layer
  6. Pin all base image versions
  7. Measure with docker images or dive

Going from 1.2 GB to 120 MB is realistic for most web apps. For compiled languages like Go, you can hit single-digit megabytes. The build takes a bit more thought upfront, but every pull, every deploy, and every registry invoice benefits from it.


Cover image: HD Wallpapers · CC0 (Openverse / kamu malı) · https://stocksnap.io/photo/light-abstract-V9L6XXK3LB