Building Portable Rust CLI Binaries with Nix — Static Linux, Portable macOS

5 min read

You build a Rust binary with Nix. It works on your machine. Ship it to a colleague’s Linux box and it segfaults or complains about missing glibc. Ship the macOS binary to someone without Nix and it fails with dyld: Library not loaded: /nix/store/.../libiconv.2.dylib. The binary has Nix store paths burned into its dynamic linker references.

What you actually want:

  • Linux: a truly static binary. Zero runtime deps. Runs on any distro, any container, bare metal.
  • macOS: a portable binary that only depends on system libraries (libSystem, libiconv) at their standard paths, not Nix store paths.

This post covers how to get both from a single flake, plus cross-compilation between platforms.

The baseline package

Start with a standard rustPlatform.buildRustPackage:

packages.my-cli = pkgs.rustPlatform.buildRustPackage {
  pname = "my-cli";
  version = "0.1.0";
  src = ./.;
  cargoLock.lockFile = ./Cargo.lock;
};

If your CLI lives in a subdirectory of a monorepo, use buildAndTestSubdir and copy the lockfile up:

packages.my-cli = pkgs.rustPlatform.buildRustPackage {
  pname = "my-cli";
  version = "0.1.0";
  src = ./.;
  buildAndTestSubdir = "cli";
  cargoLock.lockFile = ./cli/Cargo.lock;

  postUnpack = ''
    cp $sourceRoot/cli/Cargo.lock $sourceRoot/Cargo.lock
  '';
};

This builds a working binary per platform via eachDefaultSystem, but it’s dynamically linked and not portable.

Static builds: platform-conditional logic

Linux and macOS need fundamentally different approaches. Use stdenv.hostPlatform.isLinux to branch:

packages.my-cli-static =
  if pkgs.stdenv.hostPlatform.isLinux then
    # Fully static musl binary
    pkgs.pkgsStatic.rustPlatform.buildRustPackage {
      pname = "my-cli";
      version = "0.1.0";
      src = ./.;
      cargoLock.lockFile = ./Cargo.lock;
      doCheck = false;
    }
  else
    # macOS: static Rust runtime, rewrite dylib paths for portability
    pkgs.rustPlatform.buildRustPackage {
      pname = "my-cli";
      version = "0.1.0";
      src = ./.;
      cargoLock.lockFile = ./Cargo.lock;
      RUSTFLAGS = "-C target-feature=+crt-static";
      nativeBuildInputs = [ pkgs.darwin.cctools ];
      doCheck = false;

      postInstall = ''
        install_name_tool -change \
          ${pkgs.libiconv}/lib/libiconv.2.dylib \
          /usr/lib/libiconv.2.dylib \
          $out/bin/my-cli
      '';
    };

Two completely different strategies behind the same attribute name. Let’s break each one down.

Linux: pkgsStatic and musl

pkgs.pkgsStatic is a nixpkgs overlay that swaps glibc for musl and forces static linking across all packages. When you use pkgs.pkgsStatic.rustPlatform.buildRustPackage, the Rust toolchain links against musl libc statically. The result is a single binary with zero dynamic dependencies:

$ file result/bin/my-cli
result/bin/my-cli: ELF 64-bit LSB executable, x86-64, statically linked, stripped

$ ldd result/bin/my-cli
  not a dynamic executable

This binary runs on any Linux — Alpine, Ubuntu, Debian, RHEL, bare containers, you name it.

Disable tests (doCheck = false) for the static variant — test suites sometimes rely on dynamic features or network access that break under musl static linking. Run your tests in the dynamic build instead.

macOS: why you can’t go fully static

macOS doesn’t support fully static binaries. Apple requires dynamic linking against libSystem.B.dylib (the kernel interface). There’s no musl equivalent for Darwin.

What you can do:

  1. Statically link the Rust/C runtime with RUSTFLAGS = "-C target-feature=+crt-static"
  2. Rewrite any remaining Nix store dylib references to system paths

install_name_tool (from pkgs.darwin.cctools) rewrites the Mach-O load commands. After rewriting, otool -L should show only system paths:

$ otool -L result/bin/my-cli
result/bin/my-cli:
  /usr/lib/libiconv.2.dylib (...)
  /usr/lib/libSystem.B.dylib (...)

No /nix/store/... references. The binary works on any macOS system.

If your binary links against additional C libraries, you’ll need one install_name_tool -change per library. Check otool -L on the binary before rewriting to see what needs fixing.

Cross-compilation

Building Linux from macOS

With eachDefaultSystem, each system gets its own package set. To build a Linux binary from macOS:

nix build .#packages.x86_64-linux.my-cli-static

This works because nixpkgs has cross-compilation toolchains built in. Nix fetches the appropriate cross-compiler and musl target libraries. The result is a static Linux ELF binary built on your Mac.

For aarch64 Linux (ARM servers, Raspberry Pi, Graviton):

nix build .#packages.aarch64-linux.my-cli-static

Building macOS from Linux

This also works in the other direction:

nix build .#packages.aarch64-darwin.my-cli-static
nix build .#packages.x86_64-darwin.my-cli-static

Though in practice, macOS cross-compilation from Linux is less common and can hit edge cases with Apple framework headers. If you have a Mac available, prefer native builds for Darwin targets.

All four static targets

With eachDefaultSystem + the platform-conditional static build, one flake produces:

TargetLinkingTechnique
x86_64-linuxFully static (musl)pkgsStatic
aarch64-linuxFully static (musl)pkgsStatic
x86_64-darwinPartial static + rewritecrt-static + install_name_tool
aarch64-darwinPartial static + rewritecrt-static + install_name_tool

Cargo.toml: optimize for release size

For CLI tools, optimize the release profile for binary size:

[profile.release]
lto = true           # Link-time optimization — slower build, smaller binary
strip = true         # Strip debug symbols
codegen-units = 1    # Single codegen unit — better optimization, slower build
opt-level = "z"      # Optimize aggressively for size

This typically cuts binary size by 50–70% compared to default release settings.

Avoid OpenSSL — use rustls

If your CLI makes HTTPS requests, use rustls (pure Rust TLS) instead of openssl-sys. OpenSSL is a C dependency that’s painful to cross-compile and link statically. Most Rust HTTP/gRPC crates support a rustls feature flag:

[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
# or for gRPC:
tonic = { version = "0.12", features = ["tls-rustls"] }

Zero C TLS dependencies = cross-compilation just works.

Protobuf codegen

If your CLI uses Protocol Buffers (e.g., gRPC), provide protoc via Nix:

nativeBuildInputs = [ pkgs.protobuf ];
PROTOC = "${pkgs.protobuf}/bin/protoc";

With a build.rs that runs tonic_build or prost_build:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure()
        .build_server(false)
        .compile_protos(&["proto/service.proto"], &["proto"])?;
    Ok(())
}

Protobuf codegen is text generation — it’s platform-independent and needs no special cross-compilation handling. Just make sure PROTOC is set so it finds the compiler.

Making it runnable via nix run

Register the package as an app:

apps.my-cli = {
  type = "app";
  program = "${self.packages.${system}.my-cli}/bin/my-cli";
};

Then: nix run .#my-cli -- --help

Why not crane or naersk?

rustPlatform.buildRustPackage is built into nixpkgs and works natively with pkgsStatic. External tools like crane or naersk add incremental build caching (useful for CI), but for a single CLI binary the added complexity isn’t worth it. cargoLock.lockFile gives you reproducibility, pkgsStatic gives you musl — that’s all you need.

Putting it together

The full pattern in one flake: a dynamic build for development and testing, a static/portable build for distribution, cross-compilation across all four targets, and nix run support. The platform-conditional logic handles the Linux/macOS split transparently — consumers of the flake don’t need to think about it. They nix build .#my-cli-static and get a binary that works anywhere their target OS runs.