nix-playgraound/notes/005-multi-system.md

4.3 KiB

Multi-System Outputs

This note covers 03-multi-system/, which makes a flake's packages, dev shells, and checks available on multiple platforms from a single flake.nix.


1. The Problem

Flake outputs are keyed by system: packages.x86_64-linux.default, packages.aarch64-darwin.default, and so on. If you hard-code one system (as 01-devshell and 02-package do), the flake only works on that platform.

To support multiple systems, you need to generate the per-system attrset for each platform you care about.


2. The forAllSystems Pattern

The 03-multi-system example defines a local helper:

forAllSystems = f:
  nixpkgs.lib.genAttrs supportedSystems (system:
    f (import nixpkgs { inherit system; })
  );

genAttrs takes a list of keys and a function, and returns { key1 = f key1; key2 = f key2; … }. Wrapping it so the callback receives pkgs (already imported for the right system) keeps the per-system output definitions clean:

packages = forAllSystems (pkgs: {
  default = pkgs.writeShellScriptBin "hello-multi" ''
    echo "hello from $(uname -s)/$(uname -m)"
  '';
});

This produces:

packages.x86_64-linux.default
packages.aarch64-linux.default
packages.aarch64-darwin.default
packages.x86_64-darwin.default

No external dependencies. You control the system list explicitly.


3. The flake-utils Alternative

flake-utils is a community flake that provides eachDefaultSystem, which does the same thing with less boilerplate:

inputs.flake-utils.url = "github:numtide/flake-utils";

outputs = { self, nixpkgs, flake-utils }:
  flake-utils.lib.eachDefaultSystem (system:
    let pkgs = import nixpkgs { inherit system; }; in {
      packages.default = pkgs.hello;
    });

Differences from the hand-rolled approach:

  • eachDefaultSystem covers a predefined list of common systems (currently x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin). You can use eachSystem to specify your own list.
  • The callback returns a flat attrset ({ packages.default = …; devShells.default = …; }), and eachDefaultSystem nests it under each system automatically.
  • It adds one input to flake.lock. Use follows to keep its nixpkgs in sync with yours.

Both approaches are common. The hand-rolled helper is better when you want full control; flake-utils is better when you want less repetition. flake-parts is a third option for larger projects that benefit from a module system on top of flakes.


4. The checks Output

03-multi-system also demonstrates a checks output:

checks = forAllSystems (pkgs: {
  runs = pkgs.runCommand "hello-multi-runs" {} ''
    ${self.packages.${pkgs.system}.default}/bin/hello-multi > $out
  '';
});

nix flake check evaluates and builds everything under checks.<system>. If any check derivation fails to build, the command exits non-zero.

runCommand is a convenience wrapper around mkDerivation that runs a single shell snippet. It needs to produce $out (a file or directory) to succeed. Here it runs the built script and writes its output to $out, which proves the binary at least executes without error.

This is the simplest form of a flake-level test. More involved checks might run a test suite, validate config syntax, or compare outputs against expected snapshots.


5. Commands to Try

cd 03-multi-system

nix flake show                     # see outputs for all four systems
nix build                          # build default package for your system
nix run                            # build + run
nix flake check                    # build all checks (proves the script runs)

# Inspect what other systems would get (eval only, no cross-build):
nix eval .#packages.aarch64-darwin.default.name

6. When Cross-System Matters

Multi-system outputs are useful when:

  • You collaborate across macOS and Linux machines.
  • CI runs on a different platform than your laptop.
  • You publish a flake for others to consume (they expect their system to be present).
  • You want nix flake check to evaluate outputs for every platform, catching typos that would only surface on the other OS.

If you only ever build on one machine, a single hard-coded system string is fine. You can always add forAllSystems later without changing any output behavior on your own platform.