How to Package Rust CLI Tools for Multiple Platforms

Cli
Compile Rust CLI tools for multiple platforms using cargo build with release profiles and target triples.

The "Works on My Machine" Trap

You spend a weekend building a CLI tool in Rust. It parses config files, hits an API, and formats output beautifully. cargo run works perfectly. You zip up the binary from target/release and email it to a friend running Linux. They run it and get Exec format error. You try it on a Windows VM and get a missing DLL warning. The binary you built is tied to your CPU architecture and your operating system's libraries. It is useless anywhere else.

Rust solves this with cross-compilation. The compiler can run on your Mac and generate a binary for Linux, Windows, or even embedded devices. You don't need a fleet of machines. You need the right target configuration and a strategy for dependencies.

Anatomy of a Target Triple

Rust identifies every platform with a string called a target triple. It looks like x86_64-unknown-linux-gnu. This isn't random text. Each part tells the compiler exactly what code to generate and how to link it.

  • Architecture: x86_64 means 64-bit Intel or AMD CPUs. aarch64 is for ARM chips like Apple Silicon or Raspberry Pi.
  • Vendor: unknown is the standard placeholder. It means the toolchain is generic, not tied to a specific hardware vendor. You will almost always see unknown here.
  • OS: linux, windows, darwin (macOS), or freebsd. This tells the compiler which system calls to use.
  • Environment: gnu means the binary expects the GNU C library (glibc). msvc means Microsoft Visual C++ runtime. musl means the musl C library. This part controls how the binary links to system libraries.

Convention aside: The vendor field is unknown for 99% of targets. Don't try to put your name or company there. The toolchain expects unknown.

Cross-Compiling with Rustup

Rustup manages your toolchain and targets. To cross-compile, you first add the target to your installation. This downloads the standard library for that platform.

# Add the Linux target to your local rustup installation
rustup target add x86_64-unknown-linux-gnu

# Add the Windows target
rustup target add x86_64-pc-windows-msvc

# Add the macOS target
rustup target add x86_64-apple-darwin

Once the target is installed, you pass it to cargo build. The compiler switches modes and generates code for the specified platform.

# Build a release binary for Linux
cargo build --release --target x86_64-unknown-linux-gnu

# The binary appears in a target subdirectory named after the triple
# target/x86_64-unknown-linux-gnu/release/your-cli-tool

The compiler checks the target triple, loads the appropriate standard library, and emits object code. If your crate is pure Rust with no C dependencies, this works instantly. The resulting binary is ready to ship.

The C Dependency Wall

Pure Rust crates cross-compile easily. The moment you pull in a crate that wraps C code, things get harder. Crates like openssl-sys, libz-sys, or libsqlite3-sys need the C headers and libraries for the target platform.

If you are on macOS and try to build for Linux, your machine doesn't have Linux headers. The build fails with a linker error. The compiler cannot find the C libraries it needs.

error: linker `x86_64-linux-gnu-gcc` not found

This error means the build system is looking for a C compiler that targets Linux, but your Mac only has a C compiler for macOS. You have two paths forward. You can install a cross-compiler toolchain manually, or you can use static linking to bundle everything into the binary.

Static Linking and Musl

Static linking bundles all dependencies, including the C library, directly into the binary. The binary becomes larger, but it runs on any system without needing external libraries installed. This is the gold standard for distributing CLI tools on Linux.

The gnu environment links dynamically against glibc. Linux distributions have different versions of glibc. A binary built on Ubuntu 22.04 might crash on Debian 10 because the glibc version is too old. The musl environment avoids this problem. Musl is a lightweight C library designed for static linking.

Use x86_64-unknown-linux-musl as your target. This produces a statically linked binary that runs on almost any Linux system.

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

# Build a statically linked Linux binary
cargo build --release --target x86_64-unknown-linux-musl

Convention aside: The community prefers musl for Linux releases of CLI tools. It eliminates glibc version conflicts. Users can run the binary on Alpine, Arch, Debian, or RHEL without headaches.

Some crates struggle with musl. If a crate uses pkg-config to find C libraries, it might fail. The cross crate solves this by running builds inside Docker containers pre-configured for each target. It handles the C toolchains and headers automatically.

# In Cargo.toml
[build-dependencies]
cross = "0.2"

The cross crate is the community standard for crates with C dependencies. It wraps Docker to give you a clean build environment. You run cross build --release --target x86_64-unknown-linux-musl and it handles the rest.

Automating with GitHub Actions

Manual builds don't scale. You need a CI pipeline that builds for every platform on every push. GitHub Actions makes this straightforward with a matrix strategy.

name: Release

on:
  push:
    tags:
      - "v*"

jobs:
  build:
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
          - os: macos-latest
            target: x86_64-apple-darwin
          - os: windows-latest
            target: x86_64-pc-windows-msvc

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

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

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: binary-${{ matrix.target }}
          path: target/${{ matrix.target }}/release/your-cli-tool

The matrix defines each platform combination. The job runs in parallel for Linux, macOS, and Windows. Rustup installs the target, cargo build compiles the binary, and the artifact is uploaded. This workflow ensures you always have fresh binaries for every supported platform.

Convention aside: Use dtolnay/rust-toolchain instead of rust-lang/setup-rust-toolchain. It's faster and handles target installation more reliably.

Packaging the Release

A binary alone isn't a complete release. Users need checksums to verify integrity. They might need a signature to trust the source. Package the binary with a checksum file and compress it.

# Generate a SHA-256 checksum
sha256sum target/x86_64-unknown-linux-musl/release/your-cli-tool > checksums.txt

# Create a compressed archive
tar -czvf your-cli-tool-linux-x86_64.tar.gz \
  target/x86_64-unknown-linux-musl/release/your-cli-tool \
  checksums.txt

On Windows, use zip instead of tar. Include the checksum file in the zip. Sign the archive with GPG if you have a key. This gives users everything they need to verify and install the tool.

Pitfalls and Fixes

Cross-compilation fails in predictable ways. Knowing the errors saves hours of debugging.

Linker not found: error: linker 'x86_64-linux-gnu-gcc' not found. This happens when you target gnu without a cross-compiler installed. Switch to musl or install the cross-toolchain. On Ubuntu, run sudo apt install gcc-x86-64-linux-gnu.

Pkg-config errors: error: failed to run custom build command for 'libz-sys'. The crate needs pkg-config to find C headers. The cross crate handles this. If you are building manually, install the -dev packages for the target.

TLS dependency hell: error: could not find system library 'ssl'. Crates using native-tls pull in platform-specific C libraries. This breaks cross-compilation on Windows and Linux. Switch to rustls. It's pure Rust and cross-compiles everywhere.

Convention aside: Prefer rustls over native-tls for CLI tools. native-tls drags in OpenSSL or Schannel, which complicates cross-compilation. rustls adds zero C dependencies and works out of the box.

E0277 trait bound errors: These are compile-time errors, not cross-compilation issues. If you see E0277, check your generic constraints. The error means a type doesn't implement a required trait. Fix the code, not the build target.

Choosing Your Strategy

Pick the approach that matches your crate's complexity and your users' needs.

Use cargo install when your users already have Rust installed and you want zero maintenance. They compile the source themselves, so you don't need to manage binaries.

Use binary releases when you want instant startup and no build step for users. This is the standard for CLI tools. Users download and run immediately.

Use x86_64-unknown-linux-musl when targeting Linux distributions with varying glibc versions. Static linking ensures the binary runs everywhere.

Use cross when your crate depends on C libraries that are hard to cross-compile manually. It handles Docker containers and toolchains automatically.

Use rustls when you need TLS and want to avoid platform-specific C dependencies. It simplifies cross-compilation and reduces build failures.

Use GitHub Actions matrix builds when you need to release for multiple platforms consistently. Automate the process so every tag produces binaries for all targets.

Static linking buys you portability. Take the deal. The cross crate saves hours of linker debugging. Use it. Don't ship a binary that only runs on your machine.

Where to go next