On a Mac, cargo build --target x86_64-unknown-linux-gnu compiles every crate and then dies at the very last step with linking with `cc` failed. Nothing is wrong with your code. The Rust compiler can produce machine code for a dozen architectures out of the box, but it cannot produce a Linux executable without a linker that understands Linux. This guide gets from that error to two working Linux binaries, a 64-bit x86 one and an ARM64 one, both fully static, built on a Mac with Rust 1.90 and edition 2024, then runs them in a container to prove they work. It also covers the case the static trick does not solve, which is code that links against C libraries.
What a target triple tells you
Cargo and rustc name a platform with a triple of the shape architecture-vendor-os-environment. Four triples cover most of what people ship to servers:
| Triple | CPU | C library | Linking |
|---|---|---|---|
x86_64-unknown-linux-gnu | x86-64 | glibc | dynamic against the host's libc.so.6 |
x86_64-unknown-linux-musl | x86-64 | musl | static by default |
aarch64-unknown-linux-gnu | ARM64 | glibc | dynamic |
aarch64-unknown-linux-musl | ARM64 | musl | static by default |
The last component is the whole story of this post. A gnu binary expects the target machine to have a compatible glibc and breaks with version `GLIBC_2.38' not found if the server's is older than the one you built against. A musl binary carries its C library inside itself, so it runs on any kernel of the right architecture, including an empty scratch container. That portability is why musl is the usual answer for "build once, run anywhere on Linux", and why this guide builds musl first.
Ask the toolchain what it knows, and what you have installed:
rustc --print target-list | grep linux
rustup target list --installedInstall the standard library for the target
A compiled Rust program needs a build of std for its target. The toolchain you installed with rustup carries only the one for your own machine, so add the others:
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-muslForget this step and the build fails early, with a message that names the fix:
error[E0463]: can't find crate for `std`
|
= note: the `aarch64-unknown-linux-gnu` target may not be installed
= help: consider downloading the target with `rustup target add aarch64-unknown-linux-gnu`That one is easy. The next one is not.
The failure you will actually hit
A program small enough to read in full, which prints the OS and CPU it was compiled for so we can tell the binaries apart:
fn main() {
println!(
"{} {} {}",
std::env::consts::OS,
std::env::consts::ARCH,
std::mem::size_of::<usize>()
);
}cargo build --release --target x86_64-unknown-linux-gnuerror: linking with `cc` failed: exit status: 1
= note: clang: warning: argument unused during compilation: '-pie' [-Wunused-command-line-argument]
ld: unknown options: --as-needed -Bstatic -Bdynamic --eh-frame-hdr -z --gc-sections -z -z --strip-debug
clang: error: linker command failed with exit code 1 (use -v to see invocation)
error: could not compile `xc` (bin "xc") due to 1 previous errorRead the list after unknown options. Those are GNU linker flags (--as-needed, -Bstatic, --gc-sections), and the program that rejected them is Apple's ld. Rust asked the system cc to link an ELF file, and on a Mac cc is clang driving a Mach-O linker. Every Rust cross-compile failure of this kind has the same shape: the host's default linker does not speak the target's object format. The fix is to name a linker that does.
Use the linker that ships with Rust
Rust includes rust-lld, a build of LLVM's linker that handles ELF, Mach-O, COFF and WebAssembly from any host. For musl targets, which are self-contained, it is enough on its own. Tell Cargo per target in the project's config:
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
[target.aarch64-unknown-linux-musl]
linker = "rust-lld"cargo build --release --target x86_64-unknown-linux-musl
cargo build --release --target aarch64-unknown-linux-muslBoth succeed. Inspect what came out, because a successful build tells you nothing about what it built:
file target/x86_64-unknown-linux-musl/release/xc
file target/aarch64-unknown-linux-musl/release/xctarget/x86_64-unknown-linux-musl/release/xc: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, not stripped
target/aarch64-unknown-linux-musl/release/xc: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, not strippedELF files, for the right architectures, statically linked. The unstripped sizes were 564,392 and 587,848 bytes. Add strip = true, lto = true and panic = "abort" to the release profile (see shrinking a binary) and they become 378,240 bytes for x86-64 and 343,992 for ARM64, a cut of 33% and 41%.
The output directory matters for scripts. A cross build writes to target/<triple>/release/, not target/release/. Pass --target and the host build directory stays untouched, which is also why cargo run with a foreign target fails: Cargo can build it and cannot execute it.
Prove it runs on the real target
A file that says "ARM aarch64" has not run yet. The lowest-friction check on a laptop is a throwaway container, since Docker Desktop runs containers for the other architecture through emulation:
docker run --rm -v "$PWD/target:/t" alpine:3.20 sh -c '
uname -m
/t/aarch64-unknown-linux-musl/release/xc
/t/x86_64-unknown-linux-musl/release/xc'aarch64
linux aarch64 8
linux x86_64 8The host container is aarch64 and runs the ARM64 binary natively. The x86-64 binary ran under emulation, so treat that as "it executes", not as a performance figure. The third column is size_of::<usize>(), 8 on both, confirming 64-bit pointers. For a binary that does real work, run its test suite inside the container, not just its first line of output.
Statically linked is not the same as portable
The musl route comes with costs that deserve to be named before you ship.
- The allocator. musl's
mallocis simple and can be markedly slower under many threads than glibc's. Services that allocate heavily often swap inmimallocorjemallocas the#[global_allocator]. Benchmark your workload; we did not measure one here. - DNS and name services. musl's resolver does not read
nsswitch.confand handles someresolv.confoptions differently. Programs that depend on custom NSS modules behave differently. - Dynamic loading. A statically linked musl binary cannot
dlopena shared library, so plugin systems and some database drivers do not work. - Crates with C code. If a dependency compiles C, which includes
openssl-sysandring's assembly, the build needs a C cross-compiler too. This is the next section.
When the dependency has C in it
Pure-Rust crates cross-compile with the steps above. A crate with a build.rs that invokes a C compiler through the cc crate needs a compiler for the target. The failure comes from the build script rather than the linker, and names the tool it could not find (the exact wording varies with the crate and the cc version):
error: failed to run custom build command for `<crate>`
--- stderr
error occurred in cc-rs: failed to find tool "<triple>-gcc": No such file or directoryThree ways to supply that tool, from least to most setup:
- Prefer a pure-Rust alternative.
rustlswith theaws-lc-rsorringbackend still compiles C or assembly, but a TLS stack built onrustls-rustcrypto, or avoiding TLS in the binary altogether by terminating it at a proxy, removes the problem. - Use Zig as the C compiler and linker.
cargo install cargo-zigbuildandcargo zigbuild --target aarch64-unknown-linux-gnu.2.17builds against a pinned minimum glibc version, which is the cleanest fix for theGLIBC_2.38 not foundproblem ongnutargets. Install Zig from your package manager. We did not run this path for this post. - Build inside a container for the target, with the real toolchain.
crossautomates it:cargo install cross, thencross build --release --target aarch64-unknown-linux-gnuin place ofcargo. It needs Docker or Podman, and downloads an image the first time.
Whichever you pick, set the matching environment variables for cc-rs when you point it at a specific compiler: CC_aarch64_unknown_linux_musl and AR_aarch64_unknown_linux_musl, with the triple's hyphens replaced by underscores.
A gnu target from Linux
If you are on a Linux machine, producing an ARM64 glibc binary is a matter of installing the cross toolchain from your distribution and naming it:
sudo apt install gcc-aarch64-linux-gnu
rustup target add aarch64-unknown-linux-gnu[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"That GCC links against the glibc version in your distribution's cross sysroot, so the resulting binary runs on machines whose glibc is the same age or newer. Build on the oldest distribution you support, or use Zig's pinned versions, and the binary will run on everything after it. That one rule, "link against the oldest glibc you intend to support", is the entire story of the version error that appears when you build on a new machine and deploy to an old one.
Choosing between musl and gnu
Most teams should build musl for command-line tools and for services that are pure Rust, and gnu for anything that touches C libraries, loads plugins or needs the best allocator throughput without swapping one in. The decision reduces to three questions, in this order.
- Does any dependency need a C library that is only available dynamically? If so,
gnu, and build against the oldest glibc you deploy to. - Do you run in a container with a minimal base? A static musl binary can sit in a
scratchimage of a few hundred kilobytes with nothing else, which makes the image trivial to scan and to ship. - Is allocation throughput on many threads your bottleneck? Then measure musl against glibc under your load before committing, and consider a replacement allocator on either.
The container case is common enough to show whole. A multi-stage Dockerfile builds the static binary in a Rust image and copies it into an empty one:
FROM rust:1.90-alpine AS build
RUN apk add --no-cache musl-dev
WORKDIR /src
COPY . .
RUN cargo build --release
FROM scratch
COPY --from=build /src/target/release/app /app
ENTRYPOINT ["/app"]On an Alpine-based Rust image the default target is already musl, so no --target is needed, and the build runs natively for whatever architecture the builder uses. Pass --platform linux/arm64 to docker build and the same file produces the ARM64 image. The caveat for scratch is that it has no CA certificates and no timezone data. A program that makes HTTPS requests needs the certificates copied in, or a library that bundles them, such as webpki-roots, otherwise the first request fails with a certificate error that looks like a network problem.
Writing code that differs per target
Cross-compiling exposes code that assumed the build machine. Rust's conditional compilation attributes select code by target, and the compiler evaluates them for the target, not the host:
fn describe() -> &'static str {
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
"linux on arm64"
} else if cfg!(target_os = "linux") {
"linux on something else"
} else {
"not linux"
}
}
#[cfg(target_pointer_width = "64")]
const WIDTH: &str = "64-bit";
#[cfg(not(target_pointer_width = "64"))]
const WIDTH: &str = "not 64-bit";
fn main() {
println!("{} ({WIDTH})", describe());
}Built for the ARM64 musl target and run in the Alpine container from earlier, this prints linux on arm64 (64-bit). The trap is in build.rs. A build script runs on the host, so cfg!(target_os = "linux") inside it describes your Mac, not the target. A build script reads the real target from the environment variables Cargo sets, CARGO_CFG_TARGET_OS and CARGO_CFG_TARGET_ARCH:
fn main() {
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap();
println!("cargo::warning=building for {os}/{arch}");
}Using the wrong one is the source of the most confusing cross-compile bug: the build succeeds, the right file is produced, and it contains code selected for the machine that built it.
Setting things without editing files
Every [target.*] and [build] key in .cargo/config.toml has an environment variable equivalent, which is how CI sets them without a committed file. The target and linker for one build:
export CARGO_BUILD_TARGET=aarch64-unknown-linux-musl
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld
cargo build --releaseThe triple is uppercased with hyphens replaced by underscores. Setting CARGO_BUILD_TARGET also changes where artifacts land, so scripts that hardcode target/release/ stop finding them. A project that cross-compiles by default can write [build] target = "aarch64-unknown-linux-musl" in its config, but that surprises the next contributor who runs cargo test and gets a binary their machine cannot execute. Prefer an explicit --target in scripts and CI, and leave the default alone.
Run the tests for another architecture
A cross-compiled test binary can run under the architecture's emulator if you tell Cargo how. runner is a per-target setting that names a program to prefix to the executable:
[target.aarch64-unknown-linux-musl]
linker = "rust-lld"
runner = "qemu-aarch64"With qemu-user installed on a Linux host, cargo test --target aarch64-unknown-linux-musl then builds the tests for ARM64 and executes each under emulation. On a Mac, where that is not available, the container approach from earlier does the same job in CI. What you must not do is run only the host tests and assume the foreign build is fine: endianness, pointer width and alignment bugs surface only on the other architecture, and 32-bit ARM targets will disagree with you on usize.
The ways a cross build lies to you
A cross-compiled binary that builds cleanly can still be the wrong binary, and the mistakes come in a small number of repeating shapes. Knowing them saves a deploy.
It ran on your machine because it is your machine's binary. If cargo run works and the Linux server rejects the file with Exec format error, you shipped target/release/app from a host build instead of target/<triple>/release/app. The two directories sit side by side and the names are identical. Check with file before you copy anything.
The test ran on the host. cargo test with no --target compiles and runs for your own architecture, so a green test suite says nothing about ARM64. We ran the cfg program above inside an ARM64 Alpine container and it printed linux on arm64 (64-bit); the same source built natively on the Mac prints not linux (64-bit). Same code, different answer, which is the entire reason to run the artifact you ship.
The glibc version moved. A gnu binary built on a recent Linux distribution links against its newer symbols and fails on an older server with version `GLIBC_2.38' not found. List what a binary requires with objdump -T app | grep GLIBC_ and compare the highest version against the oldest machine you deploy to.
A feature differs per target. Some crates enable different features on different platforms through [target.'cfg(...)'.dependencies]. Features unify across the dependency graph per target, so a feature that was off in your host build can be on in the cross build, or the other way round. When a cross build pulls in a crate you have never seen, run cargo tree --target aarch64-unknown-linux-musl and compare it with the host tree.
All four come down to verifying the artifact instead of the build. A script of five lines (build, file, run in a container, check the exit code, compare the size to a budget) removes the class of failures where everything looked fine until it reached the server.
A CI matrix that builds all of it
The same four builds in a workflow, with the output uploaded under a name that carries the triple:
name: release
on:
push:
tags: ["v*"]
jobs:
build:
strategy:
matrix:
target:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- run: cargo build --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: app-${{ matrix.target }}
path: target/${{ matrix.target }}/release/appOn an Ubuntu runner, the aarch64 musl build needs the same rust-lld setting in the repository's .cargo/config.toml, which is why that file belongs in version control and not in your home directory. If it lives only on your laptop, CI hits the linking with `cc` failed error and the reason is invisible. Packaging the result into release archives with checksums is the job of cargo dist, which automates this matrix.
Check the architecture before you ship
Two habits catch most disasters. First, run file on every artifact in the pipeline and fail the job if the output does not contain the expected architecture string; a mislabelled upload is a classic way to send an x86 binary to ARM servers. Second, name the artifact with the triple, as above, so no one has to guess. A release page with a file called app and no suffix is a support ticket.
Where this recipe stops
Everything here targets Linux. macOS and Windows have their own rules: you can cross-compile for them, but you need their SDKs or linkers, and Apple's license restricts where its SDK may be used. Building Windows executables from Linux with x86_64-pc-windows-gnu and MinGW, or cargo xwin for the MSVC ABI, is a separate setup. Embedded targets such as thumbv7em-none-eabihf are not "Linux with another CPU": they have no OS, no std by default and a different link story, covered in the embedded Rust guide.
If your program must load plugins, use PAM or NSS, or link a vendor's shared library, musl static linking is the wrong tool; build the gnu target against the oldest glibc you support and ship it dynamically. And if you need only one extra architecture for a handful of machines, building natively on a machine of that architecture, such as a small ARM instance, is less work than any of the above.
Next steps
Add the two [target.*] blocks to .cargo/config.toml and commit it. Add strip = true and lto = true to the release profile. Run the produced binaries in a container as part of CI, and fail the build if file reports the wrong architecture. From there the remaining question is distribution, which is archive formats and checksums, not compilation.



