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

Single Nix Flake Supporting X86-64 and Aarch64 Without SBOM Drift

One locked input graph and shared output pattern prevent architecture-specific dependency drift.

· 8 min read
Cover illustration for “Single Nix Flake Supporting X86-64 and Aarch64 Without SBOM Drift”
Environment Parity · October 26, 2026 · 8 min read · 1,892 words

A single Nix flake can support x86-64 and AArch64 without uncontrolled SBOM drift when both architectures use one locked input graph and one authored output pattern. The goal is shared dependency provenance. That same structure gives developer environments and CI a common foundation, while per-architecture SBOM generation verifies what each release actually contains.

Why multi-arch flakes create SBOM drift by default

An engineer generates an SBOM for the x86 release and assumes it also describes the ARM build. It does not necessarily describe that build. Although the releases come from the same flake, their dependency closures can differ because Nix evaluates outputs for each system. An SBOM for x86_64-linux describes that output’s closure. If the aarch64-linux output selects different dependencies, the audit artifacts describe different software.

The lockfile alone cannot prevent that divergence. Flakes record exact input revisions in flake.lock, so both systems can use the same nixpkgs commit while selecting different packages from it. Separately authored architecture branches can drift in package selection, while a hard-coded system = "aarch64-darwin" can leave the other architecture without an evaluated output. Logic inside a derivation that depends on the architecture can also change the closure, even when the package definition looks the same. These are distinct failure points, and a common input revision addresses only the starting point.

Locked inputs therefore leave system-specific output selection uncontrolled. One fixes where dependencies come from. The other determines which dependencies enter a particular build. Reproducibility requires both to be controlled, and an SBOM must describe the resulting closure. Otherwise, a reproducible build can still produce the wrong evidence for the release under review.

How the input-derived hash establishes a shared identity across architectures

Nix represents build inputs as an explicit directed acyclic graph, or DAG. Inputs contribute to the hash of the derivation they define, connecting an output’s identity to its declared build graph. The hash is not merely a version label. It distinguishes a build rooted in particular inputs from another build that happens to use the same package name. This gives an SBOM a more exact way to track provenance than names and versions alone.

When both architectures use the same flake.lock, they share the resolved upstream revisions and integrity information recorded there. Their architecture-specific derivation hashes need not be identical. When the same dependencies are selected, differences between platforms can account for different derivation identities even if no unreviewed source update has taken place. But if architecture-specific logic changes the dependencies, the graph differs for another reason. A trustworthy comparison must distinguish those expected platform differences from unintended dependency changes.

Committing the lockfile makes that shared provenance reviewable. An SBOM generated from a Nix store path or flake reference can associate the output’s input-derived identity with its exact source revision. That association supports hash-level CVE blocking and rapid CVE-to-patch workflows because the affected build has a specific dependency identity. The output structure must preserve this connection for both architectures, rather than allowing one branch to acquire a separate dependency selection unnoticed.

Choosing an output pattern that spans both architectures from one authored file

flake-utils.lib.eachDefaultSystem calls a function once per platform and places the results under the corresponding system identifier. It is a valid multi-architecture pattern. Its cost is another flake input, which expands flake.lock for users and can lead to multiple pinned copies of the helper. That trade-off is different from a correctness defect, and the pattern should be judged accordingly.

nixpkgs.lib.genAttrs offers a more explicit alternative over a hand-enumerated system list. The author controls which systems receive outputs. However, the list becomes another maintained boundary, and omitting an architecture leaves it outside the shared definition. This is better structured than independent architecture branches, but it still relies on the author keeping the supported-system list complete.

builtins.mapAttrs over inputs.nixpkgs.legacyPackages removes that separate enumeration and requires no extra input. The default nix flake init template adopted this pattern in August 2026. It walks the platform attribute set exposed by nixpkgs and produces outputs such as packages.x86_64-linux.foo and packages.aarch64-linux.foo from a single authored block. The legacyPackages name identifies an output whose nested package sets and helpers do not fit the strict, flat flake output schema.

For packages, the shared packages block maps over inputs.nixpkgs.legacyPackages and selects packages once for each package set it receives. The devShells block maps over the same attribute set in the same way. Both blocks thus have the same nixpkgs input root. Unlike a hard-coded system string, this structure exposes both architectures without duplicating the selection logic. It removes a major opportunity for drift at the authored selection layer.

Cross-compilation and emulation: what they change about the closure and what they do not

Emulation lets an x86-64 host execute AArch64 binaries rather than treating every foreign-architecture build as cross-compilation. On NixOS, the host configuration can enable this with boot.binfmt.emulatedSystems = [ "aarch64-linux" ]. This changes the available execution path. It can support deployment workflows involving Colmena and multiple NixOS hosts, while macOS developers building Linux production images typically rely on CI for final builds. The shared output pattern still defines the packages for the system being requested.

Cross-compilation instead uses a separate package set such as pkgsCross.aarch64-multiplatform, which adds cross-toolchain components to the build graph. Those components distinguish it from a native AArch64 build. An SBOM comparison must therefore check whether it covers the build toolchain before counting a difference as drift in the software that ships. Builds with the same source inputs can have different build graphs, and even with the same build graph, x86-64 and AArch64 binaries can differ bit-for-bit. Native builds avoid the extra differences from the cross-toolchain, but differences between architectures remain.

For comparable release evidence, the CI strategy is to build natively on each architecture from the same committed lockfile. This keeps the build mode consistent across runners. It also prevents a missing or ignored flake.lock from allowing CI to resolve inputs independently. The comparison can then focus on expected system differences and actual dependency divergence, rather than mixing native and cross-compilation graphs.

Generating and comparing SBOMs from a dual-architecture flake

Technology Innovation Institute’s sbomnix, under tiiuae, generates SBOMs from a Nix flake reference or store path. The dependency graph is already explicit in Nix. When given a flake reference, the tool can resolve the nixpkgs version used to build the package and enrich the result with descriptions, licenses, maintainers, and homepage links. This makes the chosen reference important because it connects component inventory with the source metadata needed to interpret it.

nikstur’s bombon automates CycloneDX v1.5 generation with the aim of compliance with the German BSI Technical Guideline TR-03183 v2.0.0 and US Executive Order 14028. Tool selection therefore depends on the required output format and compliance context, as well as the ability to read the intended Nix output. With either tool, both architecture-specific release outputs still need to be selected and evaluated.

Semwright’s September 2026 CI pipeline provides a concrete reproducibility pattern. Its PR pins nixpkgs to an exact revision and builds through a Nix derivation. The pipeline then generates reproducible per-binary CycloneDX 1.5 SBOMs twice and compares them byte-for-byte. It also produces deterministic non-admitted tar/deb certification bundles on x86_64 and aarch64. The important sequence is fixed inputs, derivation-based builds, repeated SBOM generation, and an explicit comparison.

Identical results show that repeated generation produced the same evidence, while a diff exposes components or metadata requiring investigation. That is separate from comparing the x86-64 inventory with the AArch64 inventory, where platform-specific differences may be legitimate. A review across architectures must allow for those differences, since byte equality is not always the test of success. Together, the two comparisons distinguish unstable reporting from a genuinely different dependency closure.

Package classification remains an unresolved metadata gap in nixpkgs. CycloneDX uses a type property for categories including firmware, device-driver, libraries, and applications, while SPDX uses primaryPurpose with values including firmware, deviceDriver, library, and application. Nix cannot reliably infer these categories from package names or outputs alone. A standardized metadata attribute is needed to avoid potential misidentification, even when the dependency graph and SBOM generation are reproducible.

Pinning strategy and lockfile discipline that keep SBOM drift from re-entering over time

The operational discipline is to commit flake.lock rather than ignore it and make nix flake update a deliberate, reviewable change. The resulting lockfile diff exposes changes to the resolved input graph. Without that record, workstations and CI runners can build from different resolutions despite using the same authored flake.

Branch selection sets the context for those updates. nixos-unstable is acceptable for development machines, but the policy described here excludes it from CI and production. A release branch such as nixos-24.11 provides security patches with a reduced risk of breakage. Security fixes can still introduce incompatibilities. For teams maintaining an internal developer platform, selective updates and a committed lockfile keep workstations and CI/CD runners on the same declared dependency graph, down to the C runtime and dynamic linker.

A private binary cache complements that discipline by avoiding repeated builds of identical derivations. It improves performance without replacing input control. Cached artifacts are keyed to their input-derived identity, so reuse follows the requested derivation. That does not make architecture-specific artifacts interchangeable. Each runner still needs the artifact corresponding to its own derivation and platform.

Julien Malka of Télécom Paris, Institut Polytechnique de Paris, analyzed how reproducible builds could have detected the March 2024 xz backdoor through rebuilding suspect packages from independently sourced tarballs and checking for bitwise convergence. That verification depends on a locked, known input graph. Lockfile discipline consequently provides more than repeatable installation because it establishes the baseline against which a suspicious build can be examined.

How declarative environment manifests extend multi-arch SBOM consistency beyond the flake

A correctly structured flake reaches the team only when its environment definition travels with the code. Its devShells outputs extend the shared selection pattern to development tools. Developers on ARM Macs and engineers on x86 Linux can then enter the same declared environment, with the appropriate system-specific realizations. This also covers the toolchain, which locking application dependencies alone leaves out. It does not mean that every machine runs identical binaries.

Pairing the flake with direnv through nix-direnv makes that environment load automatically on entering the directory and unload on exit. Caching avoids rebuilding it for every session. For teams seeking day-one onboarding without repeated manual setup, the relevant mechanism is a code-associated environment definition that activates as part of ordinary work. The flake provides the declaration, while automatic shell activation makes using it the default. That is a concrete foundation for standardized setup rather than a claim that one architecture-specific shell covers every operating system.

For a team, the declarative manifest is what moves through each stage of the software development lifecycle. It may be flake.nix itself or a higher-level definition that composes it. The same pinned input graph links the developer environment, the CI evaluation, and the deployment artifact through their input-derived identities. Because the closure is known before deployment, SBOM generation can run in CI. Hash-level CVE blocking can then use that provenance to identify affected deployments.

AI coding agents that run inside the managed environment use the same declared tool versions, library paths, and system dependencies as human developers. Their code still builds against the declared closure. One locked graph becomes trustworthy release evidence only when output selection, build mode, SBOM comparison, and subsequent updates preserve its meaning across architectures.

More in Environment Parity