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
direnvwithnix-direnvto 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.
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.
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_CACHEat 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.tomland.nvmrcin 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.
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.
Related
- Building Wasm with Bazel — the monorepo alternative.
- Building Wasm in Docker for consistent output — containers instead of Nix.
- Pinning Wasm toolchain versions — pinning without Nix.
- Caching Rust Wasm builds in GitHub Actions — CI caching.
← Back to Cross-Platform Build Automation