Optimizing for Docker Container Size by Removing Compilation From the Equation
-
docker
-
devops
-
rust
-
go
-
java
If you've ever built a Docker image for a compiled language and watched it balloon to over a gigabyte, you've felt the pain this post is about. I recently ran into this myself while containerizing a Rust service - the final image was pulling in the entire Rust toolchain, cargo, build caches, and source code, none of which the running application actually needs. The fix is a pattern called multi-stage builds.
The Core Idea
A Dockerfile can define more than one FROM stage. Early stages can install compilers, pull dependencies, and build your binary. The final stage starts fresh from a minimal base image and copies over only the compiled artifact. Everything used to build the code - compiler, intermediate files, package caches - gets left behind in the earlier stage and never ships in your final image.
Rust Example
Rust's toolchain alone can add hundreds of megabytes. Here's a two-stage approach:
# Stage 1: Build
FROM rust:1.79 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Stage 2: Runtime
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
CMD ["myapp"]
The runtime stage never sees rustc, cargo, or any source code. You can go further and use scratch or distroless base images if your binary is statically linked, shrinking things even more.
Go Example
Go compiles to a single static binary by default, making it a natural fit:
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp
FROM scratch
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Using scratch here is viable because Go binaries typically don't need a C library at runtime.
Java Example
Java benefits differently. Maven or Gradle build tools are heavy, but the JVM is still needed at runtime, so you're trading a build environment for a slimmer JRE:
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/target/myapp.jar /app/myapp.jar
CMD ["java", "-jar", "/app/myapp.jar"]
Why This Matters
- Faster image pulls and deploys
- Smaller attack surface, since compilers and build tools aren't sitting in production
- Lower storage costs across registries and nodes
- Cleaner separation between build-time and run-time dependencies
A Few Practical Notes
- Name your stages (
AS builder) soCOPY --from=calls are readable. - Pick the smallest viable runtime base:
alpine,slim,distroless, orscratch, depending on what your binary actually needs (libc, certs, timezone data). - If your binary needs CA certificates for HTTPS calls and you're using
scratch, copy/etc/ssl/certs/ca-certificates.crtfrom the builder stage too - a common gotcha.
Multi-stage builds aren't a niche trick. They're close to standard practice now for any compiled language shipped in a container, and once set up, they cost nothing to maintain.