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:
- Cross-compile the binary on your host machine using
cargo-zigbuild - Copy the pre-built binary into a minimal Docker image
- 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
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:
- Checks prerequisites (zig, cargo-zigbuild, musl target)
- Cross-compiles using
cargo zigbuild --release --target x86_64-unknown-linux-musl - Strips symbols using
strip(reduces ~20% size) - Compresses with UPX using
upx --best --lzma(reduces another ~60%) - Builds the Docker image (just copying the binary)
- 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
| Metric | Multi-Stage Build | Zigbuild Approach |
|---|---|---|
| Cold Build Time | ~18 minutes | ~4 minutes |
| Warm Build Time | ~15 minutes | ~90 seconds |
| Image Size | ~29 MB | ~11 MB |
| Cache Reuse | None (each build isolated) | Full local cargo cache |
| CI/CD Friendly | Requires volume mounts for cache | Just 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)

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:
- Cargo cache actions: Use
actions/cacheto persist~/.cargoandtarget/between runs - sccache: A shared compilation cache that works across machines
- 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:




