Building Slim Docker Images: From 1GB to 50MB
Practical techniques to reduce Docker image size by 90% or more. Covers multi-stage builds, Alpine and distroless bases, layer optimization, and dependency management.
What you'll learn
- ✓Why image size matters for security and performance
- ✓Multi-stage builds to exclude build-time dependencies
- ✓Choosing between Alpine, Debian slim, and distroless base images
- ✓Layer optimization techniques that compound into major savings
Prerequisites
- •Basic Dockerfile knowledge
- •Familiarity with at least one programming language build process
A bloated Docker image is more than an inconvenience. It means slower CI/CD pipelines, higher registry storage costs, longer deployment rollouts, and a bigger attack surface. A 1 GB Node.js image that could be 50 MB is wasting bandwidth on every pull across every node in your cluster. This guide walks through practical, tested techniques to dramatically reduce image size without sacrificing functionality.
Measuring Where the Bloat Is
Before optimizing, understand what makes your image large:
# Check image size
docker images my-app
# Inspect layer sizes
docker history my-app:latest
# Get detailed size breakdown
docker inspect my-app:latest --format='{{.Size}}'
The docker history command is the most useful. It shows each layer’s size:
IMAGE CREATED BY SIZE
a1b2c3d4 CMD ["node" "server.js"] 0B
e5f6g7h8 COPY . /app 15MB
i9j0k1l2 RUN npm install 350MB
m3n4o5p6 COPY package*.json ./ 150KB
q7r8s9t0 WORKDIR /app 0B
u1v2w3x4 FROM node:20 1.1GB
In this example, the base image alone is 1.1 GB and npm install adds another 350 MB of dependencies, including dev dependencies not needed at runtime.
Technique 1: Choose a Smaller Base Image
The single biggest win is switching your base image.
Base Image Size Comparison
| Base Image | Size | Use Case |
|---|---|---|
node:20 | ~1.1 GB | Full Debian with build tools |
node:20-slim | ~250 MB | Debian without extras |
node:20-alpine | ~140 MB | Alpine Linux, musl libc |
gcr.io/distroless/nodejs20 | ~130 MB | No shell, no package manager |
scratch | 0 B | Empty, for static binaries |
# Before: 1.1 GB base
FROM node:20
# After: 140 MB base
FROM node:20-alpine
For Python:
# Before: 1 GB+
FROM python:3.12
# After: ~150 MB
FROM python:3.12-slim
# After: ~80 MB
FROM python:3.12-alpine
Alpine Caveats
Alpine uses musl libc instead of glibc. Most applications work fine, but some native modules (especially Python packages with C extensions) may fail to compile. If you hit issues:
FROM python:3.12-alpine
RUN apk add --no-cache gcc musl-dev linux-headers
Or fall back to the slim variant, which uses glibc and has fewer compatibility issues.
Technique 2: Multi-Stage Builds
Multi-stage builds are the most impactful optimization technique. The idea is simple: use one stage to build your application and a separate stage to run it. Only the runtime artifacts get copied to the final image.
Node.js Multi-Stage Build
# Stage 1: Install dependencies and build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
The build stage has all dev dependencies (TypeScript compiler, bundlers, linters). The production stage only has the compiled output and production dependencies.
Python Multi-Stage Build
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=build /app .
USER nobody
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
The virtual environment from the build stage is copied directly into the production stage. Build tools like gcc and pip’s cache never appear in the final image.
Go Multi-Stage Build
Go benefits the most from multi-stage builds because it compiles to a static binary:
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
FROM scratch
COPY --from=build /server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
The final image is literally just the binary and SSL certificates. Total size: 10-20 MB depending on your application.
The -ldflags="-s -w" flag strips debug symbols and DWARF information, reducing binary size by 20-30%.
Rust Multi-Stage Build
FROM rust:1.78-alpine AS build
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
FROM scratch
COPY --from=build /app/target/release/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
The dummy build step caches dependency compilation. Only your source code rebuild runs on subsequent builds.
Technique 3: Optimize Layers
Every RUN, COPY, and ADD instruction creates a new layer. Combine related operations to reduce layer count and avoid storing intermediate files.
Combine RUN Instructions
# Bad: Creates 3 layers, intermediate files persist
RUN apt-get update
RUN apt-get install -y curl wget git
RUN apt-get clean
# Good: Single layer, cleanup in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl wget git && \
rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag prevents apt from pulling in suggested packages you do not need. The rm -rf /var/lib/apt/lists/* cleans the package cache. Both must be in the same RUN instruction to actually save space.
Order Layers by Change Frequency
Docker caches layers. Put infrequently changing layers first:
FROM node:20-alpine
WORKDIR /app
# Rarely changes - cached most of the time
COPY package*.json ./
RUN npm ci --omit=dev
# Changes frequently - but previous layers are cached
COPY . .
CMD ["node", "server.js"]
If you reverse this order and COPY . . first, every source code change invalidates the npm install cache.
Technique 4: Exclude Unnecessary Files
Use .dockerignore
A .dockerignore file prevents unnecessary files from entering the build context:
node_modules
.git
.gitignore
*.md
LICENSE
.env*
coverage
__tests__
test
.vscode
.idea
dist
build
.DS_Store
*.log
Without this, COPY . . pulls in everything, including your .git directory (which can be hundreds of MB), test files, and documentation.
Remove Caches and Temp Files
# Python: no pip cache
RUN pip install --no-cache-dir -r requirements.txt
# Node: clean npm cache
RUN npm ci && npm cache clean --force
# Alpine: no apk cache
RUN apk add --no-cache python3
# Debian: remove apt cache
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
make && make install && \
apt-get purge -y build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
Technique 5: Use Distroless Images
Google’s distroless images contain only your application and its runtime dependencies. No shell, no package manager, no utilities:
# Node.js
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=build /app /app
WORKDIR /app
CMD ["dist/index.js"]
# Python
FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /app
WORKDIR /app
CMD ["app/main.py"]
# Java
FROM gcr.io/distroless/java21-debian12
COPY --from=build /app/target/app.jar /app.jar
CMD ["app.jar"]
The security advantage is significant. With no shell, an attacker who exploits your application cannot drop into a shell, install tools, or pivot to other systems.
The debugging trade-off is real, however. You cannot exec into a distroless container. For debugging, use a debug variant:
# Has a busybox shell for debugging
FROM gcr.io/distroless/nodejs20-debian12:debug
Technique 6: Compress and Strip Binaries
For compiled languages, strip debug symbols:
# Go
go build -ldflags="-s -w" -o server .
# C/C++
gcc -O2 -s -o server server.c
# Rust
# In Cargo.toml
[profile.release]
strip = true
lto = true
opt-level = "z" # Optimize for size
Use UPX for further compression if startup time is not critical:
FROM alpine AS compress
RUN apk add --no-cache upx
COPY --from=build /server /server
RUN upx --best /server
FROM scratch
COPY --from=compress /server /server
ENTRYPOINT ["/server"]
UPX can reduce binary size by 50-70% but adds decompression time at startup.
Real-World Size Comparison
Here is what these techniques achieve for a typical Express.js API:
| Approach | Image Size |
|---|---|
FROM node:20 + npm install | 1.2 GB |
FROM node:20-slim + npm install | 380 MB |
FROM node:20-alpine + npm install | 270 MB |
| Multi-stage + Alpine + prod deps only | 95 MB |
| Multi-stage + distroless | 85 MB |
For a Go API:
| Approach | Image Size |
|---|---|
FROM golang:1.22 | 850 MB |
| Multi-stage + Alpine | 25 MB |
| Multi-stage + scratch + stripped binary | 8 MB |
| Multi-stage + scratch + UPX | 3 MB |
Wrapping Up
Reducing Docker image size is not about any single trick. It is the combination of choosing a minimal base image, using multi-stage builds to exclude build tools, optimizing layers to prevent cache waste, and stripping what you do not need. Start with the biggest wins first: switch to Alpine or slim, add a multi-stage build, and create a proper .dockerignore. These three changes alone typically reduce image size by 80% or more. Then refine from there with distroless bases, binary stripping, and layer optimization based on what your specific application needs.
Related articles
- Docker Docker Multi-Stage Builds: Advanced Optimization Techniques
Go beyond basic multi-stage builds. Learn cache mounts, parallel stages, and distroless bases to shrink Docker images by 90%.
- Docker Docker Multi-Stage Builds for Smaller Images
Use Docker multi-stage builds to ship tiny production images — build with full toolchains, copy only the artifacts. Examples for Node and Go, with .dockerignore and size comparisons.
- CI/CD Optimize Docker Builds in CI/CD Pipelines
Speed up Docker builds in CI/CD — multi-stage builds, layer caching, BuildKit, registry caching, and slim base images for faster deployments.
- DevOps Container Image Optimization Techniques
Reduce Docker image size and build time with multi-stage builds, layer caching, distroless bases, and practical Dockerfile patterns.