Docker 82 🐳 Containerizing Compiled Applications: Go, Rust, and C++ Binaries
Compiled languages produce a single artifact that either runs or does not. There is no interpreter, no virtual environment, no dependency resolution at startup. This property makes them ideal candidates for minimal container images: the build stage can be heavy, but the runtime stage needs only the binary and whatever shared libraries it dynamically links against. The multi-stage Docker build pattern exists precisely for this: compile in a full toolchain image, copy the artifact into an empty or near-empty base, and ship an image measured in megabytes rather than gigabytes.
The three languages differ in how much control the developer has over linking. Go can produce a fully static binary by setting CGO_ENABLED=0, making it the simplest case—a scratch or static base suffices . Rust can also target static linking via musl libc, but dependencies that rely on system libraries (OpenSSL, for example) complicate the build . C++ compiles to a dynamically linked binary by default on most systems; static linking with -static is possible but requires attention to glibc’s limitations with NSS and DNS resolution. Each language has a “happy path” and a set of pitfalls that determine whether the final image runs or fails with cryptic errors.
This chapter covers the multi-stage Dockerfile pattern for Go, Rust, and C++, the choice between scratch, distroless, alpine, and debian-slim runtime bases, the linking decisions that determine whether a binary is portable, and the dependency-caching techniques that keep rebuild times reasonable. It assumes familiarity with Docker fundamentals and the distinction between static and dynamic linking.
Key point: The runtime base image must match the binary’s linking model. A statically linked binary runs anywhere; a dynamically linked binary requires its shared libraries in the final image. Go gives you the most control; Rust and C++ require deliberate decisions about musl, glibc, and build dependencies.
Why compiled applications need multi-stage builds
Build toolchains are large and unnecessary at runtime. A rust:1.75 image is over 1GB; the compiled binary it produces may be 10MB . A golang:1.23 image includes the compiler, standard library source, and build cache—hundreds of megabytes that the running application never uses. A C++ build requires build-essential, which pulls in gcc, g++, make, and headers. Copying only the binary into a fresh runtime stage discards all of this, reducing the image size by an order of magnitude or more.
The linking model determines the runtime base. A statically linked binary contains all the code it needs, including the C library (or a substitute). It can run on scratch, the empty image, because it does not expect to find anything on the filesystem . A dynamically linked binary expects a dynamic linker (ld-linux.so or ld-musl.so) and shared libraries at specific paths. If those are absent, the binary fails with “no such file or directory” or a missing library error, even though the file is present and executable . The runtime base must satisfy these expectations.
scratch is minimal but often too minimal. An empty image has no shell, no ls, no /tmp directory, and—critically—no CA certificates. A Go or Rust service that makes outbound HTTPS calls will fail TLS verification because the root certificates are absent . The distroless/static image exists for this reason: it provides CA certificates, time zone data, and basic directory structure, at roughly 2MB, which is still far smaller than alpine or debian-slim . Chainguard’s static image serves the same purpose .
Dependency caching keeps rebuilds fast. Copying the entire source tree before building forces Docker to re-run the dependency installation and compilation steps every time any file changes. The fix is to copy manifests first (go.mod, Cargo.toml, Cargo.lock), run the dependency fetch or build against a dummy source file, then copy the real source and build again . This way, the expensive dependency layer remains cached as long as the manifests do not change.
The trade-off. Static linking produces the smallest, most portable images but can complicate builds when native dependencies expect dynamic linking. Dynamic linking with debian-slim is simpler to set up but produces larger images and requires matching the build and runtime glibc versions. There is no universally correct choice; the decision depends on the application’s dependencies and the team’s tolerance for build complexity.
a. Go: static by default, scratch or distroless
Go’s toolchain produces a statically linked binary when CGO_ENABLED=0 is set. This is the critical flag: without it, Go may link against glibc if the build environment has a C compiler available, producing a binary that fails on a scratch or distroless base because the dynamic linker is missing . The fix is explicit: always set CGO_ENABLED=0 unless the application genuinely needs C bindings.
# syntax=docker/dockerfile:1
# Build stage: Go toolchain compiles a static binary.
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Cache dependencies: copy manifests first.
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build a static binary.
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s" \
-o /app/server .
# Runtime stage: distroless static includes CA certs and tzdata.
FROM gcr.io/distroless/static-debian12
# Copy only the binary.
COPY --from=builder /app/server /server
# Exec form is required; distroless has no shell.
ENTRYPOINT ["/server"]
The -ldflags="-w -s" flag strips debug information and the symbol table, reducing binary size without affecting execution . The distroless static image provides CA certificates for HTTPS and time zone data for correct local time handling, both of which are absent from scratch . For an application that needs nothing beyond the binary—no outbound TLS, no time zones—scratch works and is marginally smaller. For almost everything else, distroless static is the better default.
If the Go application requires CGO (for a database driver that links against a C library, for example), the binary is dynamically linked. The runtime base must then be debian-slim or a distroless variant that includes glibc, and the C library versions in build and runtime should match .
b. Rust: musl for static, debian-slim for dynamic
Rust’s default target links against glibc, producing a dynamically linked binary. A debian:bookworm-slim runtime with ca-certificates and libssl3 installed is the conventional choice when dependencies like OpenSSL are involved . For a fully static binary, the target must be switched to musl (x86_64-unknown-linux-musl), which requires the musl toolchain in the builder.
# syntax=docker/dockerfile:1
# Build stage: Alpine with musl for static linking.
FROM rust:1.75-alpine AS builder
# Install musl-dev and build dependencies for crates that need C.
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
WORKDIR /app
# Cache dependencies: copy manifests, build dummy source.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
# Copy real source and build.
COPY src ./src
RUN touch src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl
# Runtime stage: scratch or distroless static.
FROM scratch
# Copy CA certificates for HTTPS.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the static binary.
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
ENTRYPOINT ["/myapp"]
The dummy-source build trick is essential for Rust. Cargo compiles every dependency before compiling the application. Without the dummy source, any change to main.rs invalidates the entire dependency cache, forcing a full recompilation that can take minutes . Building against an empty main.rs first compiles and caches the dependencies; the real build then only compiles the application code.
The musl target produces a fully static binary, but it has limitations: musl’s DNS resolution differs from glibc’s, and some crates that rely on glibc-specific behavior may fail or require patches. For applications with complex native dependencies, the debian-slim runtime with a glibc-linked binary is often more reliable . The image is larger—80–100MB versus 10–20MB—but the build is simpler and the runtime behavior matches the development environment.
c. C++: static linking with -static, scratch runtime
C++ has no package manager or standard build system, so the Dockerfile must install the compiler and build the application explicitly. The Docker documentation’s C++ guide uses ubuntu:latest with build-essential, compiles with g++ -static, and copies the binary into scratch . This produces an image containing only the binary—no shell, no libraries, no package manager.
# syntax=docker/dockerfile:1
# Build stage: Ubuntu with build tools.
FROM ubuntu:24.04 AS build
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY hello.cpp .
# Static compilation: -static links all libraries into the binary.
RUN g++ -O2 -static -o hello hello.cpp
# Runtime stage: scratch, only the binary.
FROM scratch
COPY --from=build /app/hello /hello
CMD ["/hello"]
The -static flag is the critical decision. Without it, the binary expects libstdc++.so, libc.so.6, and the dynamic linker at their standard paths. On scratch, none of these exist, and the binary fails to start . With -static, the linker embeds all required library code into the executable, producing a larger binary (often 2–5MB for a simple program) that runs anywhere.
Static linking C++ with glibc has a known caveat: getaddrinfo and related NSS (Name Service Switch) functions may not work correctly because glibc dynamically loads NSS modules at runtime. For applications that perform DNS resolution, static linking against glibc requires either a workaround (--enable-static-nss at glibc build time) or linking against musl instead. For a simple hello-world or an application that does not resolve hostnames, static linking is unproblematic. For network services, testing DNS behavior in the container is mandatory before relying on a scratch base.
A more moderate approach uses debian:bookworm-slim as the runtime base, copying the dynamically linked binary and its required libraries. This produces a larger image but avoids the static linking pitfalls. The choice depends on whether the application’s dependencies are fully understood and tested under static linking.
Complete Example Session
# ============================================
# PART 1: GO — STATIC BUILD, DISTROLESS RUNTIME
# ============================================
FROM golang:1.23-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /server .
FROM gcr.io/distroless/static-debian12
COPY --from=go-builder /server /server
ENTRYPOINT ["/server"]
# ============================================
# PART 2: RUST — MUSL STATIC BUILD, SCRATCH RUNTIME
# ============================================
FROM rust:1.75-alpine AS rust-builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
COPY src ./src
RUN touch src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=rust-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=rust-builder /app/target/x86_64-unknown-linux-musl/release/app /app
ENTRYPOINT ["/app"]
# ============================================
# PART 3: C++ — STATIC BUILD, SCRATCH RUNTIME
# ============================================
FROM ubuntu:24.04 AS cpp-builder
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY main.cpp .
RUN g++ -O2 -static -o /app/server main.cpp
FROM scratch
COPY --from=cpp-builder /app/server /server
CMD ["/server"]
# ============================================
# PART 4: LINKING DECISION TREE
# ============================================
# Go:
# CGO_ENABLED=0 → static → scratch/distroless static
# CGO_ENABLED=1 → dynamic → debian-slim + libc
#
# Rust:
# x86_64-unknown-linux-musl → static → scratch/distroless
# default (gnu) → dynamic → debian-slim + libssl3
#
# C++:
# -static → static → scratch (test DNS if needed)
# default → dynamic → debian-slim + libstdc++
# ============================================
# PART 5: DEPENDENCY CACHING — GO
# ============================================
# COPY go.mod go.sum ./
# RUN go mod download
# COPY . .
# Manifests change rarely; source changes often.
# ============================================
# PART 6: DEPENDENCY CACHING — RUST
# ============================================
# COPY Cargo.toml Cargo.lock ./
# RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build ...
# This compiles dependencies once, caches the layer.
# ============================================
# PART 7: DEPENDENCY CACHING — C++
# ============================================
# apt-get install is the only dependency step.
# Put it in its own layer; it rarely changes.
# ============================================
# PART 8: CA CERTIFICATES
# ============================================
# scratch: copy /etc/ssl/certs/ca-certificates.crt from builder.
# distroless/static: includes certificates.
# alpine/debian-slim: install ca-certificates package.
# ============================================
# PART 9: NON-ROOT USERS
# ============================================
# distroless: use :nonroot tag or USER 65532.
# scratch: must create user in builder, copy /etc/passwd if needed.
# Simpler: run as numeric UID with USER 1000:1000.
# ============================================
# PART 10: VERIFICATION
# ============================================
# docker build -t app .
# docker run --rm app # should start
# docker run --rm app ls # scratch: fails, expected
# docker images app # check size
Quick Reference
Runtime Base Comparison
| Base | Size | Shell | CA Certs | Use For |
|---|---|---|---|---|
scratch | 0 B | No | No | Fully static, no TLS |
distroless/static | ~2 MB | No | Yes | Static Go/Rust with TLS |
alpine | ~5 MB | Yes | Install | Dynamic musl binaries |
debian-slim | ~75 MB | Yes | Install | Dynamic glibc binaries |
Linking Flags
| Language | Static Flag | Result |
|---|---|---|
| Go | CGO_ENABLED=0 | Static binary |
| Rust | --target x86_64-unknown-linux-musl | Static (musl) |
| C++ | -static | Static (glibc caveats) |
Dependency Caching Pattern
| Language | Copy First | Dummy Build | Then Copy |
|---|---|---|---|
| Go | go.mod, go.sum | go mod download | Source |
| Rust | Cargo.toml, Cargo.lock | main.rs stub | src/ |
| C++ | — | apt-get install | Source |
Best Practices
✅ Do This:
# Set CGO_ENABLED=0 explicitly for static Go. // ✅
RUN CGO_ENABLED=0 go build -o /app .
# Use distroless/static instead of scratch for TLS. // ✅
FROM gcr.io/distroless/static-debian12
# Cache dependencies before copying source. // ✅
COPY go.mod go.sum ./ && RUN go mod download
COPY . .
# Use exec form in ENTRYPOINT/CMD. // ✅
ENTRYPOINT ["/app"]
❌ Don’t Do This:
# Assume Go binary is static without CGO_ENABLED=0. // ❌
RUN go build -o /app . # May link glibc; fails on scratch.
# Use scratch with an HTTPS client. // ❌
# No CA certificates; TLS verification fails.
# Copy entire project before installing dependencies. // ❌
COPY . . && go mod download # Cache invalidated on any change.
# Use shell form with distroless/scratch. // ❌
CMD /app # No shell to interpret it.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| “No such file” on scratch | Binary is dynamically linked | CGO_ENABLED=0 or static target |
| TLS fails in container | No CA certificates | Use distroless/static or copy certs |
| Rust rebuild takes minutes | Dummy source not used | Cache dependencies separately |
| C++ DNS broken | Static glibc NSS limitation | Test; use musl or dynamic base |
| Image still large | Build stage copied entirely | Copy only binary in final stage |
Real-World Examples
1. Go Static Build
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /server .
2. Go Distroless Runtime
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
3. Rust musl Target
RUN cargo build --release --target x86_64-unknown-linux-musl
4. Rust Dependency Cache
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build ...
5. C++ Static Compile
RUN g++ -O2 -static -o hello hello.cpp
6. Scratch Runtime
FROM scratch
COPY --from=build /app/hello /hello
7. CA Certificates from Builder
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
8. Go Module Cache
COPY go.mod go.sum ./
RUN go mod download
9. Non-Root Numeric UID
USER 1000:1000
10. Verify Binary Linkage
ldd ./binary # "not a dynamic executable" = static
Visual
Multi-Stage Build Flow
┌──────────────────────────────────────────────┐
│ BUILDER STAGE RUNTIME STAGE │
│ │
│ golang:1.23-alpine distroless/static │
│ │ │ │
│ go mod download │ │
│ │ │ │
│ go build ──────────────► COPY binary │
│ (static) (only artifact) │
│ │ │
│ Toolchain remains. No toolchain. │
│ Large layer. Tiny image. │
│ │
│ Final image: binary + CA certs + tzdata │
│ (~5–10 MB for Go, ~10–20 MB for Rust) │
│ │
└──────────────────────────────────────────────┘
Linking Decision
┌──────────────────────────────────────────────┐
│ STATIC vs DYNAMIC │
│ │
│ Static binary: │
│ All libraries embedded. │
│ Runs on scratch. │
│ Larger binary (~2–10 MB). │
│ │
│ Dynamic binary: │
│ Expects shared libraries + linker. │
│ Needs debian-slim or alpine. │
│ Smaller binary, larger image. │
│ │
│ Check: ldd ./binary │
│ "not a dynamic executable" → static │
│ "libc.so.6 => ..." → dynamic │
│ │
└──────────────────────────────────────────────┘
Dependency Caching
┌──────────────────────────────────────────────┐
│ LAYER CACHING │
│ │
│ Bad order: │
│ COPY . . ← any change invalidates│
│ RUN go mod download ← re-runs │
│ RUN go build ← re-runs │
│ │
│ Good order: │
│ COPY go.mod go.sum ← rare change │
│ RUN go mod download ← cached │
│ COPY . . ← frequent change │
│ RUN go build ← only this re-runs │
│ │
│ Rust adds dummy source: │
│ COPY Cargo.toml Cargo.lock │
│ RUN cargo build (dummy main) ← cached │
│ COPY src │
│ RUN cargo build (real) ← fast │
│ │
└──────────────────────────────────────────────┘
Runtime Base Selection
┌──────────────────────────────────────────────┐
│ WHICH BASE? │
│ │
│ Static + no TLS? │
│ └── scratch │
│ │
│ Static + TLS/outbound? │
│ └── distroless/static │
│ │
│ Dynamic glibc (Rust default, C++)? │
│ └── debian-slim + libs │
│ │
│ Dynamic musl (Alpine build)? │
│ └── alpine + ca-certificates │
│ │
│ Go: CGO_ENABLED=0 → static │
│ Rust: musl target → static │
│ C++: -static → static (test DNS) │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Multi-stage build | Build in toolchain image, copy artifact to minimal base |
| Go static flag | CGO_ENABLED=0 |
| Rust static target | x86_64-unknown-linux-musl |
| C++ static flag | -static |
| Smallest base | scratch (no shell, no certs) |
| Static with TLS | distroless/static (~2 MB) |
| Dynamic glibc | debian-slim |
| Dependency cache (Go) | go.mod + go mod download first |
| Dependency cache (Rust) | Dummy main.rs before real source |
| Distroless entrypoint | Must use exec form ["/app"] |
Key takeaways:
- The runtime base must match the linking model. A statically linked binary runs on
scratch; a dynamically linked binary requires its shared libraries. Mismatch produces “no such file” errors even when the binary is present . - Go’s
CGO_ENABLED=0is not optional for minimal images. Without it, the Go toolchain may link against glibc, producing a binary that fails on distroless or scratch . scratchis rarely the right answer alone. It lacks CA certificates, time zone data, and basic directories.distroless/staticprovides these at roughly 2MB, making it the better default for static Go and Rust binaries .- Rust’s dummy-source build is essential for iteration speed. Without it, any source change forces a full dependency recompilation. The empty
main.rscaches dependencies separately . - C++ static linking with glibc has DNS caveats.
getaddrinfomay not work correctly in a statically linked glibc binary because NSS modules are loaded dynamically. Test DNS resolution in the container before relying onscratchfor network services. - Dependency caching follows the same pattern in all three languages. Copy manifests first, install or build dependencies, then copy source. The expensive layer stays cached as long as the manifests are unchanged.
- Distroless and scratch require exec-form entrypoints. There is no shell to interpret
CMD /app; the vector form["/app"]is mandatory .
Remember: Containerizing a compiled application is a linking problem disguised as a Docker problem. Go gives you a static binary with one environment variable; Rust gives you a static binary with one target triple; C++ gives you a static binary with one compiler flag. Each choice determines whether the runtime base can be empty, distroless, or a full Debian image. The multi-stage build separates the heavy toolchain from the light artifact, and the dependency-caching pattern keeps the build fast. Get the linking right, and the image is small, secure, and portable. Get it wrong, and the binary fails to start with an error that says nothing about the real cause.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!