How to cross-compile with Cargo

Install the target with rustup and build using the --target flag to compile Rust code for different architectures.

Cross-compilation: building for other machines from your laptop

You wrote a CLI tool on your M2 Mac. It runs fast. Now you need to deploy it to a Raspberry Pi cluster, a Linux server running Alpine, or a Windows machine for a colleague. Copying the binary won't work. The executable format, the CPU instruction set, and the system libraries are all different. The binary is locked to your machine.

Cross-compilation solves this. It lets you build binaries for a different architecture or operating system without installing that system. You keep your development environment on your laptop and produce artifacts that run anywhere. Rust supports cross-compilation from the ground up. The toolchain is designed to target any platform, provided you configure the build correctly.

What a target triple actually means

Rust identifies every platform with a target triple. This string tells the compiler exactly what code to generate. The format is architecture-vendor-os-environment.

  • Architecture: The CPU family. x86_64 for standard Intel/AMD 64-bit, aarch64 for ARM 64-bit, wasm32 for WebAssembly.
  • Vendor: The hardware vendor. Often unknown when it doesn't matter, or apple for macOS.
  • OS: The operating system. linux, darwin (macOS), windows, none for bare metal.
  • Environment: The C library or ABI. gnu for glibc, musl for musl libc, msvc for Windows MSVC runtime.

Example: x86_64-unknown-linux-musl. This targets a 64-bit x86 CPU, any vendor, Linux OS, using the musl C library. The environment part is critical. It determines how the binary links to system functions.

The target triple is the contract between your code and the runtime. If the triple is wrong, the binary fails to link or crashes on startup. Treat the triple as a precise specification, not a guess.

Minimal setup: adding a target and building

Rust separates the standard library for each target. You must install the target's std before you can compile for it. rustup handles this.

# Install the standard library for the target.
# rustup downloads the precompiled std crate for aarch64 Linux.
rustup target add aarch64-unknown-linux-gnu

# Build for the target.
# Cargo passes --target to rustc and uses the target's std.
cargo build --target aarch64-unknown-linux-gnu

The rustup target add command fetches the standard library for that triple. Without it, the compiler has no std to link against and fails. The cargo build --target flag switches the compiler mode. It generates object files for the target architecture and links against the target's standard library.

Convention aside: Run rustup show to list installed targets. The output shows which triples are available on your machine. This is faster than guessing and getting a "target not found" error.

How the build pipeline changes

When you cross-compile, the build pipeline shifts in three places.

  1. Standard library selection: rustc loads the std crate for the target, not the host. This ensures the generated code calls the right system APIs and uses the right data layouts.
  2. Code generation: The compiler emits machine code for the target CPU. Registers, instruction sets, and calling conventions change. A function call on x86_64 looks different on aarch64. The compiler handles this automatically based on the target triple.
  3. Linking: This is where cross-compilation breaks most often. The linker must produce a binary for the target. If you use the host's linker, it tries to link host libraries and produces a host binary. You need a cross-linker or a toolchain that provides the target's linker.

For pure Rust projects with no C dependencies, the Rust toolchain often provides everything needed. The standard library includes the necessary runtime support, and the Rust linker can produce the final binary. The moment you depend on C libraries, the linker needs access to the target's C headers and libraries. This requires a sysroot.

Realistic example: static Linux binaries with musl

Deploying to Linux servers often requires a binary that runs without installing extra packages. The standard gnu environment links dynamically against glibc. The binary depends on the target system having a compatible glibc version. Older servers might have an older glibc, causing the binary to fail.

The musl environment solves this. musl is a lightweight C library designed for static linking. When you build for x86_64-unknown-linux-musl, the linker bundles the C library into the binary. The result is a single file with no external dependencies. It runs on any Linux system, regardless of the installed libraries.

# Add the musl target.
rustup target add x86_64-unknown-linux-musl

# Build a static binary.
# --release is essential for performance and smaller size.
cargo build --target x86_64-unknown-linux-musl --release

Convention aside: Always use --release for cross-compiled binaries. Debug builds include host-specific debug information and run slower. The target machine won't have your source code for debugging anyway. Release builds optimize for the target and strip unnecessary metadata.

Static linking buys you portability. The binary runs anywhere, even if the system is missing libraries. This is why musl targets are the standard for Docker containers and embedded deployments.

The C dependency trap and sysroots

Rust is safe, but it relies on C for system calls. Crates that use libc, openssl, or other C bindings need the C headers and libraries for the target. If you cross-compile without providing these, the build fails.

The compiler rejects you with E0463 (can't find crate for std) if the target is missing. More commonly, you get linker errors like linking with cc failed or cannot find -lssl. These errors mean the linker can't find the target's C libraries.

A sysroot is a directory containing the headers and libraries for the target OS. It acts as a fake root filesystem for the build. The compiler and linker search the sysroot instead of the host's system directories. Setting up a sysroot manually is tedious. You need to download the target's toolchain, extract headers, and configure environment variables.

The community solution is cross. It is a Cargo wrapper that uses Docker to create a clean build environment for the target. It handles sysroots, linkers, and dependencies automatically.

# Install cross globally.
cargo install cross

# Build for a target with C dependencies.
# cross spins up a Docker container with the correct sysroot.
cross build --target aarch64-unknown-linux-gnu

cross detects the target and runs the build inside a container that matches the target platform. The container has the right compiler, linker, and libraries pre-installed. Your local machine stays clean. The build is reproducible across different developer machines.

Convention aside: cross is the community standard for cross-compilation involving C dependencies. It saves hours of sysroot configuration. If your project uses openssl, libsqlite3-sys, or any crate that compiles C code, reach for cross immediately.

build.rs runs on the host

Build scripts run on the host machine, not the target. This is a design choice. build.rs executes during the build process to generate code or compile assets. It has access to the host's filesystem and tools.

This causes a trap when build.rs compiles C code. If you invoke a C compiler directly in build.rs, it uses the host's compiler. The resulting object files are for the host, not the target. Linking fails.

The solution is the cc crate. It detects the target triple and configures the C compiler automatically. It finds the cross-compiler if available and sets the correct flags.

// build.rs
/// Compiles native C code for the target architecture.
fn main() {
    // cc handles cross-compilation flags automatically.
    // It sets CC and CFLAGS based on the target triple.
    cc::Build::new()
        .file("src/native.c")
        .compile("native");
}

The cc crate inspects the TARGET environment variable set by Cargo. It selects the appropriate compiler and flags. If you are cross-compiling, it looks for a cross-compiler named after the target, like aarch64-linux-gnu-gcc. If found, it uses that. Otherwise, it falls back to the host compiler, which may fail later.

Convention aside: Never invoke std::process::Command to run a compiler in build.rs. Use cc or bindgen. These crates understand cross-compilation and handle the complexity for you. Direct commands break the build pipeline.

Per-target configuration with environment variables

Cargo allows per-target configuration through environment variables. This is useful when different targets need different flags or linker arguments.

The pattern is CARGO_TARGET_<TRIPLE>_<VAR>. Replace <TRIPLE> with the target triple in uppercase, replacing hyphens with underscores.

# Set a linker flag for the musl target.
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-C target-feature=+crt-static"

# Build with the custom flag.
cargo build --target x86_64-unknown-linux-musl

This sets RUSTFLAGS only for the specified target. Other targets use the default flags. This keeps your configuration clean and avoids polluting the global build settings.

Convention aside: Use CARGO_TARGET_... variables for per-target tweaks. Avoid hardcoding flags in build.rs. Environment variables are explicit and easier to override in CI pipelines.

Pitfalls and compiler errors

Cross-compilation introduces specific failure modes. Recognizing them saves debugging time.

  • E0463: can't find crate for std. The target is not installed. Run rustup target add <triple>.
  • Linker failures: linking with cc failed. The linker cannot find target libraries. You need a sysroot or a cross-linker. Use cross for complex targets.
  • pkg-config errors: Package xyz was not found. The build script uses pkg-config to find C libraries. pkg-config searches the host by default. Set PKG_CONFIG_SYSROOT_DIR to point to the sysroot, or use cross which configures this automatically.
  • Dynamic linking issues: The binary runs but crashes with library not found. The binary links dynamically against a library missing on the target. Switch to a musl target for static linking, or ensure the target system has the required libraries.
  • Architecture mismatch: The binary runs but crashes immediately. The target triple doesn't match the actual hardware. Verify the CPU architecture with uname -m on the target.

The compiler doesn't care about your OS. It cares about the target triple. If the triple is wrong, the binary is useless. Verify the triple against the target hardware before shipping.

Decision matrix: choosing the right tool

Use cargo build --target when the project is pure Rust or uses crates that handle cross-compilation automatically. This works for most libraries and simple binaries. The Rust toolchain provides the standard library and linker support for many targets.

Use cross when the project depends on C libraries or needs a specific system environment. cross manages sysroots, linkers, and dependencies via Docker. It is the reliable choice for complex builds.

Use musl targets when you need a statically linked binary for Linux deployment. musl produces self-contained binaries that run on any Linux system. This is ideal for containers, embedded devices, and minimal servers.

Use wasm32-unknown-unknown when targeting web browsers or JavaScript runtimes. WebAssembly requires a different toolchain and runtime. The wasm-pack tool builds on top of cargo to handle WASM-specific packaging.

Use rustup target add before every cross-compilation attempt. The target must be installed. This is a one-time setup per target, but forgetting it causes immediate build failures.

Where to go next

Cross-compilation is a feature, not a hack. Rust supports it from the ground up. Master the target triples and the toolchain, and you can ship to any platform from a single laptop. Let Docker handle the sysroot. Your local machine stays clean.