How to Reduce Docker Image Size
Multi-stage builds, minimal base images, .dockerignore, and BuildKit cache mounts routinely shrink production images from 1-2 GB down to 50-200 MB.
Temps Team
June 6, 2026 · 2mo ago
The fastest way to reduce Docker image size is to combine multi-stage builds, a minimal base image (Alpine or distroless), a .dockerignore file, and BuildKit cache mounts. Together these techniques routinely shrink production images from 1–2 GB down to 50–200 MB — without changing application behavior.
Docker image size directly affects how fast your deployments go. A 1.2 GB image takes 3–4 minutes to push to a registry and pull onto a new node. A 120 MB image takes under 20 seconds. Multiply that by every deployment, every auto-scaling event, and every new worker node, and the savings compound fast.
This guide walks through each technique with working Dockerfiles, explains why each one matters, and covers how modern deployment platforms like Temps handle build caching automatically so you get fast deploys without manual optimization on every project.
How Do You Reduce Docker Image Size?
The core problem is that most default Dockerfiles include build tools, development dependencies, source code, and package manager caches that have no place in a production image. The fix is a combination of five practices applied in order:
- Multi-stage builds — separate build environment from runtime image
- Minimal base images — choose Alpine or distroless over full Debian/Ubuntu
.dockerignorefile — exclude files that don't belong in the image- Layer caching discipline — order instructions from least to most frequently changed
- BuildKit cache mounts — avoid re-downloading packages on every build
Each is covered below with real examples.
1. Multi-Stage Builds
Multi-stage builds are the single biggest lever for reducing Docker image size. The idea is simple: use one stage to compile and build, then copy only the production artifacts into a minimal final image. The build tools, intermediate files, and dev dependencies never touch the final image.
Node.js Example
A naive single-stage Dockerfile for a Node.js app includes the full Node runtime, all node_modules (including devDependencies), and the TypeScript compiler:
# Bad: single stage includes everything
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
This produces an image around 1.1 GB. Here is the multi-stage equivalent:
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=dev
COPY . .
RUN npm run build
# Stage 2: production runtime only
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
Result: approximately 180 MB. The build tools and TypeScript compiler are gone. Only the compiled output and production node_modules remain.
Go Example
Go compiles to a static binary, which means the runtime image can be extremely minimal:
# Stage 1: compile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Stage 2: minimal runtime
FROM gcr.io/distroless/static:nonroot AS runner
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The distroless/static base image is 2 MB. The final image for most Go services is 10–20 MB. No shell, no package manager, no OS packages — just the binary and its dependencies.
Python Example
Python is harder to strip down, but multi-stage still helps significantly:
# Stage 1: install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/packages -r requirements.txt
# Stage 2: production image
FROM python:3.12-slim AS runner
WORKDIR /app
COPY --from=builder /app/packages ./packages
COPY . .
ENV PYTHONPATH=/app/packages
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Rust Example
# Stage 1: compile (includes full Rust toolchain)
FROM rust:1.85-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
# Stage 2: minimal runtime
FROM alpine:3.20 AS runner
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/target/release/server /usr/local/bin/server
USER appuser
EXPOSE 8080
ENTRYPOINT ["server"]
The rust:1.85 image is about 1.4 GB. The final Alpine image with only the compiled binary is around 20 MB.
2. Choose the Right Base Image
Base image choice has a dramatic effect on final size:
| Base Image | Compressed Size | Use Case |
|---|---|---|
ubuntu:22.04 | ~29 MB (compressed), ~77 MB on disk | Legacy apps needing glibc utilities |
debian:bookworm-slim | ~31 MB compressed | General purpose, good glibc compatibility |
alpine:3.20 | ~3.5 MB compressed | Most statically-linked or musl-compatible apps |
gcr.io/distroless/static | ~2 MB | Go, Rust static binaries (no shell, no OS) |
gcr.io/distroless/base | ~20 MB | Apps needing glibc but nothing else |
node:20-alpine vs node:20 | ~48 MB vs ~144 MB | Node.js apps where full Debian is not needed |
Alpine is the right default for most apps. The main caveat is that Alpine uses musl libc instead of glibc. Libraries that bundle precompiled binaries (some Python wheels, some Node native addons) may not work on Alpine without additional compilation steps. Use debian:slim or debian:bookworm-slim as a fallback when Alpine causes issues.
Distroless is the right choice when security is paramount. No shell means no shell injection vectors. No package manager means no apt install in a compromised container. The downside is harder debugging — you cannot exec into the container and run commands.
3. Write a .dockerignore File
Every file you don't exclude ends up in the build context that Docker sends to the daemon before building. That slows builds and risks including files that inflate image size.
A .dockerignore file works exactly like .gitignore. Create it in the same directory as your Dockerfile:
# Version control
.git
.gitignore
.github
# Node.js
node_modules
npm-debug.log
.npm
# Build outputs (if managing separately)
dist
build
.next
out
# Development files
*.test.ts
*.spec.ts
*.test.js
*.spec.js
__tests__
coverage
.nyc_output
# Environment files
.env
.env.local
.env.*.local
# Editor and OS files
.DS_Store
.vscode
.idea
*.swp
*.swo
Thumbs.db
# Docker files themselves
Dockerfile
.dockerignore
docker-compose*.yml
Without .dockerignore, a typical Node.js project sends 400–800 MB of node_modules to the Docker daemon before the build even starts. With it, the build context drops to 1–5 MB.
4. Order Layers for Maximum Cache Reuse
Docker builds each instruction as a layer and caches it. When a layer changes, all subsequent layers are invalidated. This means instruction order directly affects how often the cache is used.
The rule is: copy files that change rarely before files that change frequently.
# Good: dependencies layer (rarely changes) before application code (changes every commit)
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # rarely changes
RUN npm ci --only=production # cached unless dependencies change
COPY src ./src # changes on every commit
RUN npm run build
CMD ["node", "dist/server.js"]
# Bad: COPY . . invalidates npm install cache on every change to any file
FROM node:20-alpine
WORKDIR /app
COPY . . # any change invalidates everything below
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
The practical impact: with correct ordering, a npm install that takes 90 seconds runs once and is cached for every subsequent deploy that doesn't change package.json. With incorrect ordering, it runs on every build.
5. BuildKit Cache Mounts
BuildKit cache mounts (--mount=type=cache) let package managers write to a persistent cache directory that survives between builds without becoming part of the image layer. This is separate from Docker's layer cache — it persists even when you change package.json.
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# Python with pip cache
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Go with module cache
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /app/server ./cmd/server
The # syntax=docker/dockerfile:1 comment at the top is required to enable BuildKit features. Enable BuildKit by setting DOCKER_BUILDKIT=1 or using docker buildx build instead of docker build.
How Much Can You Actually Save?
Here are real-world size reductions from applying all five techniques:
| App Type | Naive Image | Optimized Image | Reduction |
|---|---|---|---|
| Node.js (TypeScript API) | 1.1 GB | 160 MB | 85% |
| Python (FastAPI) | 890 MB | 145 MB | 84% |
| Go (API server) | 380 MB | 18 MB | 95% |
| Rust (web service) | 1.4 GB | 22 MB | 98% |
| Next.js (standalone output) | 1.3 GB | 220 MB | 83% |
Results vary based on your dependency tree and whether you're using Alpine-compatible packages, but 80–95% reductions are typical when moving from a naive Dockerfile to multi-stage + Alpine + distroless.
Measuring Image Size
Before and after optimization, measure actual image sizes:
# List images with sizes
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# Inspect layer breakdown
docker image history my-app:latest
# Detailed layer analysis with dive (install separately)
dive my-app:latest
The dive tool gives you a layer-by-layer breakdown of what's consuming space and what percentage of each layer is wasted (files added then deleted in a later layer).
Platforms That Handle Build Optimization Automatically
If you use a deployment platform, you may get some of these optimizations for free.
Temps uses Nixpacks as its default build system. Nixpacks automatically detects your project type (Node.js, Python, Go, Rust, and 20+ other languages) and generates an optimized build configuration — no Dockerfile required. If you include your own Dockerfile, Temps builds it as-is using BuildKit.
For security, Temps runs Trivy 0.58.1 to scan every deployed image for known CVEs immediately after deployment and on a daily schedule at midnight UTC. Critical and High severity findings generate alerts — so you know when a base image update is needed before it becomes a breach.
On the cost side, self-hosting Temps is free (Apache 2.0 / MIT dual license) — the only cost is the server you already pay for. Temps Cloud, a coming-soon managed add-on for telemetry retention, offsite backups, and AI credits, is not priced here.
Quick Reference Checklist
Apply these in order when optimizing a Dockerfile:
[ ] Multi-stage build: build tools in stage 1, artifacts in stage 2
[ ] Minimal base image: Alpine for most, distroless for Go/Rust static binaries
[ ] .dockerignore: excludes node_modules, .git, test files, .env, editor files
[ ] Layer order: COPY package.json → RUN install → COPY source → RUN build
[ ] BuildKit cache mounts: --mount=type=cache for npm, pip, Go module caches
[ ] Verify: docker images shows final image ≤ 200 MB for most web apps
Frequently Asked Questions
Does Alpine cause compatibility issues?
Sometimes. The main risk is native modules or precompiled binaries that bundle glibc. Node.js native addons (like bcrypt, sharp, some database drivers) often ship glibc-linked binaries that fail on Alpine's musl libc. The fix is either to rebuild them from source (add RUN apk add --no-cache python3 make g++ before npm install) or switch to debian:bookworm-slim as the base. Debian slim gives you 80–90% of Alpine's savings without the compatibility risk.
Can I use distroless without a shell for debugging?
Yes — gcr.io/distroless/base:debug includes a busybox shell. Use the debug variant in non-production environments or when you need to diagnose a failing container.
Should I pin base image versions?
Yes. Using alpine:latest means a rebuild today might produce a different image than a rebuild in six months. Pin to a specific version (alpine:3.20) and update on a schedule. Your vulnerability scanner will tell you when the pinned version has unpatched CVEs that require an update.
What about squashing layers?
docker build --squash merges all layers into one, which can reduce size when earlier layers create files that later layers delete. BuildKit's approach is cleaner: avoid creating files in early layers that you delete in later ones. If you catch yourself running RUN apt-get install && do stuff && apt-get clean in a single RUN command to avoid wasted layers — that is the right approach.
Does image size affect registry storage costs?
Yes. Most container registries (Docker Hub, GitHub Container Registry, AWS ECR, Google Artifact Registry) charge per GB stored per month. A fleet of 20 services each at 1 GB generates 60–80 GB of registry storage (current + a few historical tags). The same fleet at 150 MB per image is 9–12 GB. At typical registry pricing of $0.10–$0.25/GB-month, that is a $5–$17/month difference — not huge, but it adds up across multiple projects over years.
Get weekly updates