Security Notes
CI/CD & Supply Chain

Nix, Melange/Apko and Hermetic Reproducible Builds

40 min read 12 sections 9 model answers verified 2026-10 version 1 · updated 2026-10-24

Last verified2026-10 Also see: Dependency management (lockfiles, the 7-day rule, reproducible-build basics) · GitHub & CI/CD security (SLSA and Sigstore quick reference, pinning actions) · OWASP CI/CD Top 10 (CICD-SEC-9 artifact integrity) · Build platform security (caches, BuildKit, remote execution) · Sandbox isolation (gVisor vs microVMs) · Secure multi-tenant CI runner platform


Start here: the basics in plain words

A build turns source code into an artifact: a binary, a container image, a package. Most builds are not a pure function of the source. They also depend on whatever happens to be on the machine (the compiler version, a cached library, the clock, the locale) and on whatever the network returns that day. That hidden input is where supply-chain attackers live: change the machine or the network, and the "same" source silently produces a different artifact.

Two properties close that gap, and people constantly mix them up:

Hermetic build

The build can only see inputs you declared. No ambient compiler, no $HOME, no network (with one controlled exception). If an input is missing, the build fails instead of quietly picking something up from the host.

Reproducible build

Building the same source with the same declared inputs gives a byte-for-byte identical artifact, on any machine, at any time. You can check this by building twice and comparing hashes.

Derivation

Nix's word for a build recipe: the exact inputs, the builder program, the environment variables and the expected outputs. Stored as a .drv file. A derivation is data, so it can be hashed.

Store path

Where Nix puts every input and output: /nix/store/<hash>-<name>. The hash is computed from what the thing is (or from how it was built), so two different things can never share a path.

Binary cache

A server that hands out already-built store paths so you don't rebuild the world. Each entry is signed by the cache's key; your machine refuses unsigned entries by default.

Provenance

A signed statement that says "artifact X was built by builder Y from source Z with these inputs". SLSA (Supply-chain Levels for Software Artifacts) grades how trustworthy that statement is.

SBOM

Software Bill of Materials: the list of every package inside an artifact. Good ones are generated from the build's declared inputs; weak ones are guessed afterwards by scanning the filesystem.

The relationship between hermetic and reproducible, in one picture:

                 inputs declared?        same output every time?
hermetic only         yes                       no   (e.g. embeds a build timestamp)
reproducible only     no                        yes  (happens to be stable, but trusts the host)
both                  yes                       yes  ← the goal: anyone can rebuild and verify
neither               no                        no   ← a typical Dockerfile with `apt-get install` and `curl | bash`
Memory hook

"hermetic is about inputs, reproducible is about outputs." Hermetic: nothing gets in that you didn't list. Reproducible: what comes out never changes. You need both before "rebuild it and compare" becomes a security control.


What is this?

This page covers the tooling that makes builds a verifiable function of their inputs: Nix (a package manager and build system built around a content-addressed store and sandboxed builds), melange and apko (Chainguard's tools that build packages and container images declaratively, with no Dockerfile and no Docker daemon), and the attestation layer on top (in-toto statements, Sigstore/cosign keyless signing, SLSA levels). It also covers where the guarantees stop: fixed-output derivations that are allowed to reach the network, poisoned binary caches, and the question of who compiled your compiler.

Why it matters

An interviewer who asks about reproducible builds is not asking whether you can spell SBOM. They want to know whether you understand that the build system is part of the trusted computing base, that a compromised build host can produce a backdoored artifact from clean source (SolarWinds, 2020), and that the only general defence is to make builds independent of the host so a second, independent builder can catch the difference. For a platform that runs other people's CI jobs, hermeticity is also what makes a build cacheable and sandboxable: if the build declares its inputs, you can pre-fetch them through a controlled proxy and run the actual compilation with no network at all.


Hermeticity vs Reproducibility

What is this?Hermeticity is a property of the environment: the build is sealed. Reproducibility is a property of the result: it is deterministic. You can have one without the other in both directions.

Why it mattersEach property defeats a different attacker. A hermetic build defeats the attacker who changed something on the build host or on the network path. A reproducible build defeats the attacker who changed the artifact after the fact, or who compromised one builder but not another, because an independent rebuild exposes the mismatch. Only together do they give you "verify by rebuilding".

How it worksThe verification loop that both properties enable:

  1. 1. Pin every inputserversource commit, toolchain, dependencies, by hash
    • A lockfile, a Nix flake lock, a digest-pinned base image
  2. 2. Build in a sealed environmentdefendno host state, no network
    • Nix sandbox, BuildKit --network=none, a fresh microVM
  3. 3. Record what was usedserverprovenance
    • Builder identity, source digest, resolved dependency digests
  4. 4. Rebuild somewhere elsedefenda second builder you control, or a volunteer
    • Same inputs, different machine, different day
  5. 5. Compareattackbyte-for-byte
    • Mismatch = either non-determinism to fix, or one of the builders is lying

In practiceNix has the comparison built in. --rebuild builds a path that already exists and diffs the result:

$ nix build nixpkgs#hello --rebuild
$ nix build .#my-service --rebuild
error: derivation '/nix/store/9k2…-my-service-1.4.2.drv' may not be deterministic:
  output '/nix/store/4q7…-my-service-1.4.2' differs from '/nix/store/4q7…-my-service-1.4.2.check'   ← same recipe, different bytes

$ diffoscope /nix/store/4q7…-my-service-1.4.2 /nix/store/4q7…-my-service-1.4.2.check
├── bin/my-service
│ ├── readelf --notes
│ │ -  Build ID: 3f1c…a9                                                                           ← linker embedded a different build ID
│ │ +  Build ID: 77e0…c2
│ ├── strings
│ │ -  built 2026-10-24T09:12:41Z                                                                  ← a timestamp baked in at compile time
│ │ +  built 2026-10-24T09:13:05Z

Two rules fall out of this. A hermetic build that is not reproducible still protects you against host contamination, but you cannot use "rebuild and compare" as evidence. A reproducible build that is not hermetic (it just happens to be stable on your fleet) is fragile: the first time a host has a different /usr/bin/cc, the output changes and you will not know whether that was the host or an attacker.

📰

Real incident — SolarWinds Orion / SUNSPOT (2020). The pieces you need to know. Orion was built with MSBuild on SolarWinds' own build servers. MSBuild reads .cs source files from disk and compiles them; it trusts that what is on disk is what is in version control. What went wrong. A program the attackers named SUNSPOT ran on the build server. It polled the process list every second, and when it saw MsBuild.exe building the Orion solution it swapped one source file, InventoryManager.cs, for a backdoored copy, waited for the compile to finish, and put the original back. It checked the MD5 of the original before swapping so it only ever targeted the exact file it expected. Version control was clean the whole time. Why it's bad. The signed, released product contained the SUNBURST backdoor, shipped to roughly 18,000 organisations, and every code review, scanner and signature check passed because the source was fine and the signature was genuine. The compromise lived in the ten seconds between "read source" and "emit binary". The lesson. The build host is in the trusted computing base, so treat it like one: ephemeral, hermetic, and never the only builder. A second independent build of the same commit would have produced a different binary, and the mismatch is the detection. That is the whole argument for reproducibility as a security control, not a tidiness goal.

🎯

On the job. When you review a build pipeline, ask one question of every step: "what does this read that is not in the lockfile?" The answers (a base image by tag, curl | bash, apt-get update, $PATH on the runner, the system clock) are your list of unpinned inputs. Each is a place where the artifact can change without a source change.


Nix Architecture: Derivations, the Store and the Sandbox

What is this?Nix models a build as a pure function. The recipe (a derivation) lists its inputs by store path, and the output lands at a store path whose name is a hash. Because inputs are named by hash, the whole dependency graph is a Merkle tree: change one byte in any input and every path that depends on it changes.

Why it mattersThis is what makes hermeticity enforceable rather than aspirational. The sandbox only mounts the store paths the derivation declares. If a build tries to read /usr/lib/libssl.so, the file does not exist inside the sandbox, so the build fails loudly instead of silently linking against the host.

The derivation

A .drv file is the complete, serialised recipe. The fields that matter:

outputsmap
inputDrvslist
inputSrcslist
systemstring
builderpath
argslist
envmap
outputs
Output names (usually out) and, for input-addressed builds, their pre-computed store paths
inputDrvs
Other derivations whose outputs this build needs, with which outputs
inputSrcs
Store paths that are plain sources (files added with builtins.path, patches, the builder script)
system
Target platform, e.g. x86_64-linux or aarch64-darwin; a different system is a different derivation
builder
The program that runs, typically /nix/store/…-bash/bin/bash
args
Arguments to the builder, usually the build script
env
The entire environment the builder sees; nothing else leaks in from the host

In practicenix derivation show prints the recipe. Abridged for a small Go service:

text
$ nix derivation show .#my-service
{
  "/nix/store/9k2…-my-service-1.4.2.drv": {
    "outputs": { "out": { "path": "/nix/store/4q7…-my-service-1.4.2" } },   ← output path known BEFORE building
    "inputDrvs": {
      "/nix/store/b1x…-go-1.23.4.drv": { "outputs": ["out"] },             ← the compiler is an input, by hash
      "/nix/store/c8m…-my-service-go-modules.drv": { "outputs": ["out"] }  ← vendored modules, a fixed-output derivation
    },
    "inputSrcs": [ "/nix/store/0s1…-source" ],                              ← the source tree, copied into the store
    "system": "aarch64-linux",
    "builder": "/nix/store/d4f…-bash-5.2p37/bin/bash",
    "env": {
      "CGO_ENABLED": "0",
      "GOFLAGS": "-trimpath -mod=vendor",
      "PATH": "/nix/store/b1x…-go-1.23.4/bin:/nix/store/…",                ← PATH lists store paths only; no /usr/bin
      "SOURCE_DATE_EPOCH": "315532800"                                      ← 1980-01-01, so nothing embeds "now"
    }
  }
}

Notice that the output path is already in the file. For an input-addressed derivation, Nix computes the output path from a hash of the recipe before the build runs. That is only sound if the build is deterministic, which is why non-determinism is treated as a bug in Nixpkgs, not a shrug.

How a store path is computed

Every store path is /nix/store/<digest>-<name>. The digest is derived from a fingerprint string:

fingerprint  = "<type>:sha256:<inner-digest>:/nix/store:<name>"
digest       = nix32( compress_to_20_bytes( sha256(fingerprint) ) )      ← 32 characters of Nix's base-32 alphabet

The <type> says what the inner digest covers:

source

the inner digest is the SHA-256 of the NAR (Nix Archive, a canonical serialisation of a directory tree). Used for sources and for fixed-output fetches. Content-addressed: same bytes in, same path out, regardless of how they were fetched.

text

the inner digest is over the literal text, used for .drv files and small generated files.

output:out

the inner digest is over the derivation itself (its ATerm serialisation), with one twist: wherever the derivation references a fixed-output derivation, the hash of that dependency's output content is substituted for the hash of its recipe. That is the "hash modulo fixed-output" rule, and it is why you can rewrite how a tarball is fetched (different mirror, different fetcher) without rebuilding everything above it.

A worked example, conceptually:

1. Your service's recipe changes (new Go flag)   → the .drv text changes
2. sha256(.drv modulo FODs)                        → new inner digest
3. "output:out:sha256:<inner>:/nix/store:my-service-1.4.2"  → new fingerprint
4. sha256 → compress → nix32                       → /nix/store/j3r…-my-service-1.4.2   ← a brand new path
5. Every derivation that lists this one as an input sees a new input path, so ITS fingerprint changes too

The security consequence: a store path is a commitment to the complete recipe graph. Two machines that agree on a path agree on every byte of every input that produced it. That is the property a binary cache signs.

Input-addressed vs content-addressed derivations

Input-addressed (default)

Output path = hash of the recipe. Known before the build. Simple, but if the recipe changes trivially (a comment in a build script) the output path changes and everything downstream rebuilds, even if the bytes would be identical.

Content-addressed (ca-derivations, experimental)

Output path = hash of the output bytes, computed after the build. Enables early cutoff: if a recipe change produces identical output, downstream paths are unchanged and nothing rebuilds. Requires a separate signed record (a "realisation") mapping each derivation to the output it produced.

What content addressing solves: with an input-addressed cache, a rebuild of glibc with a cosmetic change means a new path and a new signed entry for every package above it; the trust question "is this the glibc I expect?" is answered only by the recipe hash. With content addressing, a path is its bytes, so two independent builders that agree on the output automatically produce the same path, which is exactly the "rebuild and compare" check expressed as a store property.

What it introduces: the mapping from derivation to output is no longer computable; it is a claim. A cache now has to sign realisations ("drv X produced output Y"), and the client must decide whose claims to trust. Non-determinism also becomes visible rather than hidden: two builders that disagree produce two different valid paths, and you need a policy for which wins. The feature is still marked experimental in the Nix manual, and treating it as production-ready in an interview is a mistake; treat it as "the direction, with open trust questions".

The sandbox

On Linux, sandbox = true is the default. Each build runs in private PID, mount, network, IPC and UTS namespaces (the kernel's mechanisms for giving a process its own view of processes, filesystems, network, shared memory and hostname). The filesystem view is only the declared input store paths, a temporary build directory, a private /proc and /dev, and whatever sandbox-paths adds. There is no network device, so curl fails with "network unreachable", not with a surprise download.

In practiceChecking the configuration that matters on a builder:

text
$ nix config show | grep -E '^(sandbox|require-sigs|trusted-users|trusted-public-keys|substituters) '
require-sigs = true                                                            ← unsigned paths are refused
sandbox = true                                                                 ← Linux default; false on macOS
substituters = https://cache.nixos.org/
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
trusted-users = root                                                           ← anyone here can turn all of this off

On macOS the sandbox is off by default and, when enabled, is built on Apple's sandbox-exec profiles rather than namespaces. It is weaker (no private network namespace, so builds can still open sockets), which matters when your Apple Silicon builders under Apple's Virtualization framework (vz) are producing release artifacts: the VM is the hermetic boundary there, not Nix.

🎯

On the job. trusted-users is the setting to grep for in any Nix builder audit. The manual's own words are that adding a user there is "essentially equivalent to giving that user root access". A trusted user can pass --option sandbox false, add arbitrary substituters and trusted-public-keys on the command line, and import unsigned paths. If your CI job runs as a trusted user, every guarantee on this page is advisory.


Declarative Distroless Packaging: melange, apko and dockerTools

What is this?A Dockerfile is an imperative script: RUN apt-get install …, RUN curl … | sh. Each RUN can do anything, read anything and fetch anything. apko and melange replace that with declarations. melange builds an .apk package (the Alpine/Wolfi package format) from a YAML pipeline, signs it, and emits per-architecture outputs. apko assembles an OCI image from a list of .apk packages and a keyring, with no RUN steps at all, and writes an SBOM at the same time because it already knows exactly what it put in.

Why it mattersThe image build is the last step before deployment and the one most often done on a shared Docker daemon with root. Removing the daemon removes a privileged process from the runner; removing RUN removes arbitrary code execution and arbitrary network access from the image-assembly step; generating the SBOM from the inputs removes the guesswork of scanning a finished filesystem.

How it works

  1. 1. melange.yamlserverthe package recipe
    • Source URI + expected hash, build environment (which repos, which packages), pipeline steps, subpackages
  2. 2. melange build --signing-key melange.rsadefendpackage build
    • Runs in a container per architecture (QEMU for foreign arches); output .apk files are signed with your key
  3. 3. apko.yamlserverthe image recipe
    • contents.repositories, contents.keyring, contents.packages, entrypoint, user, environment. No RUN.
  4. 4. apko build apko.yaml img:tag img.tardefendimage assembly
    • Resolves packages, verifies apk signatures against the keyring, lays out layers, writes SBOM (SPDX JSON) per architecture
  5. 5. cosign sign + attesttlspublish
    • Image signed keylessly via OIDC; SBOM and provenance attached as attestations

In practiceAn apko.yaml for a Go service, and what the build prints:

yaml
contents:
  repositories:
    - https://packages.wolfi.dev/os
    - '@local /work/packages'                       # ← melange output from the previous step
  keyring:
    - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
    - /work/melange.rsa.pub                          # ← only packages signed by these keys are accepted
  packages:
    - wolfi-baselayout
    - ca-certificates-bundle
    - my-service@local                               # ← pinned to the local repo, no accidental upstream package of the same name
accounts:
  users: [{ username: app, uid: 65532 }]
  run-as: app
entrypoint:
  command: /usr/bin/my-service
archs: [x86_64, aarch64]
text
$ apko build apko.yaml my-service:1.4.2 my-service.tar --sbom-path sbom/
INFO building image my-service:1.4.2 for x86_64, aarch64
INFO verifying signature of my-service-1.4.2-r0.apk with /work/melange.rsa.pub        ← signature checked per package
INFO installed 6 packages                                                             ← six, not six hundred
INFO generating SBOM sbom/sbom-x86_64.spdx.json                                       ← written from the install list, not by scanning
INFO final image digest sha256:7a1f…e3 (x86_64), sha256:0c9d…b8 (aarch64)
$ apko build apko.yaml my-service:1.4.2 again.tar && sha256sum my-service.tar again.tar
7a1f…e3  my-service.tar
7a1f…e3  again.tar                                                                    ← identical: reproducible by default

Why the apko SBOM is better than a scanner's SBOM: a scanner looks at a filesystem and infers packages from file paths and metadata databases, missing statically linked code and vendored copies, and sometimes inventing matches. apko knows the package list, versions and the signing key that vouched for each one, because that list is the only way anything got into the image. The SBOM is a by-product of the build, not an audit of it.

Nixpkgs dockerTools gets to a similar place from the other direction: dockerTools.buildLayeredImage takes a Nix closure and writes an OCI image where each layer is a set of store paths. Reproducibility comes from the store; the SBOM can be derived from the closure. The difference is cultural more than technical: apko/melange speak the apk and OCI vocabulary a container team already knows, while dockerTools asks the team to adopt Nix for the whole build.

🎯

On the job. The smell in a Dockerfile review is any RUN that touches the network: apt-get, pip install, curl, go mod download. Each one is an unpinned input and a network egress during the build. The migration path is: pin digests → move fetches into a dedicated stage with --network allowed and hash verification → build with --network=none → replace the whole thing with apko or dockerTools once packages are the unit of trust.


Cryptographic Integrity: in-toto, cosign and SLSA

What is this?A reproducible build proves what was built. Attestation proves who built it and how, in a form someone else can verify without rebuilding. in-toto defines the statement format. Sigstore's cosign signs it, usually "keyless": a short-lived certificate is issued to a workload's OIDC identity by the Fulcio certificate authority and recorded in the Rekor transparency log, so there is no long-lived signing key to steal. SLSA v1.0 grades the build platform that produced the statement.

How it worksThe shape of a provenance attestation:

_typestring
subjectlist
predicateTypestring
predicate.buildDefinition.buildTypestring
predicate.buildDefinition.externalParametersmap
predicate.buildDefinition.resolvedDependencieslist
predicate.runDetails.builder.idstring
predicate.runDetails.metadatamap
_type
https://in-toto.io/Statement/v1
subject
What this statement is about: artifact name and digest (sha256:…)
predicateType
https://slsa.dev/provenance/v1
predicate.buildDefinition.buildType
What kind of build, e.g. a GitHub Actions workflow or a Buildkite pipeline definition
predicate.buildDefinition.externalParameters
Inputs the user controlled: source repo, ref, workflow path
predicate.buildDefinition.resolvedDependencies
Inputs the build resolved: source commit digest, action digests, base images
predicate.runDetails.builder.id
The platform that ran the build and signed this statement
predicate.runDetails.metadata
Invocation id, start and finish times

In practiceVerifying an image's provenance against a specific builder identity:

text
$ cosign verify-attestation \
    --type slsaprovenance1 \
    --certificate-oidc-issuer https://token.actions.githubusercontent.com \
    --certificate-identity-regexp '^https://github.com/acme/release-workflows/.github/workflows/build.yml@refs/tags/v[0-9.]+$' \   ← the exact reusable workflow, not "anything in the org"
    ghcr.io/acme/my-service@sha256:7a1f…e3 | jq -r '.payload' | base64 -d | jq '.predicate.runDetails.builder.id, .predicate.buildDefinition.resolvedDependencies[0]'
Verification for ghcr.io/acme/my-service@sha256:7a1f…e3 --
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline        ← Rekor inclusion proof
  - The code-signing certificate was verified using trusted certificate authority certificates
"https://github.com/acme/release-workflows/.github/workflows/build.yml@refs/tags/v2.3.0"
{ "uri": "git+https://github.com/acme/my-service@refs/tags/v1.4.2", "digest": { "gitCommit": "e1b2…9f" } }   ← which commit, exactly

The line that does the security work is --certificate-identity-regexp. Without it, verification only proves "someone with a GitHub OIDC token signed this", which includes every fork on the planet.

SLSA v1.0 Build track, and why self-hosted is the hard case

LevelRequirement (v1.0 wording)What it stops
L1Provenance exists, showing how the package was builtMistakes and confusion about what was shipped; nothing adversarial
L2Signed provenance, generated by a hosted build platformA user forging provenance after the fact; tampering between build and consumer
L3Hardened build platform: runs cannot influence each other, and the signing secret is unreachable from user-defined build stepsThe build itself forging or altering its own provenance; one tenant's job poisoning another's

GitHub's artifact attestations give L2 by default and L3 when the build runs in a reusable workflow, because the reusable workflow separates the trusted build definition from the calling repository's steps. On self-hosted Buildkite or GitHub runners the platform is you, so L3 means you must build the separation yourself:

  1. 1. Job runs in a fresh sandboxservergVisor, Firecracker or a vz VM, destroyed after
    • Satisfies "runs cannot influence one another" only if caches and workspaces are also per-tenant
  2. 2. The job never holds the signing identitydefendthe hard requirement
    • Provenance is produced by a controller that observed the job, not by a step inside it
  3. 3. Controller signs keylessly with its own OIDC identitytlsFulcio + Rekor
    • The job's OIDC token (if any) is for cloud access, not for attesting itself
  4. 4. Provenance lists what the controller resolvedservernot what the job claims
    • Source digest from the checkout the controller performed, dependency digests from the proxy's log

The failure that drops you from L3 to L1 is letting a build step call cosign attest with a credential available to the job. A malicious step can then attest to anything, and the signature is genuine.

📰

Real incident — tj-actions/changed-files (2025, CVE-2025-30066). The pieces you need to know. A GitHub Action is code pulled into your job at run time by a reference like uses: tj-actions/changed-files@v45. A tag is a movable pointer, and the runner process holds every secret the job was given, in memory. What went wrong. In March 2025 an attacker with access to the action's repository rewrote almost every version tag to point at one malicious commit. The new code dumped the runner worker's memory, found the secrets in it and printed them, encoded, into the public build log. Any pipeline that referenced the action by tag, in tens of thousands of repositories, ran the attacker's code on its next trigger. Why it's bad. No pipeline file changed and no source changed. The artifact of the pipeline (its logs, and anything built with the stolen credentials) was compromised by an unpinned build input. The lesson. An action by tag is a dependency without a lockfile. Pin to a commit SHA, prefer a vetted internal mirror, and make the provenance record the resolved digest of every action so the attestation shows what actually ran.


Cross-Architecture Determinism

What is this?Reproducibility is defined per target: the same source and toolchain, for the same target platform, must give the same bytes. An x86-64 build on an AMD EPYC host and an arm64 build on an AmpereOne host are supposed to differ. The question is whether the x86-64 build is identical whether it was produced on EPYC, on an AmpereOne cross-compiling, or on an Apple Silicon machine inside a Linux VM.

Why it mattersA platform with mixed hardware wants to build on whatever is free and verify anywhere. Every host-dependent input breaks that, and the breaks are silent: the binary works, it just has a different hash, and now "rebuild and compare" produces false alarms until nobody looks at it.

How it worksThe usual culprits:

Timestamps

__DATE__/__TIME__ in C, archive mtimes, "built at" strings. Fix: export SOURCE_DATE_EPOCH (seconds since 1970, commonly the last commit time) and let gcc ≥ 7, clang ≥ 16, Docker Buildx ≥ 0.10 and most packagers honour it.

Build paths

Absolute paths embedded in debug info and panics. Fix: Go -trimpath; C/C++ -fdebug-prefix-map=$PWD=. or -ffile-prefix-map; build in the same fixed directory everywhere.

Archive metadata

tar/zip record owner, mode, mtime and directory order as the filesystem returned it. Fix: tar --sort=name --mtime=@$SOURCE_DATE_EPOCH --owner=0 --group=0 --numeric-owner --pax-option=delete=atime,delete=ctime.

Locale and sort order

sort and ls differ between en_US.UTF-8 and C. Fix: LC_ALL=C in the build environment, which a hermetic sandbox gives you for free.

Hash-map iteration

Go maps and many generators iterate in random order. Fix: sort before emitting; the Go toolchain itself had to do this to become reproducible.

Parallelism

Link order or codegen that depends on which worker finished first. Fix: deterministic ordering in the build graph; -j should change speed, not output.

Host-specific codegen

-march=native on EPYC emits AVX-512; on another host it does not. Fix: name the target explicitly (-march=x86-64-v3), never native.

Host C toolchain

cgo links against whatever libc and linker the host has. Fix: CGO_ENABLED=0 where possible; otherwise pin the C toolchain as a declared input, which is exactly what a Nix stdenv is.

Apple specifics

Ad-hoc code signatures embed a hash of the binary (fine) but signing with a timestamp authority embeds the time (not fine); different Xcode/SDK versions emit different Mach-O load commands; the ld-prime linker orders symbols differently from classic ld64. Fix: pin Xcode and SDK versions in the vz VM image, sign in a separate step, and treat "built in this image digest" as part of the recipe.

Emulation vs native

Building arm64 on x86 under QEMU user-mode should give identical bytes to native arm64, but kernel-visible details (uname -m, CPU feature flags) can leak into configure scripts. Fix: configure against the target, not the host; compare the two outputs once and keep the check in CI.

In practiceGo is the easy case once you know the two switches:

text
$ CGO_ENABLED=0 go build -trimpath -ldflags='-buildid=' -o out/my-service ./cmd/my-service
$ sha256sum out/my-service
c41e…07  out/my-service
$ go version -m out/my-service | grep -E 'vcs|CGO|trimpath'
	build	-trimpath=true                                   ← no /home/runner paths inside
	build	CGO_ENABLED=0                                    ← no host libc or linker involved
	build	vcs.revision=e1b2…9f
	build	vcs.modified=false                               ← built from a clean checkout; "true" means the hash is unrepeatable by design
# same command on an AmpereOne builder targeting GOARCH=amd64:
$ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-buildid=' -o out/my-service ./cmd/my-service && sha256sum out/my-service
c41e…07  out/my-service                                  ← identical bytes from a different host architecture

Go 1.21 was the first toolchain release whose own binaries were perfectly reproducible, and the list of things the Go team had to remove to get there (map ordering, host linker paths, cgo, timestamps, user IDs in archives, an ARM floating-point default that depended on the build host) is a good checklist for any language. For TypeScript the equivalent discipline is npm ci from a committed lockfile, a bundler with deterministic chunk naming, and no postinstall scripts that fetch binaries; for C it is a pinned toolchain plus the flags above.

🎯

On the job. Put a determinism check in the pipeline, not in a wiki: build the release artifact twice on two different runner pools (EPYC and AmpereOne, or Linux and the vz Linux guest on Apple Silicon) and fail the release if the digests differ. The first week finds your timestamps and paths; after that, a failure means something changed that you did not change.


Failure Modes and Attack Vectors

Fixed-output derivations: the hole in the sandbox, by design

The pieces you need to knowA hermetic sandbox has no network, but source code has to come from somewhere. Nix's answer is the fixed-output derivation (FOD): a derivation that declares outputHash, outputHashAlgo and outputHashMode up front. Because the output's hash is already known, Nix lets this one kind of derivation keep the host's network namespace. Whatever it downloads is checked against the declared hash and rejected on mismatch. This is how fetchurl, fetchFromGitHub and the Go/npm/cargo vendoring helpers work.

What can go wrong

The hash was never independently checked.

The common workflow is to write hash = lib.fakeHash, run the build, and paste the "got:" value from the error. That pins whatever the network returned that day, which is trust-on-first-use. If the upstream was already compromised, you have now notarised the compromise.

A FOD is a network-capable process inside your builder.

The output is pinned, but the process can do anything on the way: beacon to a server, or send out the contents of the build directory. Nixpkgs reviews FODs for this reason, but a private repository's FODs get whatever review your team gives them.

Fetcher output that depends on the fetcher.

A fetchgit with leaveDotGit = true, or a vendoring helper whose output depends on the tool version, produces hash mismatches that teams "fix" by updating the hash without asking why it changed.

Escape hatches.

__noChroot = true with sandbox = relaxed, impureEnvVars (allowed only in FODs, for proxy settings), sandbox-fallback = true on a kernel that forbids user namespaces, and a job running as a trusted-users member can each quietly turn hermeticity off.

In practiceAn egress proxy makes FOD traffic visible and bounded. The sandbox keeps the host network namespace for FODs, so routing them through a proxy is a matter of setting the proxy variables (which is what impureEnvVars exists for) and denying everything else at the host firewall:

text
# builder host: only the proxy may leave; the build users' uids are nixbld1..32
$ nft list chain inet filter output | grep -E 'nixbld|drop'
    meta skuid 30001-30032 ip daddr 10.9.0.5 tcp dport 3128 accept    ← build users may only talk to the proxy
    meta skuid 30001-30032 drop                                        ← anything else from a build is dropped, and logged

# proxy log, one line per FOD fetch, correlated with the derivation name by the controller
2026-10-24T09:12:03Z ALLOW  drv=go-modules-my-service-1.4.2   CONNECT proxy.golang.org:443   bytes=18492011
2026-10-24T09:12:09Z ALLOW  drv=source-my-service-1.4.2       CONNECT github.com:443         bytes=2210441
2026-10-24T09:14:51Z DENY   drv=libfoo-fetch-1.0.3            CONNECT 203.0.113.9:443        reason=host-not-allowlisted   ← a FOD talking to something that is not a package source

A FOD that talks to an address that is not a known package host is either a packaging mistake or a beacon. Either way you want the DENY line, not the download.

Binary cache poisoning

The pieces you need to knowA binary cache serves .narinfo files (metadata) and .nar.xz files (the bytes). The narinfo carries a Sig: line: a signature by the cache's Ed25519 key over the store path, the NAR hash, the NAR size and the list of referenced paths. Your Nix checks that signature against trusted-public-keys before adding the path, because require-sigs is true by default.

What can go wrongAn attacker who controls the storage (an S3 bucket, an Attic or Cachix account, an internal HTTP server) but not the key can delete entries or serve stale ones, but cannot forge a path: the signature fails and Nix falls back to building. The real risks are therefore about the key:

The key lives in the CI job.

The natural way to populate a cache is nix copy --to s3://cache from the job that built the path, signed with a key the job can read. Now every job that can run on that pipeline can sign arbitrary paths, including a PR from a fork. Mitigation: the job pushes unsigned to a staging store; a separate signer service verifies the build came from a trusted runner and signs server-side (Attic's model, or your own controller).

Too many trusted keys.

A developer adds a colleague's cache key to trusted-public-keys "temporarily"; that key is now as trusted as cache.nixos.org for every path on the machine.

Content-addressed paths need no signature.

require-sigs applies to non-content-addressed paths. A source-type path's name is its hash, so it is self-verifying. But a realisation under ca-derivations ("this derivation produced that content") is a claim and must be signed; a cache that serves unsigned realisations is a cache you can be lied to by.

In practiceWhat a healthy narinfo looks like, and the error you want to see for a bad one:

text
$ curl -s https://cache.example.com/4q7…-my-service-1.4.2.narinfo
StorePath: /nix/store/4q7…-my-service-1.4.2
URL: nar/1b9c…f0.nar.xz
Compression: xz
FileHash: sha256:1b9c…f0
FileSize: 4120933
NarHash: sha256:0a3e…77                                                  ← hash of the unpacked bytes, what the signature covers
NarSize: 11829248
References: 4q7…-my-service-1.4.2 d4f…-bash-5.2p37 …
Sig: cache.example.com-1:MEUCIQ…==                                      ← key NAME must match an entry in trusted-public-keys

$ nix build .#my-service --substituters https://evil.example.net
warning: ignoring substitute for '/nix/store/4q7…-my-service-1.4.2' from 'https://evil.example.net', as it's not signed by any of the keys in 'trusted-public-keys'   ← the control working
building '/nix/store/9k2…-my-service-1.4.2.drv'...
📰

Real incident — xz-utils backdoor (2024, CVE-2024-3094). The pieces you need to know. xz is a compression library (liblzma) that, on several Linux distributions, is linked into the SSH server through systemd's notification library. Open-source projects publish a release tarball that is supposed to match a tagged commit, plus generated files (the configure script and its helper macros) that are not kept in version control. Downstream distributions build from the tarball. What went wrong. Over about two years a contributor gained maintainer trust, then shipped versions 5.6.0 and 5.6.1 whose release tarball contained a modified build macro, build-to-host.m4, that was not in the git repository. During the build it extracted code hidden inside two binary test files and patched it into liblzma. The resulting library hooked the SSH server's key verification so a holder of a specific private key could run commands as root. It was found in March 2024 by Andres Freund, a database developer, who noticed SSH logins using too much CPU. Why it's bad. The source in git was clean, the tarball was signed by the legitimate maintainer, and every build was reproducible: the same tarball gave the same backdoored library everywhere. Reproducibility proves you built what you were given. It says nothing about whether what you were given is what you think it is. The lesson. Reproducibility must reach back to the source you can read. Build from the tagged commit and regenerate configure yourself, or reproduce the tarball from the tag and compare; a tarball that cannot be regenerated from version control is an unverifiable input. Bootstrappable, hermetic builds that start from reviewed source are the structural fix, and they would have made the extra macro visible as a diff instead of a mystery.

🎯

On the job. Three greps on any Nix-based pipeline: __noChroot, sandbox = relaxed|false, and the identity that runs nix copy with a signing key. Then one question: "if a PR from a fork lands on this runner, what can it sign?" If the answer is "anything", the cache is a privilege escalation path from "open a PR" to "run code on every developer laptop".


The Bootstrap Trust Problem

What is this?Every compiler was compiled by an earlier compiler. Reproducible builds show that source S gives binary B with toolchain T. They do not show that T is honest. Ken Thompson's 1984 Turing Award lecture, Reflections on Trusting Trust, described a C compiler that inserts a backdoor when it compiles the login program, and inserts the backdoor-inserting code when it compiles itself, so the malice survives in the binary even after the source is cleaned.

Why it mattersIt sets the floor of what "verified from source" can mean. If your only compiler is a binary someone handed you, the whole reproducible tower stands on an unverifiable seed.

How it worksTwo practical answers exist:

Diverse Double-Compiling

(David A. Wheeler, 2009). Compile the compiler's source with two unrelated compilers, then use each result to compile the source again. If both second-stage binaries are identical, either both original compilers are honest or they collude byte-for-byte, which is implausible. This needs the compiler to be reproducible, which is why reproducibility and bootstrapping are the same research community.

Bootstrappable builds.

Shrink the binary seed until a human can audit it. The stage0 project starts from a hex loader of a few hundred bytes (hex0, under 400 bytes for x86), builds a slightly better assembler with it, then a tiny C compiler (via M2-Planet and Mes), then TinyCC, then an old GCC, then modern GCC. GNU Guix completed a full-source bootstrap on this chain in 2023, so its entire package set descends from that few-hundred-byte seed plus source code. Nixpkgs still starts from a prebuilt bootstrap tarball, but one that is itself produced by Nixpkgs and reproduced when it is updated, which closes the loop to "a previous version of ourselves" rather than to an auditable seed.

  1. hex0 seedosa few hundred bytes of machine code, human-auditable
  2. hex1 → hex2 → M0oseach a slightly more capable assembler built by the previous one
  3. cc_x86 → M2-Planetservera minimal C subset compiler, written in that subset
  4. Mes + MesCCservera small Scheme interpreter and its C compiler
  5. TinyCCservera real C compiler, built by MesCC
  6. GCC 2.95 → GCC 4.7 → modern GCCdefendeach built by the previous, all from source

In practiceYou will not run stage0 on a release pipeline. What you will do is name the seed in the provenance, so trust is explicit rather than assumed:

text
# resolvedDependencies entry for the toolchain, from a provenance statement
{ "uri": "pkg:nix/nixpkgs@e9f6…?attr=stdenv", "digest": { "sha256": "b7c0…51" },
  "annotations": { "bootstrap": "nixpkgs-bootstrap-tools-2025-11 (reproduced from nixpkgs 7d8a…)" } }   ← says where the compiler came from, and that it was rebuilt, not downloaded once in 2019
🎯

On the job. Interviewers rarely expect you to have run a bootstrap. They expect you to know the shape of the argument: reproducibility verifies a build given a toolchain; diverse double-compiling or a bootstrappable seed verifies the toolchain; and in practice you pick a toolchain whose own build is reproducible (Go since 1.21, Guix's full-source chain, Nixpkgs' rebuilt bootstrap tools) and record its digest in provenance.


In Practice: an Isolated Build Pipeline on Buildkite or GitHub Actions

What is this?Putting the pieces together on a platform you operate: bare-metal AMD EPYC and AmpereOne Linux hosts, Apple Silicon hosts running Linux and macOS guests under vz, with self-hosted Buildkite agents or GitHub Actions runners.

                  ┌──────────────── controller (Go) ───────────────┐
  pipeline event ─►  resolve source digest, mint job OIDC token,    │
                  │  start sandbox, record resolved inputs           │
                  └───┬──────────────────────────────┬─────────────┘
                      │                              │ after job: sign provenance
                      ▼                              ▼
   ┌──── sandbox (gVisor pod / Firecracker / vz VM) ────┐     ┌── signer ──┐      ┌── binary cache ──┐
   │ phase 1  FETCH   network → egress proxy only        │     │ keyless    │ ───► │ server-side sig  │
   │          FODs, go mod download, npm ci              │     │ cosign     │      │ require-sigs     │
   │ phase 2  BUILD   --network=none, declared inputs    │     └────────────┘      └──────────────────┘
   │ phase 3  OUTPUT  artifact + SBOM to controller      │
   └─────────────────────────────────────────────────────┘
                      │
                      ▼
              egress proxy (Go) ── allowlist per job: proxy.golang.org, registry.npmjs.org, github.com
                                   log every CONNECT with job id and derivation name

The rules the controller enforces:

Fetch and build are separate phases.

Phase 1 may talk to the proxy and nothing else; its outputs are content-addressed. Phase 2 has no network namespace (--network=none for BuildKit, the Nix sandbox default, or simply no NIC in the microVM).

The job never sees a signing credential.

Its OIDC token is scoped to read a package mirror and write to a staging bucket. The controller, with its own identity, generates and signs provenance from what it resolved: the commit it checked out, the proxy's log of what was fetched, the sandbox image digest.

Caches are per tenant and per trust level.

A pull-request build reads the main-branch cache but writes only to a PR-scoped one; nothing a PR produced is ever substituted into a release build.

Policy is data, not shell.

A small Starlark policy evaluated by the controller decides which hosts a job may fetch from and which attestation identities a deploy accepts, so a reviewer reads a diff of rules rather than of if statements in YAML:

python
# policy.star, evaluated by the controller for each job
def egress_allow(job):
    base = ["proxy.golang.org", "sum.golang.org", "registry.npmjs.org", "github.com"]
    if job.tenant == "platform":
        base += ["packages.wolfi.dev"]
    return base                                    # ← nothing else resolves; a FOD to anywhere else is DENY

def accept_artifact(att):
    return (att.issuer == "https://token.actions.githubusercontent.com" and
            att.identity.startswith("https://github.com/acme/release-workflows/") and   # ← only the vetted reusable workflow
            att.builder_id == "https://ci.acme.internal/controller/v3")

Deploy-time verificationThe last step is the consumer refusing anything without the right provenance: cosign verify-attestation (or slsa-verifier) against the identity above, plus a digest match against the SBOM and, for release artifacts, a second build on the other architecture's pool with a digest comparison.

🎯

On the job. If you inherit a pipeline and can change one thing, split fetch from build and put the proxy between them. It immediately gives you a list of every network input, a place to pin them, and a log that turns "a build downloaded something odd" from invisible into a DENY line with a derivation name on it.


Interview Q&A

Q
What is the operational and security difference between a hermetic build and a reproducible build, and can you have one without the other?
Model answer

Hermetic is about inputs: the build can only see what it declared, with no host state and no network, so if an input is missing it fails instead of picking something up from the machine. Reproducible is about outputs: same declared inputs give byte-identical results anywhere, which you check by building twice and comparing. You can have either alone. A hermetic build that embeds a timestamp is not reproducible, and a plain Makefile that happens to be stable on your fleet is reproducible but not hermetic, right up until a host has a different compiler. Security-wise, hermeticity defeats a tampered host or network, reproducibility defeats a tampered artifact or a single lying builder, and only together do they let a second builder act as a detector, which is the SolarWinds lesson.

Q
How does Nix compute store paths under input-addressed derivations versus content-addressed derivations, and what supply-chain attack surface does content addressing solve or introduce?
Model answer

An input-addressed path is a hash of the recipe: Nix serialises the derivation, substitutes the output hash for any fixed-output dependency, hashes that into a fingerprint string with the store directory and the name, then compresses the SHA-256 to twenty bytes and base-32 encodes it, all before the build runs. So a path commits to the whole input graph. A content-addressed path, under the still-experimental ca-derivations feature, is a hash of the output bytes computed after the build, which gives early cutoff and means two independent builders that agree on the bytes agree on the path, so reproducibility becomes a store property. What it introduces is that "derivation X produced path Y" is now a claim rather than a computation, so caches have to sign realisations and clients have to decide whose realisations to trust, and non-determinism turns into two valid competing paths instead of one hidden one.

Q
Why does apko avoid the Docker daemon and Dockerfiles entirely when assembling OCI images, and why are apko-generated SBOMs better than post-build scanner SBOMs?
Model answer

A Dockerfile's RUN steps are arbitrary code with network access executed by a root daemon, so the image-assembly step is both a privileged process on the runner and an unpinned set of inputs. apko replaces that with a declaration: a list of signed apk packages, the keyring that must have signed them, and the user and entrypoint. There is nothing to execute, so there is no daemon and no network during assembly, and running it twice gives the same digest. The SBOM is better because apko writes it from the install list it just resolved, including versions and the signing key for each package, whereas a scanner looks at a finished filesystem and infers packages from paths and databases, missing statically linked and vendored code and sometimes inventing matches. One is a record of the build, the other is a guess about it.

Q
How do you achieve SLSA Build Level 3 when running self-hosted Buildkite or GitHub Actions agents on hybrid bare-metal and cloud infrastructure?
Model answer

Level 3 in SLSA v1.0 has two hard requirements beyond signed provenance: build runs cannot influence each other, and the provenance-signing secret is unreachable from user-defined build steps. On self-hosted infrastructure you are the platform, so you build both. Isolation means each job runs in a fresh sandbox, gVisor or a Firecracker microVM on the Linux hosts and a vz VM on Apple Silicon, with per-tenant caches and workspaces destroyed afterwards. Signing separation means the job never calls cosign attest; a controller that started the job and observed it generates provenance from what it resolved itself, the commit digest, the proxy's fetch log, the sandbox image digest, and signs keylessly with its own OIDC identity. GitHub gets to the same place with reusable workflows for its hosted runners, which is a useful comparison: the reusable workflow is the trusted build definition, the calling repo is the untrusted step.

Q
What is a fixed-output derivation in Nix, why is it an exception to the sandbox's network isolation, and how can an egress proxy mitigate its abuse?
Model answer

A fixed-output derivation declares the hash of its output in advance, so Nix lets it keep the host's network namespace to fetch sources, then verifies the download against the hash and rejects a mismatch. That makes it the only legitimate network path into an otherwise sealed build, and it is how every fetcher works. The abuse is twofold: the hash is often pinned by trust-on-first-use, copying whatever the network returned, and the fetcher is a network-capable process that can beacon or send things out even though its output is pinned. An egress proxy fixes the visibility problem: route the build users' traffic to the proxy only, drop everything else at the host firewall, allowlist package hosts per job, and log every connection with the derivation name, so a fetch to an unknown address becomes a logged deny instead of a silent download.

Q
When compiling Go, C and TypeScript across x86-64 EPYC, arm64 AmpereOne and Apple Silicon under vz, what compiler and filesystem factors silently break bit-for-bit reproducibility?
Model answer

The usual ones are timestamps, build paths, archive metadata, locale, map iteration order and host-specific code generation. Concretely: C's __DATE__ and archive mtimes, fixed with SOURCE_DATE_EPOCH; absolute paths in debug info, fixed with Go's -trimpath and gcc's prefix-map flags; tar recording owner, mode and directory order, fixed with sorted, zeroed-owner archives; -march=native emitting AVX-512 on EPYC and nothing of the sort elsewhere, fixed by naming the target level; and cgo pulling in the host libc and linker, fixed with CGO_ENABLED=0 or a pinned toolchain. Apple adds Xcode and SDK versions, the linker's symbol ordering, and code-signing timestamps, so you pin the VM image and sign in a separate step. Go 1.21 is a useful proof that this is achievable: the toolchain itself became perfectly reproducible once the team removed exactly those factors.

Q
How can a compromised binary cache serve backdoored artifacts to downstream builders, and how does signature verification protect clients?
Model answer

Every cache entry's narinfo carries a signature by the cache key over the store path, the NAR hash, size and references, and Nix with the default require-sigs refuses any path not signed by a key in trusted-public-keys. So an attacker who controls the storage but not the key can only delete or serve stale entries, which makes Nix rebuild, not run their code. The real attack is on the key: if the CI job that built the path also holds the signing key, every job on that pipeline, including a fork's pull request, can sign arbitrary paths and push them to developers and production. The defence is to move signing out of the job into a service that verifies where the build ran, keep trusted-public-keys to the minimum, and never put CI users in trusted-users, which the Nix manual equates to root. Content-addressed source paths are self-verifying, but realisations under ca-derivations are claims and must be signed too.

Q
What is the Reflections on Trusting Trust compiler bootstrap dilemma, and how do modern reproducible systems anchor trust in their initial toolchain?
Model answer

Thompson showed in 1984 that a compiler can insert a backdoor when compiling a target program and also insert that behaviour when compiling itself, so the malice persists in binaries after the source is clean. Reproducible builds prove source plus toolchain gives this binary; they do not prove the toolchain is honest. Two answers exist. Diverse double-compiling builds the compiler with two unrelated compilers and checks the second-stage outputs match, which only works if the compiler is reproducible. Bootstrappable builds shrink the binary seed to something auditable: the stage0 chain starts from a hex loader of a few hundred bytes, climbs through tiny assemblers and a minimal C compiler to TinyCC and GCC, and Guix completed a full-source bootstrap on it in 2023. Nixpkgs rebuilds its bootstrap tarball from itself. In practice you pick a toolchain whose own build is reproducible and record its digest in provenance so the trust anchor is named rather than assumed.

Q
A reviewer says "we sign all our images with cosign, so we are safe from supply-chain attacks." What is wrong with that statement?
Model answer

A signature proves who signed, not what was built or whether it was built correctly. If the build step itself holds the signing identity, a malicious dependency or a poisoned action signs its own output with a perfectly valid signature; tj-actions/changed-files in 2025 ran attacker code inside tens of thousands of legitimate pipelines. Signing is the last link; the links before it are pinned inputs, a hermetic build with no network, provenance generated by something other than the job, and a verifier at deploy time that checks the certificate identity against the one vetted workflow rather than accepting any token from the issuer. And none of that catches a bad input that was signed by its legitimate maintainer, which is the xz lesson: you also need the source you build to be the source you can read.