Docker images in the wild easily balloon past 1 GB. A python:3.12
base with your app and a few pip packages weighs over a gigabyte. The
same Go binary compiled from scratch fits in 6 MB. The difference is
not the code — it is everything the compiler left behind.
Multi-stage builds let you use a full build environment with compilers, dev headers, and debug tools in one stage, then copy only the compiled artifact to a minimal runtime stage. The build tools vanish from the final image. Your homelab disk, registry storage, and deployment bandwidth all benefit from the size reduction.
This guide covers multi-stage build patterns for the three languages you are most likely to use in homelab automation — Go, Python, and Node.js — with real Dockerfiles, cache strategies, and security hardening you can apply today.
Why Multi-Stage Builds Matter in a Homelab
A single fat image might not seem like a problem when you have a terabyte of storage. But consider the compounding effect:
- Registry storage: Five services, each rebuilt weekly with a 1 GB image means 5 GB of layers retained per version
- Backup bandwidth: Offsite backups of Docker volumes include the
image cache unless you exclude
/var/lib/docker - Pull time: A 50 MB image pulls in under 2 seconds on a gigabit link versus 30+ seconds for a 1 GB image
- Attack surface: Every package in the base image is a potential CVE. Distroless or scratch images have near-zero CVEs
The pattern is always the same — build in one stage, copy artifacts to another:
|
|
The final image is golang:1.23 (800 MB) replaced by alpine:3.21
(7 MB) with just the binary. That is the core idea. The rest is
optimization and language-specific nuance.
Go: From Scratch for the Smallest Images
Go is the ideal candidate for multi-stage builds because it compiles
to a statically-linked binary with zero runtime dependencies. You can
use the ultimate minimal base — FROM scratch — which is an empty
filesystem.
Minimal Go Dockerfile
|
|
The -ldflags="-s -w" strips debug symbols and DWARF tables, shaving
another 30-40% off the binary size. Combined with FROM scratch, your
final image is the binary plus CA certs — typically 6-15 MB.
Health Check and Config Files
If your Go service needs configuration files, copy them explicitly:
|
|
Build and Verify
|
|
Go Build Cache Optimization
Docker build cache is per-layer. Reordering your Dockerfile to cache dependency downloads saves minutes per rebuild:
|
|
This separation means adding a line of code does NOT re-download all
modules. The go mod download layer is cached and reused.
Python: Slimming the 1.2 GB Behemoth
Python does not compile to a standalone binary. Your application requires the Python interpreter plus all pip dependencies at runtime. But multi-stage builds still help dramatically by separating the build dependencies — compilers, dev headers, build tools — from the runtime.
Naive Single-Stage (What Not To Do)
|
|
This works but includes pip, setuptools, wheel, and whatever packages your requirements pulled in. Size: ~350-500 MB for a small app.
Multi-Stage Python with Virtual Environment
|
|
The build tools (gcc, python3-dev) are only in the builder stage. The runtime stage keeps only the slim Python image plus the pre-installed packages. For a typical Flask or FastAPI app, this drops size from ~500 MB to ~180 MB.
Python with Compiled Extensions
Packages like numpy, pandas, cryptography, or psycopg2 compile
C extensions during installation. Without build tools in the builder
stage, they fail.
|
|
The runtime stage only has shared libraries (libpq5, libffi8),
not the dev headers. The compilers and build tools are confined to the
builder stage and never reach the final image.
Alpine vs Slim Base for Python
|
|
Alpine images are smaller but use musl instead of glibc. If your C
extensions are compiled against glibc (most are), Alpine will either
fail to import them or produce subtle runtime bugs. For pure Python
packages without C extensions, Alpine is fine. For anything with
native code, stick with slim.
Node.js: Size from a Different Angle
Node.js images follow a similar pattern but have a gotcha: node_modules
is the bulk of the image, and npm/yarn install is the slowest build
step.
Multi-Stage Node.js Dockerfile
|
|
Key optimizations:
npm ciinstead ofnpm install— Faster, deterministic, respects lockfile exactly, and fails ifpackage-lock.jsonis out of sync withpackage.jsonnpm prune --production— Removes devDependencies after build- Separate
package.jsoncopy — Dependencies layer is cached untilpackage.jsonorpackage-lock.jsonchanges
Comparison
| Technique | Image Size | Notes |
|---|---|---|
Single-stage node:22 |
~1.2 GB | Full image with dev deps |
Single-stage node:22-alpine |
~350 MB | Smaller but still has dev deps |
| Multi-stage (above) | ~150-200 MB | Production deps only |
| Distroless Node.js | ~120-150 MB | Google distroless base |
Distroless Base Images
Google’s distroless images are another runtime option. They contain only the language runtime and its shared libraries — no shell, no package manager, no utilities.
|
|
|
|
When to use distroless:
- You need the smallest possible attack surface
- Your container is scanned by security tooling (Trivy, Grype)
- You do not need shell access to running containers
When to avoid distroless:
- You need to
docker execinto the container and debug with shell tools (nosh,curl,pingavailable) - Your application relies on OS utilities that are not in the image
- You run health checks that depend on shell commands
For homelab use, Alpine or slim bases strike a better balance. They are small enough yet retain basic debugging tools. Swap to distroless only when you are comfortable debugging blind.
General Multi-Stage Optimization Strategies
Layer Ordering for Cache Hits
Docker builds each layer sequentially and caches results. To maximize cache reuse, order your Dockerfile from least-changing to most-changing:
|
|
This ordering means a single-line code change does NOT invalidate the OS package layer or the dependency download layer. Builds that would take 3 minutes recompile in 20 seconds.
Using Build Arguments
Build args let you parameterize the Dockerfile without hardcoding:
|
|
Build with platform-specific images:
|
|
Multi-Stage for Monorepos
If your homelab runs multiple Go services in one repository, use a single Dockerfile with argument-based stage selection:
|
|
Build specific services:
|
|
The go mod download layer is shared across all services, and each
build only runs one compile step.
Security Hardening Through Multi-Stage
Beyond size reduction, multi-stage builds improve security in three ways:
1. No Build Tools in Production
Compilers, headers, and package managers are the biggest source of CVEs in container images. By confining them to the builder stage, the runtime image has less software to patch.
|
|
2. Non-Root User by Default
Build the non-root user into the runtime stage:
|
|
3. Read-Only Root Filesystem
Combine with Docker’s --read-only flag:
|
|
The binary needs nowhere to write in the root filesystem. If an attacker compromises the process, they cannot drop files on the filesystem.
Database Sidecar Pattern with Alpine
Multi-stage is not just for application images. Database initializers, migration runners, and cron containers benefit too:
|
|
This runs as a docker-compose depends_on init container that applies
database migrations, then exits. The image stays under 20 MB.
Measuring Your Results
Before-and-after metrics keep you honest:
|
|
Run Trivy for a deeper audit:
|
|
A well-optimized homelab Docker image should:
- Be 80-95% smaller than its single-stage equivalent
- Have zero critical CVEs (achievable with scratch/distroless)
- Build in under 30 seconds from cache on a code change
Summary
Multi-stage builds are the single most impactful Dockerfile optimization you can make. The pattern is language-agnostic:
- Build stage — Full toolchain, compile or install everything
- Runtime stage — Minimal base, copy only the artifacts
For Go services, FROM scratch with static binaries produces images
under 15 MB. For Python and Node.js, separating build dependencies
from runtime reduces size by 60-80% while also reducing the CVE count.
Combine multi-stage with proper cache layering (dependencies before source), non-root users, and read-only filesystems for containers that are small, fast to deploy, and hardened against exploitation.