Building Portable Rust CLI Binaries with Nix — Static Linux, Portable macOS
5 min readYou 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:
- Statically link the Rust/C runtime with
RUSTFLAGS = "-C target-feature=+crt-static" - 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:
| Target | Linking | Technique |
|---|---|---|
x86_64-linux | Fully static (musl) | pkgsStatic |
aarch64-linux | Fully static (musl) | pkgsStatic |
x86_64-darwin | Partial static + rewrite | crt-static + install_name_tool |
aarch64-darwin | Partial static + rewrite | crt-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.