Skip to content
Containers

Container Image Size: From 1.2 GB to 80 MB (Real Recipes)

Cut Docker image size 80-90%. Multi-stage builds, distroless, scratch — with copy-paste recipes for Node, Go, Python, Java, Rust, and .NET.

A
Abhishek Patel10 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

Container Image Size: From 1.2 GB to 80 MB (Real Recipes)
Container Image Size: From 1.2 GB to 80 MB (Real Recipes)

Container Image Size: Real Recipes That Cut 90% Off

An 80 MB container pulls in 3-5 seconds; a 1.2 GB container pulls in 15-40 seconds. That difference compounds across every cold start of every Fargate task, every Cloud Run instance, every Kubernetes pod scheduled to a node that doesn't have your image cached. The optimizations that get you there aren't exotic — they're well-known patterns most teams just haven't applied. Multi-stage builds, distroless or scratch base images, and aggressive .dockerignore handle 80% of the wins. The remaining 20% comes from language-specific tricks (Go static binaries, Rust musl, Java jlink, Python pyc-only). The recipes below cut typical images from 1.2 GB to 80-150 MB without losing functionality.

StackNaive imageOptimizedReductionCold-pull time gain
Node.js / Express950 MB130 MB (distroless)86%30s → 4s
Go web service820 MB15 MB (scratch + static)98%25s → 1s
Python / Flask1.1 GB180 MB (distroless)84%35s → 6s
Java Spring Boot720 MB95 MB (jlink + jre-headless)87%22s → 3s
Rust Actix650 MB10 MB (scratch + musl)98%20s → 1s
.NET 9 minimal API520 MB60 MB (chiseled)88%16s → 2s

Last updated: April 2026 — verified against Node 22 LTS, Python 3.13, Go 1.24, Rust 1.85, JDK 23, .NET 9. Base image sizes shift slightly between point releases; the patterns hold.

The Three Universal Patterns

Before per-language recipes, three patterns work for every stack:

1. Multi-stage builds

Build in one stage, ship from another. Compilers, build tools, and dev dependencies stay in the build stage; only the compiled artifact copies forward. The multi-stage builds explainer covers the mechanics in detail.

2. Distroless or scratch base images

Distroless (Google) ships a minimal runtime — no shell, no package manager, no busybox. gcr.io/distroless/nodejs22-debian12 is ~50 MB compared to node:22's 380 MB. Scratch (an empty image) works for static binaries — the absolute floor at 0 MB before your binary. The trade-off: no shell means harder debugging (use :debug tags or kubectl ephemeral containers).

3. Aggressive .dockerignore

The single highest-impact one-liner: a .dockerignore file in your repo root that excludes .git, node_modules, .venv, target, build artifacts, and IDE config. Forgetting .git alone can add 200+ MB on mature repos:

# .dockerignore
.git
.gitignore
node_modules
.venv
target
dist
build
*.log
.env*
.idea
.vscode
.DS_Store
__pycache__
*.pyc
coverage/
.next

Recipe: Node.js / Express (950 MB → 130 MB)

FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production --no-audit --no-fund && \
    npm cache clean --force

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["dist/server.js"]

Three moves: separate deps stage installs only production dependencies, build stage compiles TypeScript or bundles, final stage uses distroless. Ship size: ~130 MB for a typical Express + TypeScript project.

Why not node:22-alpine?

Alpine works (~180 MB final image) but uses musl libc instead of glibc, which breaks native modules that compile against glibc. Better-sqlite3, sharp, canvas, native crypto bindings — all routinely break on Alpine. Distroless on Debian glibc is the safer default. If you're sure your dependencies are pure-JS or musl-compatible, Alpine wins another 50 MB.

Recipe: Go (820 MB → 15 MB)

Go compiles to static binaries — no runtime needed. The result is the smallest practical container in any language:

FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o /app/server ./cmd/server

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app/server /server
EXPOSE 8080
CMD ["/server"]

The -ldflags="-s -w" strips debug symbols, shrinking the binary 20-30%. CGO_ENABLED=0 guarantees static linking. The FROM scratch base is empty — only your binary, the SSL certs (needed for HTTPS calls), and nothing else. Result: 8-15 MB images for typical microservices.

The TLS cert gotcha

scratch images don't include CA certificates, so any HTTPS call fails with "x509: failed to load system roots". Copy them from the build stage as shown above, or use distroless static (gcr.io/distroless/static-debian12) which includes them.

Recipe: Python / Flask (1.1 GB → 180 MB)

Python is the trickiest stack to optimize because the runtime + standard library are heavy and many libraries have C extensions. The right pattern:

FROM python:3.13-slim-bookworm AS build
WORKDIR /app
ENV PIP_NO_CACHE_DIR=1 PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential gcc && \
    rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --user -r requirements.txt

FROM gcr.io/distroless/python3-debian12
WORKDIR /app
ENV PYTHONPATH=/root/.local/lib/python3.13/site-packages
COPY --from=build /root/.local /root/.local
COPY . .
EXPOSE 5000
CMD ["app.py"]

The uv accelerator

If you've moved to uv for Python packaging, replace pip install with uv pip install — installs are 10-20x faster, which matters for CI build time even though the final image size is identical. The Python uv comparison covers the speed deltas.

Compile-time vs runtime

The build-essential + gcc install is needed only because some Python packages (numpy, scipy, certain ML libs) compile C extensions during install. Multi-stage avoids shipping those tools. If you're sure all your dependencies have prebuilt wheels for your platform, you can skip the build stage entirely and use the slim base directly.

Recipe: Java Spring Boot (720 MB → 95 MB)

FROM eclipse-temurin:23-jdk AS build
WORKDIR /app
COPY pom.xml ./
COPY src ./src
RUN ./mvnw -B package -DskipTests && \
    jdeps --print-module-deps --ignore-missing-deps target/*.jar > /modules.txt
RUN jlink \
    --add-modules $(cat /modules.txt) \
    --strip-debug --no-man-pages --no-header-files \
    --compress=2 \
    --output /custom-jre

FROM debian:bookworm-slim
WORKDIR /app
COPY --from=build /custom-jre /opt/jre
COPY --from=build /app/target/*.jar /app/app.jar
ENV PATH="/opt/jre/bin:${PATH}"
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]

The magic is jlink — it builds a custom JRE containing only the modules your app actually uses. A typical Spring Boot app needs maybe 25 modules out of the JDK's 75+; the custom JRE drops from 200 MB to 50 MB. Pair with --strip-debug --compress=2 for further shrinkage. For runtimes deployment guidance covers when small images matter most.

Recipe: Rust (650 MB → 10 MB)

Rust compiles to native binaries; with musl + scratch, you get the smallest containers possible:

FROM rust:1.85-bookworm AS build
WORKDIR /src
RUN rustup target add x86_64-unknown-linux-musl && \
    apt-get update && apt-get install -y musl-tools
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=build /src/target/x86_64-unknown-linux-musl/release/server /server
EXPOSE 8080
CMD ["/server"]

10-15 MB images for typical Rust web services. The musl target produces a fully static binary that runs on scratch.

Recipe: .NET 9 (520 MB → 60 MB)

Microsoft ships "chiseled" Ubuntu base images specifically for .NET — minimal Ubuntu with no shell, no package manager:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish \
    /p:PublishSingleFile=true /p:SelfContained=false

FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["./MyApp"]

Why Image Size Compounds at Scale

The cold-pull time difference between a 1 GB image and a 100 MB image is roughly 10x — about 30 seconds vs 3 seconds on typical cloud network. That seems trivial until you multiply:

  • Fargate cold starts: every scale-out event waits for image pull. 30s vs 3s changes user experience.
  • Cloud Run rapid scale: serverless containers cycle frequently. A 1.2 GB image with 1000 cold starts/day = 8.3 hours of pull time you're paying for.
  • Kubernetes node provisioning: bigger images = slower pod-ready signal = slower scaling response. Hits SLA on bursty workloads.
  • CI build pipelines: every test environment, every preview deploy, every PR pulls the image. Smaller = faster pipelines = faster developer feedback.

The serverless vs containers comparison covers when image size becomes a deciding factor between deployment models.

Pro tip: For multi-platform images (linux/amd64 + linux/arm64), build with docker buildx build --platform linux/amd64,linux/arm64. ARM64 images on Graviton instances or Apple Silicon dev boxes hit native speed. The size optimization tricks above all work cross-platform; multi-arch just adds variety.

Security Bonus: Smaller Images = Smaller Attack Surface

Image size optimization isn't just about pull time. Distroless and scratch images dramatically reduce attack surface:

  • No shell: an attacker who gets RCE can't bash -c their way around. Forces them to use only what your binary supports.
  • No package manager: can't apt install additional tools post-compromise.
  • Fewer CVEs: a 1 GB Ubuntu base ships with hundreds of packages, each contributing potential CVEs. Distroless ships with maybe 20-30 packages.

Container security hardening covers this in depth — image minimization is one of the highest-leverage security wins available. The advanced patterns I deploy in production (chiseled-style for non-Microsoft stacks, Wolfi-based images, sigstore signing) I send to the newsletter.

Frequently Asked Questions

How do I reduce Docker image size?

Three universal patterns: (1) multi-stage builds — build in one stage, ship from another, dropping compilers and dev dependencies. (2) Use distroless or scratch base images — distroless ~50 MB, scratch ~0 MB before your binary. (3) Aggressive .dockerignore — exclude .git, node_modules, .venv. Together these cut typical images 80-90%, from 1+ GB to 80-200 MB.

What is a distroless Docker image?

Distroless (from Google) is a minimal base image with no shell, no package manager, no busybox — just the runtime your app needs. gcr.io/distroless/nodejs22-debian12 is ~50 MB vs node:22's 380 MB. Trade-off: harder debugging since you can't docker exec a shell. Use the :debug tags or kubectl ephemeral containers for troubleshooting.

Should I use Alpine or Debian for Docker images?

Debian slim or distroless are safer defaults. Alpine uses musl libc which breaks native modules compiled against glibc — better-sqlite3, sharp, canvas, many ML libraries fail on Alpine. Alpine wins ~50 MB on size but at compatibility cost. Use Alpine only when you've verified all dependencies work with musl.

What is FROM scratch in Docker?

FROM scratch is an empty base image — no OS, no shell, nothing. Used for static binaries (Go, Rust with musl) where the binary is fully self-contained. Result: 8-15 MB images. Caveat: scratch doesn't include CA certificates (HTTPS calls fail), DNS resolution config, or timezone data — copy these from a build stage if needed.

How small can a Docker image be?

For static-binary languages (Go, Rust musl), 5-15 MB final images are routine using FROM scratch. For Node.js / Python with full runtime, 80-180 MB with distroless is the practical floor. Java with jlink hits 95-150 MB. .NET chiseled hits 60-90 MB. Below these, you're either building unusable images or using exotic patterns that break compatibility.

Does smaller image size matter?

Yes — image pull time is part of every cold start. 1.2 GB pulls in 15-40s; 100 MB pulls in 3-5s. On Fargate / Cloud Run / Kubernetes scale-out events, that compounds across every new instance. Plus security: smaller images expose fewer CVEs and limit post-compromise tooling. The size optimization is one of the highest-leverage Docker improvements available.

How does multi-stage build reduce image size?

Multi-stage builds let you compile or build in one stage with full tooling (compilers, dev dependencies, build tools) and copy only the final artifact into a clean stage that ships. The build-stage tooling never reaches the final image. Typical savings: 60-80% off the naive single-stage image, since compilers and dev deps dominate size.

The Single Highest-Impact Image Improvement

If you implement nothing else from this article, do two things: add a thorough .dockerignore (excluding .git alone often saves 200+ MB on mature repos) and switch your final stage to a distroless or scratch base. Those two changes typically cut image size by 70-85% with no functional impact. The per-language tricks above squeeze the remaining 10-15% but the universal patterns get you most of the way there. Smaller images = faster deploys = faster scaling = better security = lower bandwidth costs. There's no good argument for shipping unoptimized images in 2026.

A

Written by

Abhishek Patel

Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.

Related Articles

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.