Static Binaries with Go and Rust for Tiny Images

Khimananda Oli 8 min read Programming and Languages
Static Binaries with Go and Rust for Tiny Images

By Khimananda Oli | Last reviewed: August 2026

Bloated container images slow down deployments, increase storage costs, and expand your security attack surface. Building static binaries with Go and Rust for tiny images solves this by eliminating runtime dependencies entirely, allowing you to run applications on minimal bases like scratch or distroless. This guide provides the exact compiler flags, multi-stage Docker patterns, and verification steps needed to produce production-ready, portable executables that start instantly and contain only your application code.

Why should you use static binaries with Go and Rust for tiny images?

The primary driver for adopting static compilation is operational efficiency. In my experience managing fleets of microservices on Amazon EKS, shifting from standard Debian-based images to static scratch containers reduced average image size from 450MB to 12MB. This isn't just about storage; it directly impacts cluster autoscaling latency. When a new node joins, pulling a 12MB image takes seconds versus minutes for a full OS base, meaning your pods reach Ready state faster during traffic spikes.

Security is the second critical factor. A standard Linux container includes hundreds of packages, shells, and utilities that an attacker can exploit if they achieve remote code execution. A static binary running on scratch has no shell, no ls, no cat, and no package manager. If an attacker compromises your app, they have nowhere to go and nothing to pivot with. This dramatically simplifies compliance audits for standards like SOC 2 and ISO 27001 because the "attack surface" evidence is literally the absence of system tooling.

Traditional Dynamic ImageApp Binary + Shared LibsPackage Manager / Shell / Utilsglibc / System LibrariesFull Linux Base (Debian/Alpine)~450 MB | High CVE CountStatic Scratch ImageSelf-Contained Static Binary(Go/Rust + Embedded Deps)Empty Filesystem (scratch)~12 MB | Minimal Attack Surface
Traditional dynamic containers carry unnecessary OS baggage, while static binaries with Go and Rust for tiny images eliminate everything except the executable.

Reliability improves as well. Dynamic linking errors are a common source of production incidents—especially when base images update and break library compatibility. A statically linked binary carries its own dependencies. It runs identically on Ubuntu, Alpine, Amazon Linux, or bare metal. You never encounter libssl.so.1.1 not found at 3 AM again.

How do you compile static binaries in Go for containers?

Go is naturally suited for static compilation because it links dependencies by default. However, CGO (the bridge to C libraries) re-introduces dynamic linking. For most web services, CLIs, and API servers, you can disable CGO entirely.

Standard static build command

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -extldflags '-static'" -o /app/server ./cmd/server

The flags here matter significantly. -s -w strips the symbol table and DWARF debugging information, typically reducing binary size by 30–40% without affecting runtime behavior. -extldflags '-static' ensures any remaining external linker calls enforce static linking. Always set GOOS and GOARCH explicitly in CI to avoid accidentally building for your developer machine's architecture.

Handling CGO dependencies

Some libraries (SQLite drivers, certain cryptography packages) require CGO. In these cases, you cannot simply disable it. Instead, use a musl-based toolchain in your build stage:

# In Dockerfile build stage
RUN apk add --no-cache gcc musl-dev git
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-linkmode external -extldflags '-static'" -o /app/server ./cmd/server

This compiles against musl libc instead of glibc, producing a fully static binary even with CGO enabled. Test thoroughly, as some C libraries behave differently under musl.

How do you build static Rust binaries with musl for Docker?

Rust requires more explicit configuration for static linking because the default GNU toolchain produces dynamically linked binaries. The musl target is the standard solution for creating static binaries with Go and Rust for tiny images.

Adding the musl target

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

Your output binary will be at target/x86_64-unknown-linux-musl/release/your-app. Unlike Go, Rust's release profile already optimizes aggressively, but you can further reduce size in Cargo.toml:

[profile.release]
strip = true
opt-level = "z"    # Optimize for size rather than speed
lto = true         # Link-time optimization across crates
codegen-units = 1  # Slower compile, smaller binary
panic = "abort"    # Remove unwind tables

The opt-level = "z" flag prioritizes size over performance. For most network-bound services, the difference is negligible, but the binary shrinks 20–30%. LTO enables cross-crate inlining, which eliminates dead code more effectively than per-crate compilation.

Source Codemain.go / main.rsgo.mod / Cargo.tomlDockerfileBuilder Stagegolang:1.23 / rust:1.82Install DepsCompile Static BinaryStrip & Verify/app/server (12MB)Runtime StageFROM scratchCOPY --from=builder/app/server /serverENTRYPOINT ["/server"]PushRegistryECR / GHCR~12 MB
Multi-stage build workflow: compile in a heavy builder image, then copy only the static artifact to a minimal runtime stage.

What is the optimal multi-stage Dockerfile pattern for minimal images?

The multi-stage build is non-negotiable for production. Never install compilers in your runtime image. Below is a battle-tested Go Dockerfile I use across multiple client projects. Adapt the paths for your specific project structure.

# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-s -w -extldflags '-static'" \
    -o /app/server ./cmd/server

# Runtime stage
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/server"]

Two details often get missed. First, ca-certificates.crt is required if your app makes HTTPS calls. Without it, TLS handshakes fail silently or with cryptic errors. Copy it from the builder or embed it at build time. Second, USER 65534:65534 runs the process as nobody. Since scratch has no user database, you must specify numeric UID/GID. This is a basic hardening step covered in depth in our Ubuntu security hardening guide, and the principle applies equally to containers.

For Rust, the pattern is identical except for the builder stage:

FROM rust:1.82-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /build
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/myapp /server
USER 65534:65534
ENTRYPOINT ["/server"]

How do Go and Rust compare for static binary size and build speed?

Choosing between Go and Rust depends on your team's constraints. Both produce excellent static binaries with Go and Rust for tiny images, but trade-offs exist.

CriteriaGoRust
Typical binary size (web API)8–15 MB3–8 MB
Cold build time10–30 seconds2–5 minutes
Incremental build time2–5 seconds5–20 seconds
Static linking easeTrivial (CGO_ENABLED=0)Moderate (musl target required)
Memory safety guaranteesRuntime GC, no buffer overflowsCompile-time, zero-cost abstractions
Ecosystem maturity for cloudExtensive (K8s, Terraform native)Growing rapidly (Axum, Tokio)
Debugging in scratch containersHarder (no symbols by default)Harder (same limitation)

In practice, Go wins on developer velocity and ecosystem integration. Most cloud-native tooling is written in Go, so libraries and patterns are abundant. Rust wins on raw binary size and runtime performance, making it ideal for edge computing, CLI tools distributed to customers, or high-throughput proxies where every MB matters.

Go vs Rust: Size & Build Time Trade-offs0 MB10 MB20 MB30 MB12 MBGo Binary5 MBRust Binary20sGo Build180sRust BuildBinary Size (smaller = better)Cold Build Time (faster = better)
Rust produces smaller static binaries but requires significantly longer compile times compared to Go for equivalent web services.

How do you verify and debug static binaries before deployment?

A common mistake is assuming a binary is static without verification. Always validate before pushing to production.

  1. Check dynamic dependencies: Run ldd /app/server. A truly static binary returns not a dynamic executable. If it lists shared libraries, your build failed.
  2. Verify file type: Use file /app/server. Output should include statically linked.
  3. Test in isolation: Run the binary in a fresh scratch container locally before deploying. Mount it: docker run --rm -v $(pwd)/server:/server scratch /server.
  4. Include health checks: Since scratch has no curl or wget, implement HTTP health endpoints in your app. Kubernetes liveness probes rely on this.

Debugging is harder without a shell. For staging environments, consider using gcr.io/distroless/static-debian12 instead of scratch. It includes a minimal debugger and CA certificates while still being under 5MB. Reserve pure scratch for production where security is paramount. If you need to inspect a running scratch container, use kubectl debug with an ephemeral container—a technique detailed in our Kubernetes debugging guide.

Also ensure your observability stack doesn't depend on host-level agents. With scratch containers, all metrics, logs, and traces must be emitted by the application itself. Review our OpenTelemetry instrumentation guide to embed telemetry directly into your static binary.

Start shipping smaller, safer containers today

Adopting static binaries with Go and Rust for tiny images is one of the highest-ROI changes you can make to your container strategy. You gain faster deployments, lower storage costs, reduced CVE exposure, and simpler compliance evidence. Start with a single non-critical service: convert it to a multi-stage scratch build, validate with ldd, and measure the difference. Once your team trusts the pattern, roll it out broadly. If you need help auditing your current container footprint or designing a migration path that maintains observability and compliance, reach out to discuss your infrastructure.

Frequently Asked Questions

Static binaries bundle all dependencies into a single executable file, eliminating runtime library requirements. This enables deployment to minimal containers like scratch or distroless without installing system packages or language runtimes.

They drastically reduce image size and attack surface by removing OS packages, shells, and package managers. Smaller images deploy faster, consume less storage, and simplify security compliance audits in production environments.

Set CGO_ENABLED=0 and GOOS/GOARCH env vars before running go build. This disables C dependencies and produces a fully static ELF binary compatible with Alpine or scratch base images.

Use the musl target with rustup target add x86_64-unknown-linux-musl then cargo build --target x86_64-unknown-linux-musl --release. Musl libc replaces glibc to ensure full static linking without dynamic dependencies.

Rust binaries are often 30-50% smaller than equivalent Go builds due to aggressive dead code elimination and no embedded runtime. Both compress well with UPX, but Rust typically yields sub-5MB executables for simple services.

Yes, UPX reduces binary size by 50-70% without functional changes. Always test compressed binaries thoroughly as some antivirus tools flag UPX-packed executables, and decompression adds marginal startup latency in cold-start scenarios.

Fully static binaries run on any Linux kernel regardless of distribution or glibc version. However, binaries linked against musl may behave differently than glibc versions regarding DNS resolution, threading, and locale handling.

Enabling CGO links against system C libraries, breaking true static compilation. Disable CGO unless you specifically need C interop, and prefer pure Go alternatives for networking, crypto, and database drivers when targeting scratch containers.

Yes, they eliminate vulnerable system libraries and reduce CVE exposure. Combined with distroless or scratch bases, static binaries minimize the attack surface and simplify vulnerability scanning since only your application code requires patching.

Stripped binaries lack symbol tables, making stack traces unreadable. Preserve debug info in separate files during CI, use build-id based symbol servers, or keep unstripped artifacts for incident response while deploying stripped versions.

Scratch containers lack certificate stores. Embed certs at compile time, mount /etc/ssl/certs from a trusted source, or use Go's embed directive or Rust's include_str macro to bundle root CAs directly in the binary.

Musl uses simpler malloc and thread implementations that may underperform in high-concurrency workloads. Benchmark your specific application; many web services see negligible differences, but compute-heavy tasks might benefit from glibc with static-pie linking.

Yes, both Go and Rust support cross-compilation natively. Use Docker multi-stage builds with platform-specific toolchains or GitHub Actions matrix strategies to produce amd64 and arm64 static binaries in parallel.

Missing musl target, incompatible crate dependencies requiring C libraries, or linker failures. Ensure all dependencies support no_std or musl, and verify openssl-sys uses vendored feature flags to avoid dynamic libssl linkage.

Avoid them when applications require dynamic plugins, JNI/JVM interoperability, or glibc-specific features like NSS modules. Dynamic linking remains preferable for complex runtimes where static compilation introduces maintenance burden or compatibility risks.