Building Wasm with Nix

This page answers one task: developers and CI keep ending up with slightly different Rust, wasm-bindgen, Binaryen and Node versions, and you want one declarative definition that gives everyone the same WebAssembly toolchain — and, ideally, a build whose output is byte-for-byte reproducible.

Prerequisites

  • [ ] Nix with flakes enabled (experimental-features = nix-command flakes).
  • [ ] A Rust (or C) project that builds to WebAssembly.
  • [ ] Optionally direnv with nix-direnv to enter the environment automatically.

Why Nix fits WebAssembly toolchains

A Rust-to-Wasm build involves at least four separately versioned tools: the Rust compiler with its wasm32-unknown-unknown standard library, wasm-bindgen-cli (which must match the crate version exactly), Binaryen’s wasm-opt, and the JavaScript tooling around them. Version drift between machines is the most common source of “works on my machine” for Wasm projects. Nix describes all of them in one file, pinned by a lockfile, built from source or fetched from a binary cache, and isolated from whatever is installed globally. Every developer, every CI runner and every release machine gets exactly the same tools, and changing a version is a reviewed change to flake.lock.

Nix also helps with reproducibility of the output: builds run in a sandbox with fixed paths and timestamps, which removes several sources of non-determinism that otherwise make two builds of the same commit differ.

What a Wasm flake pins The flake pins nixpkgs and a Rust overlay through flake.lock. From them it provides Rust with the wasm32 target, wasm-bindgen-cli matching Cargo.lock, Binaryen, wasm-tools and Node in a development shell, and a sandboxed package build that produces the optimised module. flake.lock exact nixpkgs + overlay revisions rust toolchain stable + wasm32-unknown-unknown wasm-bindgen-cli version from Cargo.lock binaryen, wasm-tools, node optimise, inspect, test devShell + package same tools everywhere

Step 1 — write the flake

Use a Rust overlay such as rust-overlay or fenix to get a toolchain with the Wasm target:

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

  outputs = { self, nixpkgs, rust-overlay }:
    let
      forAll = f: nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ] (system:
        f (import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }));
    in {
      devShells = forAll (pkgs: {
        default = pkgs.mkShell {
          packages = [
            (pkgs.rust-bin.stable."1.83.0".default.override { targets = [ "wasm32-unknown-unknown" ]; })
            pkgs.wasm-bindgen-cli        # must match Cargo.lock — see step 2
            pkgs.binaryen
            pkgs.wasm-tools
            pkgs.nodejs_22
          ];
        };
      });
    };
}

nix develop enters a shell with exactly these tools; direnv with use flake in .envrc does it automatically when you cd into the project.

Step 2 — match wasm-bindgen-cli to Cargo.lock

nixpkgs ships one wasm-bindgen-cli version at a time, which will rarely equal the version in your Cargo.lock. Build the matching version with buildRustPackage (or the wasm-bindgen-cli override pattern), pinning its hashes:

wasm-bindgen-cli = pkgs.wasm-bindgen-cli.override {
  version = "0.2.93";
  hash = "sha256-…";          # from the first failed build's error message
  cargoHash = "sha256-…";
};

When Cargo.lock changes the wasm-bindgen version, update this override in the same commit. CI fails immediately if they disagree, which is the behaviour you want. The mismatch error itself is explained in fixing wasm-bindgen schema version mismatches.

Step 3 — build the module as a Nix package

For reproducible release builds, add a package output that compiles, binds and optimises inside the Nix sandbox. Crane or buildRustPackage handle the Rust part; then run wasm-bindgen and wasm-opt:

packages = forAll (pkgs: let
  rust = pkgs.rust-bin.stable."1.83.0".default.override { targets = [ "wasm32-unknown-unknown" ]; };
  platform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
in {
  default = platform.buildRustPackage {
    pname = "app-wasm"; version = "1.0.0"; src = ./.;
    cargoLock.lockFile = ./Cargo.lock;
    nativeBuildInputs = [ wasm-bindgen-cli pkgs.binaryen ];
    buildPhase = ''
      cargo build --release --target wasm32-unknown-unknown --offline
      wasm-bindgen target/wasm32-unknown-unknown/release/app.wasm --out-dir pkg --target web
      wasm-opt -O3 pkg/app_bg.wasm -o pkg/app_bg.wasm
    '';
    installPhase = "cp -r pkg $out";
    doCheck = false;
  };
});

nix build produces result/ containing the glue and the optimised module. Dependencies are fetched by hash from Cargo.lock, so the build is offline and deterministic.

Development and release paths from one flake Developers run nix develop to get the pinned tools and build with their usual commands. CI runs nix build to produce the release package in a sandbox. Both use the same flake.lock, so tool versions match, and the sandboxed build is reproducible across machines. flake.nix + flake.lock single definition nix develop dev shell, same tools nix build sandboxed release build binary cache share results identical .wasm on every machine

Step 4 — use it in CI

In GitHub Actions, install Nix (for example with the DeterminateSystems installer), enable a binary cache (Cachix or the Magic Nix Cache), and run the same commands developers use:

- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build .#default
- run: nix develop --command npm test

The first run builds or downloads everything; later runs reuse the cache, so the toolchain costs seconds rather than minutes.

Step 5 — verify reproducibility

Build twice on different machines and compare hashes of result/app_bg.wasm. Remaining differences usually come from embedded paths or build metadata; --remap-path-prefix in RUSTFLAGS and stripping producer sections remove them, as described in producing reproducible Wasm binaries. nix build --rebuild rebuilds a derivation and reports if the output differs from the cached one, which makes reproducibility checks part of CI.

Emscripten and C toolchains under Nix

C and C++ projects can use nixpkgs’ emscripten package, which bundles a matching LLVM and Binaryen. Emscripten expects to write a cache of compiled system libraries, which conflicts with Nix’s read-only store; set EM_CACHE to a writable directory in the dev shell, or pre-populate the cache in the package build. For WASI targets, pkgsCross.wasi32 provides a cross toolchain with wasi-libc, so pkgsCross.wasi32.stdenv.mkDerivation builds C code to WASI modules like any other package. These paths are less travelled than Rust’s, so expect to pin versions carefully and occasionally patch build scripts that assume a mutable Emscripten installation.

Living with Nix in a mixed team

Not everyone on a team will use Nix, and that is fine if the flake is treated as the reference definition rather than the only way to build. Keep ordinary version pins — rust-toolchain.toml, a .nvmrc, the wasm-bindgen version in Cargo.lock — in sync with the flake, so developers using rustup and nvm get the same versions without Nix. A CI check that compares the versions in the flake with those files catches drift. Over time, teams often find that the flake’s dev shell is the quickest way to onboard new developers, since one command provides every tool, including Binaryen and wasm-tools that are otherwise easy to forget.

Including JavaScript dependencies

The Wasm module is usually one part of a JavaScript application whose npm dependencies also need pinning. package-lock.json already pins them, and Nix can build the JavaScript side reproducibly with buildNpmPackage (or pnpm-specific helpers), using an npmDepsHash computed from the lockfile. A combined package then depends on the Wasm package and runs the bundler offline in the sandbox, producing the deployable dist directory from one nix build. For development, most teams keep using npm install inside the dev shell — the shell pins Node itself — and reserve the fully sandboxed JavaScript build for releases, where reproducibility matters most. Either way, keep the bundler configured to emit the .wasm file with a content hash, so the reproducible module also yields a stable asset name.

Updating the toolchain safely

Nix makes upgrades explicit: nix flake update moves inputs forward, and flake.lock records exactly what changed. Update one input at a time — the Rust overlay, then nixpkgs — and rebuild, so a change in output size or behaviour can be attributed. Record the module’s size and a checksum of its output in CI, and review changes to either in the upgrade pull request. Combined with the binary cache, an upgrade that turns out badly is reverted by reverting the lockfile change, with the previous toolchain fetched again from the cache in seconds.

Expected output

nix develop provides Rust 1.83 with wasm32, wasm-bindgen 0.2.93 matching Cargo.lock, Binaryen and Node 22 on Linux and macOS; nix build produces the same app_bg.wasm hash on a developer laptop and in CI.

Gotchas

  • nixpkgs’ wasm-bindgen version. Rarely matches your lockfile. Override it.
  • Network access in builds. The sandbox forbids it. Vendor crates through cargoLock.
  • Emscripten cache in the store. It is read-only. Point EM_CACHE at a writable path.
  • Unpinned overlays. Update inputs deliberately with nix flake update, reviewed like any change.
  • macOS and Linux differences. Test the flake on every platform developers use.
  • Mismatched non-Nix pins. Keep rust-toolchain.toml and .nvmrc in step with the flake for non-Nix users.

Performance note

A cold CI run without a binary cache took 11 minutes to build the toolchain and module; with the Magic Nix Cache, the toolchain arrived in 25 seconds and the module built in 2 minutes. nix develop on a developer machine with a warm cache started in under a second.

CI time to a built Wasm package with Nix Minutes for a CI job to provide the toolchain and build the Wasm package without a binary cache, with a binary cache for the toolchain, and with both toolchain and package cached. minutes per CI run no binary cache 11 min toolchain cached 2.4 min toolchain + package cached 0.5 min

Frequently Asked Questions

Do I need NixOS? No — Nix runs on any Linux distribution and macOS; NixOS is not required.

Can Nix build wasm-pack projects? It can run wasm-pack, but wasm-pack downloads tools at build time, which conflicts with the sandbox. Use wasm-bindgen and wasm-opt directly.

How do I get a nightly Rust with build-std for threads? rust-overlay provides nightly toolchains with rust-src; select a dated nightly and add the component.

Is Nix overkill for a small project? Possibly; a pinned rust-toolchain.toml and a CI cache may be enough. Nix shines when several tools and machines must agree exactly.

How do I upgrade one tool without moving everything? Update a single input with nix flake lock --update-input rust-overlay, rebuild, and review the output differences.

Can Nix pin the exact wasm-bindgen CLI version? Yes — pin it in the flake alongside the crate version so the CLI and library always match.

← Back to Cross-Platform Build Automation