Writing a Dockerfile That Does Not Weigh 2GB


A 2GB Docker image isn’t a hypothetical. It’s what you get when you start from a full Ubuntu base image, install build tools, copy in your source code, install dependencies, and don’t think about cleanup. The resulting image deploys slowly, costs more to store, and has a larger attack surface than necessary.

Image optimization isn’t complicated - it’s a matter of understanding the model and applying a handful of consistent techniques.

Why Images Get Bloated

Each Dockerfile instruction that modifies the filesystem creates a layer. Layers accumulate in the final image even if subsequent instructions delete what earlier instructions created.

# This does NOT reduce the image size
RUN apt-get install -y build-tools  # Layer 1: +500MB
RUN rm -rf /usr/bin/gcc              # Layer 2: records a deletion, but Layer 1 still exists

Docker images are union filesystems. Layer 2 records the deletion on top of Layer 1, but Layer 1 is still there. The image contains both layers and the deletion in Layer 2 is just a whiteout file. Total size: still 500MB from the installation.

To actually reduce the image, cleanup must happen in the same instruction:

# This DOES reduce the image size
RUN apt-get install -y build-tools && rm -rf /var/lib/apt/lists/*

Choose the Right Base Image

The base image is the biggest lever. Starting from a smaller base starts you ahead.

BaseSizeNotes
ubuntu:22.04~78MBFull OS, many utilities
debian:bookworm-slim~75MBSlimmed Debian
alpine:3.19~7MBMinimal, musl libc
node:20~1.1GBFull Debian + Node.js
node:20-slim~250MBSlim Debian + Node.js
node:20-alpine~60MBAlpine + Node.js
gcr.io/distroless/nodejs20~105MBDistroless - no shell

Alpine is extremely small but uses musl libc instead of glibc. Some software doesn’t work on Alpine without recompilation. Test before committing to it.

Distroless images (from Google) have no shell, no package manager, and minimal OS utilities. This significantly reduces attack surface - if an attacker gets code execution, they have far less to work with. The tradeoff: debugging is harder because you can’t exec into the container and run commands.

For production Node.js images, node:20-alpine or node:20-slim are reasonable choices. For Python, python:3.12-slim-bookworm is a common starting point.

Multi-Stage Builds: The Most Impactful Technique

Multi-stage builds let you use a full build environment and produce a lean runtime image. The build stage has all the tools you need to compile or prepare your application. The final stage has only what’s needed to run it.

# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build  # Produces /app/dist

# Stage 2: production
FROM node:20-alpine AS production
WORKDIR /app
# Copy only the built artifacts and production dependencies
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev  # No devDependencies
USER node
CMD ["node", "dist/server.js"]

The final image doesn’t contain: TypeScript compiler, test dependencies, source files, or any other build-time artifact. Only the compiled output and production dependencies.

For a compiled language like Go:

# Stage 1: build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# Stage 2: minimal runtime
FROM scratch  # Empty base image
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/server"]

The Go binary is statically compiled (CGO_ENABLED=0) and the final image is built from scratch - literally empty. The entire image is the binary and the TLS certificates needed for HTTPS calls. Final size: ~10-15MB.

Layer Caching

Layer caching is why build order matters. Docker skips layers whose inputs haven’t changed. If layer N changes, all layers after N are rebuilt.

The pattern: put things that change infrequently first, things that change frequently last.

# BAD ORDER: source code (changes often) before dependencies (change rarely)
COPY . .
RUN npm ci

# GOOD ORDER: install dependencies first, they're cached between code changes
COPY package*.json ./
RUN npm ci
COPY . .  # This invalidates cache, but only for layers after this point

With the correct order, npm ci runs only when package.json or package-lock.json changes. On a typical development push where only source code changes, the dependency layer is served from cache - saving 30-120 seconds of install time.

The same principle applies to Python:

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .  # Source code after dependencies

Security: Don’t Run as Root

Docker containers run as root by default. A process running as root inside a container can do more damage if it’s compromised - writing to the filesystem, modifying configuration, potentially escalating privileges.

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set ownership
COPY --chown=appuser:appgroup . .

# Switch to non-root user before the CMD
USER appuser
CMD ["node", "server.js"]

Most official base images have a non-root user ready: node in the Node.js image, nobody in Alpine. Use them.

.dockerignore

The build context is everything sent to the Docker daemon for the build. Without a .dockerignore, this includes your entire project directory - node_modules, .git, test files, documentation.

node_modules
.git
*.md
.env
.env.*
coverage
.nyc_output
*.test.js
__tests__

A good .dockerignore reduces build context size (faster docker build), prevents secrets from ending up in images, and avoids cache invalidation from irrelevant file changes.

Checking Your Work

Inspect image layers:

docker history my-image:latest

Shows each layer, its size, and the instruction that created it. Large unexpected layers are visible immediately.

Dive is a third-party tool with a better UI for exploring layer contents:

dive my-image:latest

Scan for known vulnerabilities:

docker scout cves my-image:latest
# or
trivy image my-image:latest

Image size isn’t the only metric that matters - a small image with known critical vulnerabilities is worse than a slightly larger image that’s secure. The techniques here (smaller base, minimal layers, non-root user, multi-stage builds) improve both size and security simultaneously.



Read more