Container Image Optimization: Size, Build Speed, and Security
Large container images slow deployments, waste registry storage, and increase the attack surface. Most images have 80% of their layers that are never needed at runtime. Here's how to build minimal, fast, secure container images — distroless bases, multi-stage builds, layer caching, and vulnerability reduction.

The average Docker image for a Node.js application pulled from Docker Hub is 400–600MB. The Node.js runtime itself is about 100MB. The rest is build tools, package manager caches, OS utilities, and default OS packages that your application never touches at runtime.
Optimising container images is a compound improvement: smaller images pull faster (faster deployments, faster CI), have fewer packages to patch (smaller vulnerability surface), and consume less registry storage. A 500MB Node.js image optimised to 80MB with a distroless base is 6x smaller, faster, and has a fraction of the CVE exposure.
The Problem with Default Base Images
# Most common starting point — and the most problematic
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "src/index.js"]This image includes:
- Full Debian OS (~140MB) with all apt packages
- npm, yarn, npx package managers (not needed at runtime)
- Build tools (gcc, make, python) from the node:20 base
- All
devDependenciesfrom package.json - The entire source tree including test files, documentation, CI config
A node:20 base image starts at ~1GB. Add your application and dependencies and you're at 1.2GB. Your actual runtime need is: Node.js binary + production dependencies + your built code.
Multi-Stage Builds
Multi-stage builds solve the build-vs-runtime problem. The build stage has everything needed to compile/bundle/install. The runtime stage has only what's needed to run:
Node.js
1# Stage 1: Install dependencies and build
2FROM node:20-alpine AS builder
3WORKDIR /app
4COPY package.json package-lock.json ./
5RUN npm ci --include=dev # Install all deps for building
6COPY . .
7RUN npm run build # Compile TypeScript, bundle, etc.
8RUN npm prune --production # Remove devDependencies
9
10# Stage 2: Production runtime
11FROM node:20-alpine AS runtime
12WORKDIR /app
13ENV NODE_ENV=production
14# Create non-root user
15RUN addgroup -S app && adduser -S app -G app
16USER app
17# Copy only what the runtime needs
18COPY --from=builder --chown=app:app /app/node_modules ./node_modules
19COPY --from=builder --chown=app:app /app/dist ./dist
20COPY --from=builder --chown=app:app /app/package.json ./
21EXPOSE 8080
22CMD ["node", "dist/index.js"]Result: Builder is ~500MB. Runtime image is ~150MB (alpine + production node_modules + compiled output).
Go (near-zero runtime image)
Go compiles to a static binary — the ideal case for minimal images:
1# Stage 1: Build
2FROM golang:1.23-alpine AS builder
3WORKDIR /app
4COPY go.mod go.sum ./
5RUN go mod download # Cache dependencies separately
6COPY . .
7RUN CGO_ENABLED=0 GOOS=linux go build \
8 -ldflags="-w -s" \ # Strip debug symbols
9 -o /app/server ./cmd/server
10
11# Stage 2: Minimal runtime
12FROM gcr.io/distroless/static-debian12 AS runtime
13COPY --from=builder /app/server /server
14USER nonroot:nonroot
15EXPOSE 8080
16ENTRYPOINT ["/server"]CGO_ENABLED=0: Disables cgo, producing a fully static binary with no libc dependency.
-ldflags="-w -s": Strips debug info and symbol table — reduces binary size by 30–50%.
distroless/static: Base image with no OS packages, just CA certificates and timezone data. ~2MB.
Final image size: 10–20MB (the binary plus distroless base).
Python
1# Stage 1: Build with dependencies
2FROM python:3.12-slim AS builder
3WORKDIR /app
4RUN pip install uv # Fast Python package manager
5COPY pyproject.toml uv.lock ./
6RUN uv sync --frozen --no-dev # Install production dependencies only
7COPY src/ ./src/
8
9# Stage 2: Runtime
10FROM python:3.12-slim AS runtime
11WORKDIR /app
12ENV PYTHONDONTWRITEBYTECODE=1 \
13 PYTHONUNBUFFERED=1
14RUN addgroup --system app && adduser --system --group app
15USER app
16COPY --from=builder --chown=app:app /app/.venv ./.venv
17COPY --from=builder --chown=app:app /app/src ./src
18ENV PATH="/app/.venv/bin:$PATH"
19EXPOSE 8080
20CMD ["python", "-m", "src.main"]Java (Spring Boot)
1# Stage 1: Build
2FROM eclipse-temurin:21-jdk-alpine AS builder
3WORKDIR /app
4COPY mvnw pom.xml ./
5COPY .mvn .mvn
6RUN ./mvnw dependency:go-offline -q # Cache dependencies
7COPY src ./src
8RUN ./mvnw package -DskipTests -q
9
10# Stage 2: Extract layered JAR (Spring Boot 2.3+)
11FROM eclipse-temurin:21-jdk-alpine AS layers
12WORKDIR /app
13COPY --from=builder /app/target/*.jar app.jar
14RUN java -Djarmode=layertools -jar app.jar extract
15
16# Stage 3: Runtime with layered cache
17FROM eclipse-temurin:21-jre-alpine AS runtime
18WORKDIR /app
19RUN addgroup --system app && adduser --system --group app
20USER app
21COPY --from=layers /app/dependencies ./
22COPY --from=layers /app/spring-boot-loader ./
23COPY --from=layers /app/snapshot-dependencies ./
24COPY --from=layers /app/application ./
25EXPOSE 8080
26ENTRYPOINT ["java", \
27 "-XX:MaxRAMPercentage=75", \
28 "-XX:+UseG1GC", \
29 "org.springframework.boot.loader.launch.JarLauncher"]The layered JAR approach maximises Docker layer caching — the dependencies layer (rarely changes) is separate from the application layer (changes with every code change). Builds after the first are fast because only the application layer is rebuilt.
Distroless Base Images
Distroless images from Google (gcr.io/distroless/) contain only what's needed to run your application — no shell, no package manager, no OS utilities.
| Base Image | Size | Use For |
|---|---|---|
distroless/static-debian12 | ~2MB | Go static binaries, Rust binaries |
distroless/base-debian12 | ~20MB | Binaries with glibc dependency |
distroless/cc-debian12 | ~20MB | C/C++ applications |
distroless/python3-debian12 | ~50MB | Python applications |
distroless/java21-debian12 | ~220MB | Java 21 applications |
distroless/nodejs20-debian12 | ~110MB | Node.js applications |
No shell in the image means kubectl exec and docker exec won't give you a shell by default. For debugging, use ephemeral debug containers:
kubectl debug <pod-name> -n production -it \
--image=gcr.io/distroless/base-debian12:debug \ # debug tag includes busybox
--target=<container-name>The :debug variant includes busybox — a minimal shell and core utilities — without including the full OS toolchain.
Layer Caching
Docker layers are cached by content hash. Instructions that don't change use the cached layer. To maximise cache hits:
Copy dependency files before source code:
1# BAD — source code change invalidates the npm install cache
2COPY . .
3RUN npm install
4
5# GOOD — npm install cache only invalidates when package-lock.json changes
6COPY package.json package-lock.json ./
7RUN npm install
8COPY . .Order from least-to-most-frequently-changing:
# Order: system deps → app deps → source → build artifact
RUN apt-get install -y ... # rarely changes
COPY package.json . # changes when dependencies change
RUN npm install # invalidated by package.json change
COPY src/ . # changes frequently
RUN npm run buildBuildKit inline cache for CI:
# Push with cache metadata
docker buildx build \
--cache-from type=registry,ref=my-registry/myapp:cache \
--cache-to type=registry,ref=my-registry/myapp:cache,mode=max \
--push \
-t my-registry/myapp:$TAG .Registry-based cache lets CI runners restore the cache from a previous build even on a fresh runner.
Security: Vulnerability Reduction
Run as Non-Root
# Create and switch to non-root user
RUN addgroup --system app && adduser --system --group app
USER appRunning as non-root doesn't prevent container escape, but it contains the blast radius of a compromised process. A root process in a container that escapes the container boundary has more capability on the host than a non-root process would.
Read-Only Root Filesystem
# Declare in Dockerfile (informational)
VOLUME ["/tmp", "/var/run"] # Directories that need write access1# Enforce in Kubernetes pod spec
2securityContext:
3 readOnlyRootFilesystem: true
4volumeMounts:
5 - name: tmp
6 mountPath: /tmp
7volumes:
8 - name: tmp
9 emptyDir: {}A read-only root filesystem prevents an attacker from writing tools, modifying config files, or installing persistence mechanisms in the container's filesystem.
Vulnerability Scanning
1# Trivy: fast, comprehensive scanner
2trivy image --severity CRITICAL,HIGH my-org/myapp:v1.0.0
3
4# Fail CI on critical CVEs
5trivy image --exit-code 1 --severity CRITICAL my-org/myapp:v1.0.0
6
7# Scan Dockerfile for misconfigurations
8trivy config ./Dockerfile
9
10# Generate SARIF report for GitHub Security tab
11trivy image \
12 --format sarif \
13 --output trivy-results.sarif \
14 my-org/myapp:v1.0.0Integrate scanning into CI before the push to production registry:
1# GitHub Actions
2- name: Scan image
3 uses: aquasecurity/trivy-action@main
4 with:
5 image-ref: my-org/myapp:${{ github.sha }}
6 format: sarif
7 output: trivy-results.sarif
8 exit-code: '1'
9 severity: CRITICAL
10- name: Upload scan results
11 uses: github/codeql-action/upload-sarif@main
12 with:
13 sarif_file: trivy-results.sarifSBOM Generation
An SBOM (Software Bill of Materials) documents all packages in an image — prerequisite for tracking which images are affected by a newly disclosed CVE:
1# Generate SBOM with Syft
2syft my-org/myapp:v1.0.0 -o spdx-json > sbom.json
3
4# Attest SBOM to image with cosign
5cosign attest --predicate sbom.json --type spdx my-org/myapp:v1.0.0
6
7# Verify SBOM attestation
8cosign verify-attestation --type spdx my-org/myapp:v1.0.0With attested SBOMs, when CVE-2026-XXXX in openssl-3.0.7 is disclosed, you can query all images in your registry for the affected package:
# Find all images containing the vulnerable openssl version
grype db update
grype sbom:./sbom.jsonImage Signing
# Sign image with cosign (keyless signing via OIDC in CI)
cosign sign my-registry/myapp:v1.0.0
# Verify in Kubernetes via Kyverno policy
# (see Kubernetes Security Hardening guide for the full policy).dockerignore
Always include a .dockerignore to prevent the build context from including unnecessary files:
# Version control
.git
.gitignore
# Tests and docs
test/
tests/
__tests__/
*.test.ts
*.spec.ts
docs/
*.md
README*
# CI/CD
.github/
.gitlab-ci.yml
Jenkinsfile
# Local development
.env
.env.*
docker-compose*.yml
*.local
# Build artifacts (from local dev)
dist/
build/
target/
node_modules/ # Will be installed fresh in the container
# IDE
.vscode/
.idea/
*.swp
A large .git directory in the build context is the most common cause of "why is my build context 500MB?" — the git history isn't needed in the image and takes time to transfer to the daemon.
Image Size Benchmarks
Typical optimised vs unoptimised sizes:
| Language | Unoptimised | Optimised | Method |
|---|---|---|---|
| Node.js (TS) | 1.2GB | 150MB | Multi-stage + alpine + prod deps only |
| Go | 800MB | 15MB | Multi-stage + distroless/static |
| Python | 600MB | 120MB | Multi-stage + slim + venv |
| Java (Spring Boot) | 700MB | 280MB | Multi-stage + layered JAR + JRE |
| Rust | 2GB (build) | 12MB | Multi-stage + distroless/static |
Go and Rust are the best cases for minimal images — static binaries in distroless containers. Java is the hardest case — the JVM itself is large, though layered JARs minimise rebuild cost.
Frequently Asked Questions
Should I use Alpine or Debian slim as a base?
Alpine (musl libc) is smaller (~5MB) but causes compatibility issues with packages that assume glibc. debian:slim or distroless/base uses glibc, which is more compatible with C extension modules (Python, Node native modules, Ruby gems). For Go static binaries with CGO_ENABLED=0, use distroless/static — it has no libc at all. For everything else, prefer Debian slim over Alpine unless you're sure your dependencies support musl.
How do I debug a distroless container in production?
1# Add a debug sidecar without restarting the pod
2kubectl debug <pod-name> -n production -it \
3 --image=busybox \
4 --target=<container-name>
5
6# The debug container shares the target container's namespaces
7# ls /proc/1/root shows the target container's filesystemShould I pin base image versions?
Yes — for security reproducibility. node:20 changes when new patches are applied; node:20.17.0 is specific. For production, pin to a specific patch version and update intentionally:
FROM node:20.17.0-alpine3.20Use Renovate or Dependabot to automatically propose base image updates with the changelog diff. Unpinned base images mean your production images can change content without a code change — a supply chain risk.
How do I handle secrets needed during the build (npm token, pip index)?
Never put secrets in the Dockerfile. Use BuildKit --secret mounts:
# Mount secret only during the RUN step — not baked into the layer
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm cidocker buildx build \
--secret id=npm_token,env=NPM_TOKEN \
.The secret is available inside the build step but doesn't appear in the image layers or build cache.
For supply chain security controls (image signing, SBOM attestation, Kyverno enforcement), see Kubernetes Security Hardening: A Production Checklist. For CI/CD pipeline security, see The Claude Code Source Leak: A Case Study in CI/CD Safety.
Building a secure container image pipeline? Talk to us at Coding Protocols — we help platform teams design build processes that produce minimal, signed, scanned images by default.


