Skip to content

Instantly share code, notes, and snippets.

@aalkhodiry
Created February 16, 2026 17:03
Show Gist options
  • Select an option

  • Save aalkhodiry/ad45e414af1e5471e00c8dd0b4fa7fe4 to your computer and use it in GitHub Desktop.

Select an option

Save aalkhodiry/ad45e414af1e5471e00c8dd0b4fa7fe4 to your computer and use it in GitHub Desktop.
Cross-Compiling Rust to Static musl Binaries with cargo-zigbuild on GitHub Actions (amd64 + arm64)

Cross-Compiling Rust to Static musl Binaries with cargo-zigbuild on GitHub Actions

Build fully static amd64 + arm64 Linux binaries for Rust projects with vendored OpenSSL and ring -- no flaky toolchain servers needed.

Why cargo-zigbuild?

Cross-compiling Rust to aarch64-unknown-linux-musl on x86_64 GitHub Actions runners is painful:

Approach Problem
gcc-aarch64-linux-gnu (apt) glibc cross-compiler -- vendored OpenSSL compiles against glibc headers, producing __memcpy_chk / __memset_chk undefined references against musl
musl.cc toolchain download Single community server -- curl: (28) Failed to connect to musl.cc port 443 -- unreliable in CI
Manual CC/AR/linker env vars Fragile -- must set CC_aarch64_unknown_linux_musl, AR_aarch64_unknown_linux_musl, CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER, plus .cargo/config.toml

cargo-zigbuild solves all of this. Zig bundles its own musl libc, Clang-based C compiler, assembler, and linker. Two install steps, zero configuration, works for both architectures from a single install.

Quick Setup (3 lines)

# Zig for C/C++ cross-compilation to musl
- uses: mlugg/setup-zig@v2

- name: Install cargo-zigbuild
  run: pip3 install cargo-zigbuild

Then just replace cargo build with cargo zigbuild:

- name: Build release binary
  run: |
    # Let cargo-zigbuild handle the zig integration automatically
    cargo zigbuild --release --target ${{ matrix.rust_target }}

    cp target/${{ matrix.rust_target }}/release/${{ env.BINARY_NAME }} ./${{ env.BINARY_NAME }}-${{ matrix.arch }}
    # sanity check the binary and show dependencies
    file ./${{ env.BINARY_NAME }}-${{ matrix.arch }} && (ldd ./${{ env.BINARY_NAME }}-${{ matrix.arch }} || true)

No CC, AR, linker env vars needed. No .cargo/config.toml linker config. No musl toolchain downloads.

Complete Working Workflow

name: Release

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

env:
  CARGO_TERM_COLOR: always
  SQLX_OFFLINE: "true"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  BINARY_NAME: myapp

permissions:
  contents: write
  packages: write

jobs:
  build-binaries:
    name: Build ${{ matrix.arch }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - arch: amd64
            rust_target: x86_64-unknown-linux-musl
          - arch: arm64
            rust_target: aarch64-unknown-linux-musl
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: "1.91.0"
          targets: ${{ matrix.rust_target }}

      # Zig for C/C++ cross-compilation to musl
      - uses: mlugg/setup-zig@v2

      - name: Install cargo-zigbuild
        run: pip3 install cargo-zigbuild

      - name: Cache cargo registry and build artifacts
        uses: Swatinem/rust-cache@v2
        with:
          key: ${{ runner.os }}-${{ matrix.rust_target }}-zigbuild-${{ hashFiles('**/Cargo.lock') }}
          cache-targets: true

      - name: Build release binary
        run: |
          # Let cargo-zigbuild handle the zig integration automatically
          cargo zigbuild --release --target ${{ matrix.rust_target }}

          cp target/${{ matrix.rust_target }}/release/${{ env.BINARY_NAME }} ./${{ env.BINARY_NAME }}-${{ matrix.arch }}
          # sanity check the binary and show dependencies
          file ./${{ env.BINARY_NAME }}-${{ matrix.arch }} && (ldd ./${{ env.BINARY_NAME }}-${{ matrix.arch }} || true)

      - name: Stage artifacts
        run: |
          mkdir -p staging
          cp ./${{ env.BINARY_NAME }}-${{ matrix.arch }} staging/
          cp -r migrations staging/
          cp -r config staging/
          chmod +x staging/${{ env.BINARY_NAME }}-${{ matrix.arch }}

      - name: Upload binary artifact
        uses: actions/upload-artifact@v4
        with:
          name: binary-${{ matrix.arch }}
          path: staging/

  docker:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest
    needs: build-binaries
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Download all binary artifacts
        uses: actions/download-artifact@v4
        with:
          pattern: binary-*
          merge-multiple: true

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=tag
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push multi-arch image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: deploy/docker/Dockerfile.release
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Multi-Arch Runtime Dockerfile

Use Docker's TARGETARCH to select the right binary in a single Dockerfile:

FROM cgr.dev/chainguard/static:latest
ARG TARGETARCH
COPY --chmod=755 ./myapp-${TARGETARCH} /usr/local/bin/myapp
COPY ./migrations /app/migrations
COPY ./config /app/config
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/myapp"]

cgr.dev/chainguard/static is a zero-CVE image with no shell, no libc, no dynamic loader -- perfect for fully static musl binaries.

How It Works Under the Hood

What zig provides

  • C compiler (zig cc): Clang-based, targets any architecture from any host
  • Assembler: Handles ring's aarch64 assembly files (.S)
  • musl libc: Bundled -- no system musl-dev/musl-tools needed for cross targets
  • Archiver (zig ar): Creates static libraries for ring and openssl-src
  • Linker: Produces fully static binaries

Dependency chain for vendored OpenSSL

openssl = { features = ["vendored"] }
  -> openssl-src (build.rs)
    -> cc crate -> zig cc --target=aarch64-linux-musl
      -> OpenSSL ./Configure CC="zig cc"
        -> C code compiled with musl headers (not glibc!)
          -> No __memcpy_chk / __memset_chk issues

Dependency chain for ring (via rustls)

sqlx / reqwest / lettre -> rustls -> ring
  ring build.rs -> cc crate -> zig cc --target=aarch64-linux-musl
    -> C files + aarch64 assembly (.S) compiled correctly
    -> zig ar creates static archives

Comparison with Other Approaches

Aspect gcc-aarch64-linux-gnu musl.cc cargo-zigbuild
Install reliability apt (reliable) curl from musl.cc (flaky) pip/GitHub Action (reliable)
Works with musl target No (glibc symbols) Yes Yes
Vendored OpenSSL Fails (glibc headers) Works (with CC/AR env) Works (automatic)
ring assembly Works Works (with CC/AR env) Works (automatic)
Config needed CC, AR, linker, config.toml CC, AR, linker, config.toml None
Both arches from one install No No Yes
Disk usage ~50MB ~180MB ~45MB
Produces static binary No (glibc) Yes Yes

Troubleshooting

Version pinning

# Pin specific versions if needed
- uses: mlugg/setup-zig@v2
  with:
    version: "0.13.0"

- run: pip3 install cargo-zigbuild==0.21.8

If ring fails with zig 0.15+

Some ring versions may need clang 18+ with zig 0.15's bundled libc++ 19 headers:

- run: sudo apt-get install -y clang-18 libclang-18-dev

Or pin to zig 0.13.0 (stable, no issues).

Verify static linking

# Should show "statically linked" for amd64
file ./myapp-amd64
# ELF 64-bit LSB executable, x86-64, statically linked

# ldd should fail or show "not a dynamic executable"
ldd ./myapp-amd64 || true
# not a dynamic executable

# For arm64 (cross-compiled), file shows the arch
file ./myapp-arm64
# ELF 64-bit LSB executable, ARM aarch64, statically linked

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment