How We Achieved 5-10x Faster Rust Docker Builds with Zigbuild

How We Achieved 5-10x Faster Rust Docker Builds with Zigbuild

Traditional multi-stage Docker builds for Rust applications are painfully slow. By switching to cargo-zigbuild for cross-compilation on the host machine, we slashed our build times from 15+ minutes to under 2 minutes while producing smaller, optimized images.

Mike Chumba Mike Chumba
6 min read
1163 words

TL;DR: We replaced our traditional multi-stage Docker build with a native cross-compilation workflow using cargo-zigbuild. The result? 5–10x faster builds, smaller images, and a much better developer experience. Here’s how we did it, the gotchas we encountered, and why you should consider this approach.


The Problem: Death by Docker Build

If you’ve ever built a Rust project inside Docker, you know the pain. The typical multi-stage Dockerfile looks something like this:

# Stage 1: Builder
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev openssl-dev
WORKDIR /app
COPY . .
RUN cargo build --release

# Stage 2: Runtime
FROM alpine:3.20
COPY --from=builder /app/target/release/myapp /app/myapp
ENTRYPOINT ["/app/myapp"]

Looks clean, right? But here’s the problem: every single build starts from scratch.

Docker’s layer caching is great for interpreted languages, but Rust’s cargo cache lives in ~/.cargo and target/, which are specific to your local environment. Inside the Docker build context, you’re downloading crates, compiling dependencies, and re-linking every single time. For a non-trivial project, this easily takes 15–20 minutes.

We were building AuthOS, a multi-tenant authentication platform, with three database backend variants (SQLite, PostgreSQL, MySQL). That meant three separate builds. Do the math: 45–60 minutes just to publish a new version. Unacceptable.


The Solution: Cross-Compile on Host, Package in Docker

The insight is simple: don’t compile inside Docker. Instead:

  1. Cross-compile the binary on your host machine using cargo-zigbuild
  2. Copy the pre-built binary into a minimal Docker image
  3. Push to registry

This approach leverages your local cargo cache (incremental builds!) and only uses Docker for the final, trivial packaging step.

Why Zigbuild?

cargo-zigbuild is a drop-in replacement for cargo build that uses Zig as the linker. Why Zig?

  • Perfect cross-compilation: Zig bundles a complete C toolchain that can target musl libc without any additional setup
  • Static linking made easy: Produces fully static binaries that run on any Linux distro
  • No Docker-in-Docker complexity: No need for buildx, multi-platform manifests, or QEMU emulation

The New Architecture

The Native Cross-Compilation Architecture Fig 1: We moved the compilation step out of Docker and onto the host, using Zig to link static binaries.


The Implementation

Prerequisites

# macOS
brew install zig upx
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-musl

# Linux (Ubuntu/Debian)
sudo apt install -y upx
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-musl

The Minimal Dockerfile

Our Dockerfile is now laughably simple:

# Minimal runtime image for pre-built binaries
FROM --platform=linux/amd64 alpine:3.20

ARG BINARY_NAME=sso_sqlite

# Runtime dependencies only
RUN apk add --no-cache libgcc ca-certificates

WORKDIR /app

# Copy the pre-built binary
COPY target/dist/${BINARY_NAME} /app/sso
COPY docker-entrypoint.sh /app/docker-entrypoint.sh

RUN chmod +x /app/sso /app/docker-entrypoint.sh

EXPOSE 80
ENTRYPOINT ["/app/docker-entrypoint.sh"]

That’s it. No multi-stage. No cargo. No compilation. Just a simple COPY.

The Build Script

We automated the entire workflow in a Node.js script (docker-publish.js) that:

  1. Checks prerequisites (zig, cargo-zigbuild, musl target)
  2. Cross-compiles using cargo zigbuild --release --target x86_64-unknown-linux-musl
  3. Strips symbols using strip (reduces ~20% size)
  4. Compresses with UPX using upx --best --lzma (reduces another ~60%)
  5. Builds the Docker image (just copying the binary)
  6. Pushes to Docker Hub with proper tags
// Core build logic (simplified)
async function buildBackend(backend, imageName, version) {
    // Step 1: Cross-compile
    exec(`cargo zigbuild --release --locked --target=${TARGET} --features=db_${backend}`);
    
    // Step 2: Copy and strip
    fs.copyFileSync(`target/${TARGET}/release/sso_${backend}`, `target/dist/sso_${backend}`);
    exec(`strip target/dist/sso_${backend}`);
    
    // Step 3: Compress  
    exec(`upx --best --lzma target/dist/sso_${backend}`);
    
    // Step 4: Build Docker image
    exec(`docker build -f Dockerfile --build-arg BINARY_NAME=sso_${backend} -t ${imageName}:${backend}-${version} .`);
    
    // Step 5: Push
    exec(`docker push ${imageName}:${backend}-${version}`);
}

The Results

MetricMulti-Stage BuildZigbuild Approach
Cold Build Time~18 minutes~4 minutes
Warm Build Time~15 minutes~90 seconds
Image Size~29 MB~11 MB
Cache ReuseNone (each build isolated)Full local cargo cache
CI/CD FriendlyRequires volume mounts for cacheJust works™

The “warm build time” is where the magic happens. With local cargo cache, incremental compilation means only changed crates are recompiled. A typical code change builds in under 2 minutes instead of 15+.


The Gotchas

Nothing is perfect. Here are the issues we encountered and how we solved them:

1. OpenSSL and Vendored Dependencies

Problem: Some crates (like openssl-sys) try to link against system OpenSSL, which doesn’t exist when cross-compiling.

Solution: Use vendored/bundled variants. For us, we enabled the vendored feature on dependencies that required system libraries:

# Cargo.toml
[dependencies]
openssl = { version = "0.10", features = ["vendored"] }

2. UPX Compression Can Fail Silently

Problem: UPX sometimes fails on certain binaries or when the binary is too large, but the script would continue anyway.

Solution: We made UPX optional and non-blocking. The script warns if UPX fails but continues:

try {
    exec(`upx --best --lzma ${binaryPath}`);
} catch (e) {
    console.log(`⚠️ UPX compression failed (is upx installed?). Continuing uncompressed.`);
}

If you’re on macOS and UPX isn’t installed, just brew install upx. The build works without it, just with a larger binary.

3. Docker Context Size

Problem: Running docker build . in a Rust project copies the entire target/ directory (gigabytes!) into the build context.

Solution: Use a proper .dockerignore:

target/
!target/dist/
.git
node_modules
*.md

We only allow target/dist/ (where we put the stripped binary) into the context.

4. Build Parallelization Conflicts

Problem: We tried building all three backends (SQLite, PostgreSQL, MySQL) in parallel, but cargo lock files conflicted.

Solution: Let cargo handle serialization naturally. The compilation step blocks on cargo’s internal lock, so parallel builds work fine—they just serialize at compile time and parallelize at the Docker/push steps.

5. Feature Flag Isolation

Problem: Our codebase uses Cargo feature flags extensively (db_sqlite, db_psql, db_mysql). Cross-compiling one backend would sometimes pick up cached artifacts from another.

Solution: We use --no-default-features and explicitly enable only the target feature:

cargo zigbuild --release --no-default-features --features=db_sqlite

The Kitchen: Our Orchestration Dashboard

We wrapped this entire workflow in a web-based dashboard called Kitchen (because we’re “cooking” builds 🍳). It provides:

  • One-click builds with backend selection (SQLite, PostgreSQL, MySQL)
  • Real-time log streaming via Server-Sent Events
  • Build history persisted in SQLite
  • Process management (kill running builds)

Kitchen Dashboard

The Kitchen automatically detects your Docker Hub credentials, infers version bumps, and handles the entire publish workflow. No more typing long docker build commands.


When NOT to Use This Approach

This workflow is optimized for local development and single-machine CI. If you’re running builds in ephemeral CI containers (like GitHub Actions without caching), you won’t benefit from the local cargo cache.

For ephemeral CI, consider:

  1. Cargo cache actions: Use actions/cache to persist ~/.cargo and target/ between runs
  2. sccache: A shared compilation cache that works across machines
  3. Pre-built base images: Create a builder image with dependencies pre-compiled

Conclusion

Switching to cargo-zigbuild was one of the highest-ROI changes we made to our development workflow. The combination of:

  • Native cross-compilation (leverages local cache)
  • Minimal Docker images (just runtime deps)
  • UPX compression (smaller images, faster pulls)
  • Orchestration tooling (Kitchen dashboard)

…turned a 45-minute multi-backend publish process into a 5-minute operation.

If you’re building Rust Docker images and suffering through slow builds, give zigbuild a try. Your future self (and your CI minutes) will thank you.


Resources: