A hands-on technical reference for platform engineers operationalizing Nix-based SBOMs, CVE remediation, and supply chain controls in real C

Encoding Multi-Architecture Toolchain Contracts in Nix Flake Outputs Before CI Runs

Declare your build toolchain in version-controlled Nix flake outputs, not during CI runs.

· 8 min read
Cover illustration for “Encoding Multi-Architecture Toolchain Contracts in Nix Flake Outputs Before CI Runs”
Environment Parity · October 28, 2026 · 8 min read · 1,758 words

Managing multi-architecture toolchains requires a versioned environment contract. Nix flake outputs can encode compilers, runtimes, system libraries, and build tools for each supported architecture, while a committed flake.lock pins their inputs by hash. CI then verifies that declared contract through checks, rather than constructing its own environment from ambient state.

Why CI environment construction is the wrong place to define a toolchain contract

Runtime installation makes the runner’s surroundings part of the build definition. A job installs a compiler from a package repository, takes system libraries from its runner image, and resolves versions that were never pinned together. The environment is the hidden variable. Cargo.lock, for example, pins crate dependencies but does not define the compiler, linker, or system libraries those crates need.

That gap becomes a reliability and security problem when environment definitions are scattered across README instructions and CI setup steps. Two executions of the same job can resolve different toolchains without a corresponding source change. There is no complete, version-controlled environment definition to audit or roll back. The alternative is to declare the toolchain as a pure function of hash-pinned inputs before execution, with a separate definition for each target system. A flake’s outputs section is the structurally correct place for that contract.

What a flake's outputs section actually encodes and why the structure matters

The flake.nix schema has four top-level attributes, namely description, inputs, outputs, and nixConfig. The inputs attribute declares external dependencies through flake URLs, while outputs is a function receiving self and one attribute for each declared input. That function returns the flake’s public interface. Some output attributes, including devShells, packages, and checks, are keyed by system, while overlays, nixosModules, and templates are system-independent.

Each system-keyed output has a distinct role. devShells defines the compilers, runtimes, system libraries, and build tools activated by nix develop. packages defines named build products that nix build produces in /nix/store, normally at input-addressed paths whose hashes encode their derivations, or build-time dependency graphs. checks defines the validation derivations that nix flake check evaluates and runs.

In a minimal single-system definition, inputs supplies nixpkgs, and the outputs function selects its package set for that system. The function then exposes devShells.<system>.default containing the declared tools as the contract’s starting structure. Build products and validation belong under their own output attributes and must use the same declared toolchain, with versions chosen together. The companion flake.lock fixes the exact revisions and content hashes behind those definitions.

How to manage toolchains across x86 and ARM before CI sees them

To support both Linux and macOS across x86 and ARM, a flake needs outputs for x86_64-linux, aarch64-linux, x86_64-darwin, and aarch64-darwin. A single-system definition does not cover the other targets implicitly. The eachDefaultSystem function from flake-utils, or genAttrs from the nixpkgs library, provides the iteration needed to build system-keyed attributes. The repository’s interface then shows which architectures it supports, something that was left unstated in the choice of runner.

With flake-utils, the structure begins by declaring it as an input beside nixpkgs. The outputs body uses flake-utils.lib.eachDefaultSystem, receiving system as the loop variable and importing nixpkgs with inherit system. Inside that loop, devShells.default, packages.default, and checks.default use the package set for the current system. The wrapper produces the outer system keys. For a compiler supplied through rust-overlay, the overlay is applied inside the same per-system loop, so each target has an explicit compiler definition.

This structure makes support visible through nix flake show, but declaration is not execution. A macOS developer needs more than an attribute to build Linux production images locally and may need a VM-based or remote builder. Native runners remain necessary where the target requires them. A missing builder becomes a visible infrastructure gap.

Using the checks output as the formal pre-CI contract

Linting, formatting, and tests become derivations tied to the same pinned toolchain used by the development shell. Shared definitions are the critical connection here. Choosing tools separately for a shell and a test can give them different versions even when both are in the same flake. Their toolchain dependencies must come from the same declared inputs and selections.

The before-and-after contrast is short but consequential. A runtime-oriented job installs tools and then runs a test command. A contract-oriented job has Nix installed, checks out the repository, and invokes nix flake check for the applicable system. The check derivations state what runs and which tools it uses, so that definition is free of separate installation steps and choices based on the ambient PATH.

Cachix, for example, can supply cached derivations when the required results are available, avoiding rebuilds of unchanged dependencies while preserving the declared build definition. The audit boundary therefore stays in place even when most of a job fetches results that already exist. What CI verifies was already defined and committed before the job began.

Locking inputs so the contract is immutable at the moment of commit

A flake without a committed lock file is a declaration of intent. A moving input such as nixos-unstable can resolve differently on the next machine, even when flake.nix has not changed. The first nix develop, or an explicit nix flake lock, creates flake.lock with exact revisions and content hashes. Committing that file fixes the input state. Developer laptops, CI runners, and production builds then use the same snapshots, even as the current branch pointers move.

Updates become deliberate changes to that recorded state. nix flake update refreshes all inputs and rewrites the lock file, leaving a diff for review before commit. The command nix flake lock --update-input nixpkgs provides narrower control over a single input. This workflow also consolidates pinning for nixpkgs, a Rust overlay, and an AI tooling flake into one reviewable artifact. An unexpected hash change is visible before it enters the build.

Active monorepos can encounter conflicts when concurrent changes update the lock file. Lock-file conflicts expose competing input changes that can be resolved explicitly, while undeclared ambient drift is silent and cumulative. Omitting flake.lock avoids the visible conflict by abandoning the shared input state.

Supply chain visibility the locked closure produces without extra tooling

The locked dependency closure also shows what is in the supply chain at build time. Its packages and transitive dependencies are identified through the build graph, and the graph can be queried from store paths rather than reconstructed by scanning a running image. The build model makes provenance a structural by-product of the declared dependencies.

The tiiuae/sbomnix project illustrates how that existing information can be used. Its sbomnix tool generates SBOMs from a flake reference or store path, nixgraph queries and visualizes package dependency graphs, and vulnxscan uses those SBOMs for vulnerability scanning. A closure-derived SBOM identifies dependencies from their build-time definitions rather than inferring them from a filesystem, giving it a stronger basis for dependency provenance.

The EU Cyber Resilience Act requires software components to be identified and documented, with reports of actively exploited vulnerabilities required from September 2026 and the main obligations taking effect on 11 December 2027. That context increases the value of a queryable dependency record. Nix’s hermetic sandbox also provides structural build isolation, while the build record supplies a basis for SLSA provenance attestations. Those capabilities help provide supply chain evidence, but a locked flake alone does not show that all regulatory requirements have been met.

Extending the same contract to AI coding agents running in the development shell

AI coding agents inherit environment drift just as human developers do. Their work depends on available binaries, compiler versions, libraries, sandbox configuration, and the code or prompt. An agent launched inside nix develop receives the declared toolchain for that supported system, just as a developer does, with the guarantee limited to the environment.

A Rust web service environment can make that boundary concrete. The example toolchain declares rustc 1.82.0, sqlx-cli, postgresql_16, cargo-watch, and cargo-nextest together with a Codex CLI package. Inside the shell, the agent has the tools needed to write a database migration, compile the service, and run its tests. Installation becomes part of the environment definition. The numtide/llm-agents.nix flake similarly lets teams declare terminal-based agent tooling with their build dependencies.

The Utensils group’s mcp-nixos queries live data sources to address hallucinated Nix package names, where a wrong attribute path can break a build. SecBear’s nix-sandbox-mcp uses Nix flakes and bubblewrap to provide unprivileged execution environments with separate namespaces. Neither role replaces the toolchain contract. The shell defines which environment the agent receives, while the isolation layer governs where its actions execute.

Structuring flake outputs as a platform engineering primitive for golden-path environments

At team scale, flake outputs turn environment choices into a platform interface. The platform team versions compiler selections, system libraries, and build tools once, and developers enter the resulting environment with a single command. The right environment becomes the easy environment. Developers can skip those repeated setup choices and still read the definition in source control. For multi-architecture toolchain management, the interface also states which systems the platform commits to supporting.

As a library of environments grows beyond one repository, named sharing provides an organizational scaling layer. A hosted control plane can let teams version and share environments by name, retrieve complete stacks on demand, and generate deterministic SBOMs from their closures. The shared artifact is the environment definition.

Kratix defines Promises that state what a platform provides and let developers request resources. Flake outputs set out the pre-CI developer-environment contract, and those Promises set out the infrastructure contract. Their scopes are distinct but complementary.

Honest costs and the learning investment the pattern requires

Nix requires a meaningful investment in learning. Its functional language and build model differ from conventional package managers, and its documentation has historically assumed familiarity with functional programming concepts. Writing flakes from scratch is not immediately comfortable for most developers. Onboarding must also account for enabling the flake experimental-features flag in nix.conf. Cross-platform infrastructure remains an additional cost because declaring targets does not supply the native or remote builders needed to execute them.

The payoff comes from eliminating repeated environment assembly and reusing unchanged build results. Teams have reported cutting multi-day onboarding to a small fraction of that time, while caching keeps repeated CI work manageable. Those gains take ongoing care of the contract as well as the move to Nix syntax. Teams still need to be clear about who owns toolchain choices, check definitions, and lock updates.

For teams without existing Nix expertise, the practical starting point is deliberately narrow. Designate one engineer to own the flake and the lock-update workflow, and begin with devShells and checks before adding packages and cross-architecture targets. Commit flake.lock from day one.

More in Environment Parity