First time I tried to cross-compile a Rust binary from my Mac to Linux, I ran cargo build --target x86_64-unknown-linux-gnu and got hit with a wall of linker errors. Missing cc, wrong libc, something about crt1.o. It felt like the “build once, run anywhere” promise was a lie. It wasn’t — I just didn’t understand how Rust’s compilation model interacts with system libraries. Once that clicked, cross-compilation became routine.
How Rust Compilation Works
Rust compiles to LLVM IR, then LLVM generates machine code for the target architecture. That part works across platforms — LLVM knows how to emit x86_64, aarch64, arm, riscv, wasm, and more. The problem is the linker.
After LLVM generates object files, a linker stitches them together into a binary. The linker needs to know about the target’s system libraries, C runtime, and ABI. On your Mac, the system linker knows about macOS. It has no clue about Linux’s glibc or Windows’ msvcrt.
This means cross-compilation in Rust has two cases:
- Pure Rust, no system dependencies — Easy. Provide the right linker and you’re done.
- Uses C libraries (OpenSSL, SQLite, etc.) — Harder. You need cross-compiled versions of those libraries.
Setting Up Targets
First, install the target you want to build for:
# List all available targets
rustup target list
# Install common targets
rustup target add x86_64-unknown-linux-gnu
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-gnu
rustup target add x86_64-pc-windows-gnu
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
This installs the Rust standard library compiled for each target. But you still need a linker.
The Easy Path: cross
The cross tool from the cross-rs project wraps cargo and handles all the linker complexity using Docker containers:
cargo install cross
# Build for Linux from macOS — just works
cross build --target x86_64-unknown-linux-gnu --release
cross build --target aarch64-unknown-linux-gnu --release
# Build for Windows from macOS
cross build --target x86_64-pc-windows-gnu --release
# Run tests on the target platform (inside Docker)
cross test --target x86_64-unknown-linux-gnu
cross pulls a Docker image with the right compiler toolchain, linker, and system libraries for the target. Your source code is mounted into the container, compiled, and the binary comes back out. It handles OpenSSL, zlib, and other common C dependencies.
The downside: Docker. You need Docker running, the first build pulls a ~1GB image, and builds are slower because you’re running in a container. For CI, this is fine. For rapid iteration during development, it’s painful.
Manual Cross-Compilation: Linux musl
For CLI tools, the best target is often x86_64-unknown-linux-musl. musl is an alternative C library that supports fully static linking. The resulting binary has zero runtime dependencies — no shared libraries, no specific glibc version. It runs on any Linux system, period.
On macOS, you need a musl cross-compiler:
# Install musl cross-compiler toolchain
brew install filosottile/musl-cross/musl-cross
# Add the target
rustup target add x86_64-unknown-linux-musl
Tell cargo which linker to use. In .cargo/config.toml:
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
Now build:
cargo build --target x86_64-unknown-linux-musl --release
The resulting binary at target/x86_64-unknown-linux-musl/release/myapp is completely static:
# On the Linux machine, verify it's static
$ file myapp
myapp: ELF 64-bit LSB executable, x86-64, statically linked
$ ldd myapp
not a dynamic executable
No glibc dependency means it runs on Alpine, Debian, Ubuntu, CentOS, Amazon Linux — any x86_64 Linux, even minimal Docker containers built FROM scratch.
macOS Universal Binaries
Apple’s transition from Intel to ARM means you often want a universal binary that runs on both:
# Install both targets
rustup target add x86_64-apple-darwin
rustup target add aarch64-apple-darwin
# Build both
cargo build --target x86_64-apple-darwin --release
cargo build --target aarch64-apple-darwin --release
# Combine into universal binary
lipo -create \
target/x86_64-apple-darwin/release/myapp \
target/aarch64-apple-darwin/release/myapp \
-output myapp-universal
lipo is Apple’s tool for creating fat/universal binaries. The result runs natively on both Intel and Apple Silicon Macs without Rosetta translation.
Cargo Configuration for Multiple Targets
When you’re regularly building for multiple targets, set up .cargo/config.toml in your project:
# Default to release optimizations during cross-compilation
# (you'll pass --release anyway, but this documents your intent)
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-unknown-linux-gnu-gcc"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-unknown-linux-gnu-gcc"
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
Build Script for All Targets
Here’s the Makefile pattern I use for multi-platform builds:
NAME := myapp
VERSION := $(shell grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
TARGETS := \
x86_64-unknown-linux-musl \
aarch64-unknown-linux-musl \
x86_64-apple-darwin \
aarch64-apple-darwin \
x86_64-pc-windows-gnu
.PHONY: all clean $(TARGETS)
all: $(TARGETS)
x86_64-unknown-linux-musl:
cross build --release --target $@
cp target/$@/release/$(NAME) dist/$(NAME)-$(VERSION)-linux-amd64
aarch64-unknown-linux-musl:
cross build --release --target $@
cp target/$@/release/$(NAME) dist/$(NAME)-$(VERSION)-linux-arm64
x86_64-apple-darwin:
cargo build --release --target $@
cp target/$@/release/$(NAME) dist/$(NAME)-$(VERSION)-darwin-amd64
aarch64-apple-darwin:
cargo build --release --target $@
cp target/$@/release/$(NAME) dist/$(NAME)-$(VERSION)-darwin-arm64
x86_64-pc-windows-gnu:
cross build --release --target $@
cp target/$@/release/$(NAME).exe dist/$(NAME)-$(VERSION)-windows-amd64.exe
clean:
rm -rf dist/
cargo clean
GitHub Actions: The Real Answer
For most projects, you shouldn’t cross-compile locally at all. Let CI do it. Each platform builds on its native runner:
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: linux-amd64
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
name: linux-arm64
cross: true
- target: x86_64-apple-darwin
os: macos-latest
name: darwin-amd64
- target: aarch64-apple-darwin
os: macos-latest
name: darwin-arm64
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.cross
run: cargo install cross
- name: Build (cross)
if: matrix.cross
run: cross build --release --target ${{ matrix.target }}
- name: Build (native)
if: "!matrix.cross"
run: cargo build --release --target ${{ matrix.target }}
- name: Package (unix)
if: runner.os != 'Windows'
run: |
cd target/${{ matrix.target }}/release
tar czf ../../../myapp-${{ matrix.name }}.tar.gz myapp
cd ../../..
- name: Package (windows)
if: runner.os == 'Windows'
run: |
cd target/${{ matrix.target }}/release
7z a ../../../myapp-${{ matrix.name }}.zip myapp.exe
cd ../../..
- name: Upload release assets
uses: softprops/action-gh-release@v1
with:
files: |
myapp-*.tar.gz
myapp-*.zip
macOS targets build on macOS runners. Windows builds on Windows with MSVC (better compatibility than MinGW). Linux builds on Ubuntu with musl for static linking. Each build runs natively — no cross-compilation headaches.
Conditional Compilation
Sometimes you need platform-specific code. Rust’s cfg attributes handle this:
use std::path::PathBuf;
fn config_dir() -> PathBuf {
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config").join("myapp")
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/Shared".to_string());
PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("myapp")
}
#[cfg(target_os = "windows")]
{
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| "C:\\Users\\Public".to_string());
PathBuf::from(appdata).join("myapp")
}
}
fn open_browser(url: &str) {
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(url).spawn();
}
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/c", "start", url])
.spawn();
}
}
fn main() {
println!("Config dir: {}", config_dir().display());
// open_browser("https://example.com");
}
In practice, use the directories crate for config paths and the open crate for browser opening. But understanding cfg is essential for cases where crates don’t exist.
Handling C Dependencies
The biggest cross-compilation headache is C dependencies. OpenSSL is the classic problem child. Options:
Option 1: Use pure Rust alternatives. rustls instead of OpenSSL, rusqlite with the bundled feature instead of linking to system SQLite. This is almost always the right choice for CLI tools.
[dependencies]
# Instead of openssl:
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }
# Instead of linking to system sqlite:
rusqlite = { version = "0.31", features = ["bundled"] }
Option 2: Vendor the C library. Some crates support a vendored or bundled feature that compiles the C library from source during cargo build:
[dependencies]
openssl = { version = "0.10", features = ["vendored"] }
Option 3: Use cross. It has the system libraries pre-installed in its Docker images.
My advice: avoid C dependencies in CLI tools whenever possible. Pure Rust dependencies cross-compile without any toolchain setup. That single decision eliminates 90% of cross-compilation problems.
Stripping and Optimizing Binaries
Release binaries can be surprisingly large. A simple clap-based CLI can be 4MB+. Here’s how to slim it down:
# Cargo.toml
[profile.release]
opt-level = "z" # Optimize for size instead of speed
lto = true # Link-time optimization — slower builds, smaller binary
codegen-units = 1 # Single codegen unit — slower builds, better optimization
panic = "abort" # Don't include unwinding code
strip = true # Strip debug symbols
Before: ~4.2MB. After: ~1.1MB. For a CLI tool, opt-level = "z" is usually fine — the performance difference is negligible when your bottleneck is I/O or network, not CPU.
On Linux, you can also use upx to compress the binary further, but it adds startup overhead and can trigger false positives with antivirus software. I generally skip it.
Cross-compilation in Rust is genuinely good once you understand the tooling. Use cross for CI, musl for static Linux binaries, and pure Rust dependencies to avoid headaches. Next up — distribution. Getting your binary into users’ hands via Homebrew, GitHub Releases, and cargo install.