Bottom Line: Nix makes cross-compiling Rust fairly straightforward.

I have been tinkering with using nix to build Rust projects over the last couple of weeks and decided to try my hand at cross-compiling Rust for x86_64-linux from my M1 Mac (aarch64-darwin) via nix. Currently, several of my machines are running various flavors of NixOS (several aarch64-linux Raspberry Pis, a few x86_64-linux machines, an aarch64-linux Asahi-turned-NixOS machine, my MBP with nix-darwin), but it’s still really important for me to be able to compile for regular non-NixOS Linux machines.

Via rustup, rust does a great job providing toolchains to facilitate cross-compiling: simply rustup target add x86_64-unknown-linux-gnu. Unfortunately it doesn’t provide linkers, so even after you’ve added the toolchain, if you try to compile for linux, it’s not going to work:

$ cargo new linux-cross-example && cd linux-cross-example
$ rustup target add x86_64-unknown-linux-gnu
info: component 'rust-std' for target 'x86_64-unknown-linux-gnu' is up to date
$ cargo build --target=x86_64-unknown-linux-gnu
...
  = note: clang: warning: argument unused during compilation: '-pie' [-Wunused-command-line-argument]
          ld: unknown option: --as-needed
...

There are a number of workarounds, including downloading linkers via homebrew, various GitHub projects, using docker, or – my favorite – using zig to do the work for you (although this doesn’t seem to be working currently for musl targets, issue).

With nix, the current best practice seems to be having nix (as opposed to cargo / rust) do the heavy lifting of cross-compilation. Continuing in the linux-cross-example directory created above, I created a basic flake.nix, including these inputs:

inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
};

Perhaps the key feature of nix is ensuring reproducibility, so to that end, if readers are not having luck following this post, it may be necessary to pin the inputs to these specific revisions:

$ nix flake metadata
warning: Git tree '/Users/n8henrie/Desktop/linux-cross' is dirty
Resolved URL:  git+file:///Users/n8henrie/Desktop/linux-cross
Locked URL:    git+file:///Users/n8henrie/Desktop/linux-cross
Description:   Example of cross-compiling Rust on aarch64-darwin for x86_64-linux
Path:          /nix/store/d67wnc6v391x4gq5a24wzxbxxxfbvx07-source
Last modified: 1969-12-31 17:00:00
Inputs:
├───nixpkgs: github:nixos/nixpkgs/3c15feef7770eb5500a4b8792623e2d6f598c9c1
└───rust-overlay: github:oxalica/rust-overlay/a8b4bb4cbb744baaabc3e69099f352f99164e2c1
    ├───flake-utils: github:numtide/flake-utils/cfacdce06f30d2b68473a46042957675eebb3401
    │   └───systems: github:nix-systems/default/da67096a3b9bf56a91d16901293e51ba5b49a27e
    └───nixpkgs: github:NixOS/nixpkgs/96ba1c52e54e74c3197f4d43026b3f3d92e83ff9

For our first trick, we’ll try to compile a hello world program for x86_64-unknown-linux-gnu, probably better known as “your run-of-the-mill standard Linux system.” Here is the rest of my flake.nix:

{
  inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

  outputs = {
    self,
    nixpkgs,
    rust-overlay,
  }: let
    system = "aarch64-darwin";
    overlays = [(import rust-overlay)];
    pkgs = import nixpkgs {
      inherit overlays system;
      crossSystem = {
        config = "x86_64-unknown-linux-gnu";
        rustc.config = "x86_64-unknown-linux-gnu";
      };
    };
  in {
    packages.${system} = {
      default = self.outputs.packages.${system}.x86_64-linux-example;
      x86_64-linux-example = pkgs.callPackage ./. {};
    };
  };
}

To go along with the above, we’ll use this very simple default.nix (which is the file that will be called by “default” via pkgs.callPackage ./., or if one were to import a directory as in import ./.):

{rustPlatform}:
rustPlatform.buildRustPackage {
  name = "rust-cross-test";
  src = ./.;
  cargoLock.lockFile = ./Cargo.lock;
}

So currently our working directory looks like this:

$ tree .
.
├── Cargo.toml
├── default.nix
├── flake.nix
└── src
    └── main.rs

2 directories, 4 files

Amazingly, all that it takes for a successful build from here is to first run cargo update (or cargo build) to generate Cargo.lock, and then run nix build!

$ cargo update
$ nix build
warning: Git tree '/Users/n8henrie/Desktop/linux-cross' is dirty
warning: creating lock file '/Users/n8henrie/Desktop/linux-cross/flake.lock'
warning: Git tree '/Users/n8henrie/Desktop/linux-cross' is dirty
$ echo $?
0

We can see that the resulting file seems to have the expected architecture:

$ file result/bin/linux-cross-example
result/bin/linux-cross-example: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /nix/store/2abz7cq1p8c1pg38prm2gpja67bzr9gq-glibc-x86_64-unknown-linux-gnu-2.37-8/lib/ld-linux-x86-64.so.2, for GNU/Linux 3.10.0, not stripped

I used scp to copy the binary from ./result/bin/linux-cross-example to my Arch linux machine. Unfortunately, upon trying to run it, I got a surprising error:

$ cat /etc/os-release
NAME="Arch Linux"
PRETTY_NAME="Arch Linux"
ID=arch
BUILD_ID=rolling
ANSI_COLOR="38;2;23;147;209"
HOME_URL="https://archlinux.org/"
DOCUMENTATION_URL="https://wiki.archlinux.org/"
SUPPORT_URL="https://bbs.archlinux.org/"
BUG_REPORT_URL="https://bugs.archlinux.org/"
PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/"
LOGO=archlinux-logo
$ ./linux-cross-example
-bash: ./linux-cross-example: cannot execute: required file not found

Huh.

$ ldd ./linux-cross-example
        linux-vdso.so.1 (0x00007ffc363a4000)
        libgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f5dc90ed000)
        libc.so.6 => /usr/lib/libc.so.6 (0x00007f5dc8e00000)
        /nix/store/2abz7cq1p8c1pg38prm2gpja67bzr9gq-glibc-x86_64-unknown-linux-gnu-2.37-8/lib/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f5dc91a6000)

Huh, so it seems to be looking for ld-linux-x86-64.so.2 in /nix/store but isn’t finding it (because it’s not there).

Skipping back up a few lines, we actually already saw this path in the output from the file command, run locally on MacOS: dynamically linked, interpreter /nix/store/2abz7c...

After a bit of investigative work, it seems that rust binaries are mostly statically linked by default, but do need to find a few libraries like glibc, which are dynamically linked. Nix is creating this binary in such a way that it is trying to find nix’s copy of this required file, but the nix version doesn’t exist on my Arch machine. Apparently most Linux machines put it in /lib64/ or perhaps /usr/lib64/; on my Arch machine, it looks like /lib64/ should work (which is a symlink to /usr/lib/):

$ stat /lib64/ld-linux-x86-64.so.2
  File: /lib64/ld-linux-x86-64.so.2
  Size: 216192    	Blocks: 424        IO Block: 4096   regular file
Device: 0,25	Inode: 17427431    Links: 1
Access: (0755/-rwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2023-09-05 14:40:37.920798992 -0600
Modify: 2023-08-17 09:05:37.000000000 -0600
Change: 2023-08-22 14:10:02.729886634 -0600
 Birth: 2023-08-22 14:10:02.729886634 -0600

Thankfully, nix provides a tool called patchelf that can patch the binary to look in a non-default location for this required file. We’ll add it to default.nix:

{rustPlatform}:
rustPlatform.buildRustPackage {
  name = "rust-cross-test";
  src = ./.;
  cargoLock.lockFile = ./Cargo.lock;
  postBuild = ''
    patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 target/x86_64-unknown-linux-gnu/release/linux-cross-example
  '';
}

We’ll once again run nix build, use scp to copy the binary, and…

$ ./linux-cross-example
Hello, world!

Sweet, it works! We cross-compiled Rust from our M1 Mac to x86_64-linux with just a few lines of nix code!

For our next challenge, let’s see if we can build a fully static x86_64-unknown-linux-gnu binary! We’ll modify default.nix by removing the patchelf code (since this will by fully static and not require the --set-interpreter business) and adding a few lines of code from the same StackOverflow thread from above:

{
  rustPlatform,
  glibc,
}:
rustPlatform.buildRustPackage {
  name = "rust-cross-test";
  src = ./.;
  cargoLock.lockFile = ./Cargo.lock;
  buildInputs = [glibc.static];
  RUSTFLAGS = ["-C" "target-feature=+crt-static"];
}
$ nix build
$ file result/bin/linux-cross-example
result/bin/linux-cross-example: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), static-pie linked, for GNU/Linux 3.10.0, not stripped

It certainly looks like a static binary. Sure enough, our it runs like a champ on our Linux machine!

$ ldd linux-cross-example
        statically linked
$ ./linux-cross-example
Hello, world!

For our last trick, we’ll try to compile a fully static musl build, which should run on basically any x86_64-linux machine. For this, we can revert our default.nix back to the very simple way it started:

{rustPlatform}:
rustPlatform.buildRustPackage {
  name = "rust-cross-test";
  src = ./.;
  cargoLock.lockFile = ./Cargo.lock;
}

And simply change flake.nix to reflect the musl target triple, setting isStatic = true;:

{
  inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

  outputs = {
    self,
    nixpkgs,
    rust-overlay,
  }: let
    system = "aarch64-darwin";
    overlays = [(import rust-overlay)];
    pkgs = import nixpkgs {
      inherit overlays system;
      crossSystem = {
        config = "x86_64-unknown-linux-musl";
        rustc.config = "x86_64-unknown-linux-musl";
        isStatic = true;
      };
    };
  in {
    packages.${system} = {
      default = self.outputs.packages.${system}.x86_64-linux-musl-example;
      x86_64-linux-musl-example = pkgs.callPackage ./. {};
    };
  };
}
$ nix build
$ file result/bin/linux-cross-example
result/bin/linux-cross-example: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, not stripped

On the Arch machine:

$ ldd linux-cross-example
        statically linked
$ ./linux-cross-example
Hello, world!

Cool! It took a little bit of reading and tinkering to sort this out, but in the end it’s a remarkably simple setup requiring very few lines of code (at least for this hello world project). As a side note, I didn’t have any luck statically compiling for x86_64-unknown-linux-gnu with the isStatic setting; for me this results in a unsupported system error.

Putting everything together, with a little bit of refactoring, and adding a bonus config for aarch64-unknown-linux-musl (which runs without issue on an aarch64-linux Raspberry Pi):

{
  description = "Example of cross-compiling Rust on aarch64-darwin for x86_64-linux";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

  outputs = {
    self,
    nixpkgs,
    rust-overlay,
  }: let
    system = "aarch64-darwin";
    overlays = [(import rust-overlay)];
    makePkgs = config:
      import nixpkgs {
        inherit overlays system;
        crossSystem = {
          inherit config;
          rustc = {inherit config;};
          isStatic = builtins.elem config [
            "aarch64-unknown-linux-musl"
            "x86_64-unknown-linux-musl"
          ];
        };
      };
  in {
    packages.${system} = {
      default = self.outputs.packages.${system}.x86_64-linux-gnu-example;
      x86_64-linux-gnu-example = (makePkgs "x86_64-unknown-linux-gnu").callPackage ./. {};
      x86_64-linux-gnu-static-example = (makePkgs "x86_64-unknown-linux-gnu").callPackage ./. {buildGNUStatic = true;};
      x86_64-linux-musl-example = (makePkgs "x86_64-unknown-linux-musl").callPackage ./. {};
      aarch64-linux-musl-example = (makePkgs "aarch64-unknown-linux-musl").callPackage ./. {};
    };
  };
}
{
  rustPlatform,
  glibc,
  targetPlatform,
  lib,
  buildGNUStatic ? false,
}:
rustPlatform.buildRustPackage ({
    name = "rust-cross-test";
    src = ./.;
    cargoLock.lockFile = ./Cargo.lock;
  }
  // (
    if buildGNUStatic
    then {
      buildInputs = [glibc.static];
      RUSTFLAGS = ["-C" "target-feature=+crt-static"];
    }
    else
      lib.optionalAttrs (targetPlatform.config == "x86_64-unknown-linux-gnu") {
        postBuild = ''
          patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 target/x86_64-unknown-linux-gnu/release/linux-cross-example
        '';
      }
  ))

From here, one should be able to nix build .#x86_64-linux-musl-example and be off to the races! And thanks to the power of nix, with any luck, and if you pin your inputs to the versions listed towards the beginning of this post, you should theoretically be able to rely on a successful build today, tomorrow, and maybe months, years, or – who knows – maybe even a decade from now!