How Do I Build a Rust Binary for Multiple Targets?

Use one GitHub Actions matrix pairing each Rust target triple with a runner label, building natively where a runner exists and cross compiling where none does.

Last verified:

Build one job per target in a GitHub Actions matrix, pairing every Rust target triple with a runner label of the same architecture and operating system, and pass that triple to cargo build --target. Build natively wherever a runner of that architecture exists, and reach for cross compilation only for the targets with no matching runner, such as the musl target on a glibc host or the x86_64 Apple target on an Apple Silicon machine.

Answer

The matrix is the whole mechanism. A matrix strategy expands one job definition into one job per combination, and because runs-on accepts an expression, each expansion can land on a different machine. That turns "build for six targets" into "declare six pairs of triple and label".

Four of the six common release targets have a native host. Rates and shapes below come from the cloud runners documentation, checked on 2026-08-13.

Rust target tripleRunner labelShapePer minuteNative or cross
x86_64-unknown-linux-gnuwarp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016Native
x86_64-unknown-linux-muslwarp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016Cross, same architecture
aarch64-unknown-linux-gnuwarp-ubuntu-latest-arm64-8x8 vCPU, 32 GB$0.012Native
aarch64-apple-darwinwarp-macos-latest-arm64-6x6 vCPU, 22 GB$0.08Native
x86_64-apple-darwinwarp-macos-latest-arm64-6x6 vCPU, 22 GB$0.08Cross, same SDK
x86_64-pc-windows-msvcwarp-windows-latest-x64-4x4 vCPU, 16 GB$0.016Native

The two cross rows are the cheap kind of cross compilation. x86_64-unknown-linux-musl runs on the same x86_64 host and needs only rustup target add plus the musl-tools package for the linker. x86_64-apple-darwin links on the Apple Silicon runner because Apple's SDK carries both architectures, and WarpBuild macOS images carry the same tooling as the GitHub-hosted macOS image (preinstalled software). The expensive kind, an aarch64 glibc binary built on an x86_64 host, needs a separate cross linker and a per-target linker variable, which the native ARM64 runner removes entirely.

Here is the workflow. It fires on tags, keeps fail-fast: false so one broken target does not cancel the rest of the release, and uploads a per-target artifact.

name: release

on:
  push:
    tags: ["v*"]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            runner: warp-ubuntu-latest-x64-8x
            cache-provider: warpbuild
          - target: x86_64-unknown-linux-musl
            runner: warp-ubuntu-latest-x64-8x
            cache-provider: warpbuild
            apt: musl-tools
          - target: aarch64-unknown-linux-gnu
            runner: warp-ubuntu-latest-arm64-8x
            cache-provider: warpbuild
          - target: aarch64-apple-darwin
            runner: warp-macos-latest-arm64-6x
            cache-provider: github
          - target: x86_64-apple-darwin
            runner: warp-macos-latest-arm64-6x
            cache-provider: github
          - target: x86_64-pc-windows-msvc
            runner: warp-windows-latest-x64-4x
            cache-provider: github
            ext: .exe
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4

      - run: rustup toolchain install stable --profile minimal
      - run: rustup target add ${{ matrix.target }}

      - if: matrix.apt
        run: |
          sudo apt-get update
          sudo apt-get install -y ${{ matrix.apt }}

      - uses: WarpBuilds/rust-cache@v2
        with:
          cache-provider: ${{ matrix.cache-provider }}
          shared-key: release-${{ matrix.target }}

      - run: cargo build --release --locked --target ${{ matrix.target }}

      - uses: actions/upload-artifact@v4
        with:
          name: mytool-${{ matrix.target }}
          path: target/${{ matrix.target }}/release/mytool${{ matrix.ext }}
          if-no-files-found: error

Pricing on WarpBuild is purely usage based.

Detail

Why the target triple has to travel with the runner label

Passing --target changes the output path. Without it, cargo build --release writes to target/release/; with it, the artifacts land in target/<triple>/release/ (cargo build reference). That is why the upload step interpolates the triple into the path, and why if-no-files-found: error earns its place: a typo in the triple produces an empty upload rather than a failed job.

The ${{ matrix.ext }} suffix handles the one platform difference in the binary name. Undefined matrix keys evaluate to an empty string, so the five non-Windows legs read the same expression and get nothing appended.

Artifact names have to be unique. actions/upload-artifact@v4 refuses a second upload under a name that already exists in the run, so the triple belongs in the artifact name as well as the path. A downstream release job then downloads with pattern: mytool-* and merge-multiple: true and attaches every binary to one tag.

Where cross compiling still earns its keep

Two targets in the table have no runner of their own, and both are cheap because the host architecture already matches or the SDK already ships the second slice.

The musl target is a Tier 2 target with host tools in the Rust platform support list, which is why the toolchain installs cleanly and only the C linker is missing. Install musl-tools, add the target, and the static binary comes out of the same 8 vCPU Linux runner that built the gnu binary. Running both on one runner in one job is a fair simplification once the workspace compiles quickly, since the second cargo build reuses the warm registry.

The aarch64 glibc target is the one worth building natively. Cross compiling it from x86_64 means installing gcc-aarch64-linux-gnu, setting CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER, and repeating that work for every C dependency with a build script. The cross compiling compared with native ARM64 builds guide walks through the tradeoff. One detail to carry into scripts: Ubuntu 24.04 ARM64 runners set the work directory to /runner/_work, where GitHub-hosted runners use /home/runner/work/, so absolute paths hard coded in release scripts need a github.workspace reference instead.

Cache support differs by platform, which is why the matrix carries a cache-provider key. The WarpBuild cache is enabled by default on Linux runners, and the caching documentation records that Windows runners fall outside it, so the macOS and Windows legs keep the action's default github provider while the three Linux legs use warpbuild.

Pricing the release matrix

Take a six target release with the minutes a mid sized workspace tends to spend. Rates come from the cloud runners documentation, checked on 2026-08-13.

TargetRunner labelRateMinutesCost
x86_64-unknown-linux-gnuwarp-ubuntu-latest-x64-8x$0.0169$0.144
x86_64-unknown-linux-muslwarp-ubuntu-latest-x64-8x$0.01610$0.160
aarch64-unknown-linux-gnuwarp-ubuntu-latest-arm64-8x$0.0129$0.108
aarch64-apple-darwinwarp-macos-latest-arm64-6x$0.088$0.640
x86_64-apple-darwinwarp-macos-latest-arm64-6x$0.087$0.560
x86_64-pc-windows-msvcwarp-windows-latest-x64-4x$0.01612$0.192

That is 55 minutes and $1.804 per release, or $36.08 a month at twenty tagged releases. The two macOS legs carry $1.20 of the $1.804, about two thirds of the bill from a quarter of the targets, which is the number that should drive the next decision. Merge the two Apple targets into one job and the checkout, toolchain install, and cargo registry warm up happen once instead of twice: at 13 minutes for the merged job the darwin work drops to $1.04, saving $0.16 per release and $3.20 a month, and the same job can run lipo -create to emit a universal binary.

Two of the legs price directly against GitHub's published rates. warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price, so the 9 minute ARM64 leg is $0.108 against $0.126. warp-macos-latest-arm64-6x (6 vCPU, 22 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price, so 15 minutes of darwin work is $1.20 against $1.53. GitHub list prices come from the GitHub Actions billing reference and shapes from the GitHub-hosted runner specs, both checked on 2026-08-13. Run the same arithmetic against your own minutes on the pricing page.

Which leg to shrink first is a measurement question rather than a guess. CI observability sits in the product surface alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger, and it reports per job system metrics so you can see whether the slowest target is starved of CPU or waiting on a linker. Sizing guidance for the compile heavy legs is in fast Rust builds on GitHub Actions.

Do I need a separate job for each Rust target?

One job per target is the default shape, because runs-on is fixed for the life of a job and each target wants a machine of its architecture. Targets sharing a host can be merged: the two Apple targets fit in one macOS job, and the gnu and musl Linux x64 targets fit in one Linux job. The release build workflow guide covers how far to take that merging before the matrix stops being readable.

How do I build a universal macOS binary in GitHub Actions?

Build aarch64-apple-darwin and x86_64-apple-darwin in the same job on warp-macos-latest-arm64-6x, then run lipo -create target/aarch64-apple-darwin/release/mytool target/x86_64-apple-darwin/release/mytool -output mytool-universal and upload the merged file. Both targets link on that runner because the image carries the same Apple tooling as the GitHub-hosted macOS image (preinstalled software).

Should I cross compile aarch64 Linux instead of using an ARM64 runner?

Native is the simpler path when a native runner exists, and warp-ubuntu-latest-arm64-8x at $0.012 per minute prices below the x64 runner of the same shape. Cross compiling adds a linker package, a per-target linker variable, and a second set of failure modes in every C dependency with a build script. The cross compiling comparison has the full breakdown.

What does a six target Rust release matrix cost per run?

About $1.80 at catalog rates, for 55 minutes across two Linux x64 legs, one Linux ARM64 leg, two macOS legs, and one Windows leg. The macOS legs are $1.20 of it.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.