Fetchers and fixed-output derivations
Fetchers and fixed-output derivations
Goal: Use fixed-output derivations (FODs) correctly—fetchurl, fetchFromGitHub, vendor hashes—and debug hash mismatches without turning off purity.
Baseline: Nix 2.34 / nixpkgs 26.05. Prefer SRI hash = "sha256-…" over legacy sha256 = "…" attrs (both still appear in the wild).
Why it matters
Every package starts as bytes from somewhere. If those bytes are not fixed-output and hashed, your “reproducible” package is a network role-play. FODs are the contract: given this hash, the store path is determined by content, and the build may touch the network only to produce those exact bytes.
Without FOD discipline you cannot trust CI, caches, or offline rebuilds. With it, hash mismatches become a clear signal: either you meant to bump content, or something untrustworthy changed.
Theory 1 — Fixed-output vs normal derivations
| Kind | Network in sandbox? | Output path depends on | Failure mode |
|---|---|---|---|
| Normal build | No (default) | Inputs (input-addressed) | Compile errors, missing tools |
| Fixed-output (FOD) | Yes, limited | Declared output hash | Hash mismatch if bytes change |
FODs power almost every src = fetch… you see in nixpkgs.
src = fetchurl {
url = "https://example.com/foo-1.2.3.tar.gz";
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};Why the store path is special
For FODs, the output path is a function of the hash (and related fixed-output metadata), not of the builder script details the same way normal derivations are. That is why two packages fetching the same bytes can share a store path, and why a wrong hash never “quietly” accepts different content.
Normal: out = f(name, builder, input paths, env, system, …)
FOD: out = f(outputHash, outputHashMode, …) # content-addressed flavor
(Exact hashing rules are an internals topic; operationally: trust the hash field.)
Theory 2 — Common fetchers (2026 mental map)
| Fetcher | Use when | Notes |
|---|---|---|
fetchurl |
Direct tarball/file URL | Mirrors, curlOpts, name |
fetchTarball |
Eval-time tarball (flakes/inputs style) | Different API; not always for package src |
fetchFromGitHub |
GitHub repo + rev/tag | Sets owner, repo, rev, hash |
fetchFromGitLab |
GitLab | Similar shape |
fetchgit |
Arbitrary git | Needs hash; leaveDotGit carefully |
fetchzip |
Zip that should unpack | Unpacks in fetcher |
fetchpatch / fetchpatch2 |
Single patch files | Great for patches = [ … ] |
fetchPypi |
Python sdist/wheel helpers | Via python infra |
| Language-specific vendor hashes | Go modules, Cargo, npm | See language ecosystem chapters |
fetchFromGitHub pattern
{ stdenv, fetchFromGitHub, lib }:
stdenv.mkDerivation rec {
pname = "example-src-only";
version = "14.1.1";
src = fetchFromGitHub {
owner = "BurntSushi";
repo = "ripgrep";
rev = version; # tag or full commit
hash = "sha256-…"; # FOD for the archive Nix downloads
};
# real packaging would use rustPlatform; this is fetcher-focused
meta = {
description = "Fetcher demo only";
license = lib.licenses.unfreeRedistributable; # demo—set real license for real pkgs
};
}Prefer tags or full commit SHAs over branch names (main). Branches move; FODs must not.
Submodules and deep clones
src = fetchFromGitHub {
owner = "org";
repo = "repo";
rev = "v1.0.0";
hash = "sha256-…";
fetchSubmodules = true; # changes the hash vs without
};Sparse / partial checkouts are advanced—prefer release tarballs when upstream publishes them.
Theory 3 — The hash mismatch workflow
You will see this forever:
error: hash mismatch in fixed-output derivation '…':
specified: sha256-AAAA…
got: sha256-BBBB…
Correct response
- Trust “got” only after you trust the URL/rev. If the project was compromised, blindly updating the hash pins malware.
- Confirm you intended that
rev/ version.
- Replace
hashwith thegotvalue (or uselib.fakeHashonce to force a fail).
- Rebuild; lock the known-good hash in git.
- Never leave
hash = "";orlib.fakeHashin committed production flakes.
Prefetch helpers
# URL
nix-prefetch-url https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz
nix store prefetch-file https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz
# Flake-style prefetch (metadata + hash)
nix flake prefetch github:BurntSushi/ripgrep/14.1.1
# Generic: let the build fail once
# hash = lib.fakeHash;# Temporary during packaging
hash = lib.fakeHash; # forces mismatch with a clear "got"SRI vs base32
Modern nixpkgs prints and prefers SRI (sha256-…). Older docs show base32. Both can refer to the same bytes; do not mix formats carelessly when comparing strings by eye—use the got: line Nix prints.
Theory 4 — Impure traps and purity
| Trap | Why it hurts | Fix |
|---|---|---|
builtins.fetchGit without flake lock discipline |
Eval-time drift | Prefer flake inputs or FOD fetchers with hash |
Branch rev = "main" |
Hash churn | Pin commit |
curl in a normal buildPhase |
Sandbox network blocked | Use FOD or pkgs.fetchurl as input |
| Post-download patching inside FOD | Changes bytes → hash mismatch | Patch in normal derivation patches |
Leaving .git without care |
Non-determinism | Default archives omit .git; leaveDotGit is advanced |
| Mirror that rewrites tarballs | Same URL, new bytes | Pin mirrors; notice mismatch as signal |
| LFS pointers instead of files | Tiny “source”, broken build | Ensure LFS materialization in fetch |
Rule: Download and fix bytes in the FOD or patch after unpack in the normal build—do not mix casually.
[FOD: pure download → exact bytes]
│
▼
[Normal derivation: patch / compile / install]
Theory 5 — Flake inputs vs package fetchers
| Mechanism | Pinned by | Good for |
|---|---|---|
inputs.foo.url = "github:…" |
flake.lock |
Nix code, whole repos as flakes |
fetchFromGitHub in a package |
hash on the fetcher |
Upstream source of a package |
pkgs.fetchgit of a monorepo path |
hash + sparse options | Subdir projects |
path: / ./src |
Your git tree | Lab-local code |
Do not confuse “my flake input updated” with “my package src hash updated.” Both can change store paths; only one is your app’s source tarball.
# flake input — for Nix modules / another flake
inputs.tooling.url = "github:acme/tooling/v1.2.3";
# package src — for building C/Go/… sources
src = fetchFromGitHub {
owner = "acme";
repo = "tooling";
rev = "v1.2.3";
hash = "sha256-…";
};Sometimes you use both: flake input for Nix files, FOD for the language project’s release tarball. That is normal—document which is which.
Theory 6 — outputHashMode (awareness)
FODs declare how content is hashed:
| Mode | Typical use |
|---|---|
flat |
Single file |
recursive |
Directory tree (default for many fetchers) |
You rarely set this by hand when using fetchurl / fetchFromGitHub. When writing custom FODs (stdenv.mkDerivation with outputHash), wrong mode causes confusing mismatches. Prefer high-level fetchers.
# Conceptual custom FOD — prefer fetchurl in real packages
stdenv.mkDerivation {
name = "my-fod";
builder = …;
outputHashMode = "flat";
outputHashAlgo = "sha256";
outputHash = "sha256-…";
}Theory 7 — Private sources and credentials
| Approach | Notes |
|---|---|
| CI token in netrc / access tokens | Keep out of world-readable store; prefer FODs that do not embed secrets in drv env |
SSH fetchgit |
Agent/daemon SSH complexity; document who can build |
| Vendor a release tarball internally | Mirror to your cache/object store + fetchurl |
builtins.fetchGit with local credentials |
Eval-time; flake lock discipline still required |
Never put passwords in hash or URL query strings committed to git. Prefer: internal mirror + FOD hash of the mirrored artifact.
Worked examples
Example A — tarball package skeleton
# pkgs/hello-fod.nix
{ stdenv, fetchurl, lib }:
stdenv.mkDerivation rec {
pname = "hello";
version = "2.12.1";
src = fetchurl {
url = "https://ftp.gnu.org/gnu/hello/hello-${version}.tar.gz";
hash = "sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=";
};
doCheck = true;
meta = {
description = "GNU Hello via explicit FOD";
license = lib.licenses.gpl3Plus;
mainProgram = "hello";
};
}Example B — GitHub + patch FOD
{ stdenv, fetchFromGitHub, fetchpatch, lib }:
stdenv.mkDerivation {
pname = "example";
version = "1.0.0";
src = fetchFromGitHub {
owner = "example-org";
repo = "example";
rev = "v1.0.0";
hash = "sha256-SRCSOURCESOURCESOURCESOURCESOURCESOURCESO=";
};
patches = [
(fetchpatch {
url = "https://github.com/example-org/example/commit/abcdef.patch";
hash = "sha256-PATCHPATCHPATCHPATCHPATCHPATCHPATCHPATCHPA=";
})
];
meta = {
license = lib.licenses.mit;
};
}Local patches (./patches/foo.patch) are path inputs, not FODs. Remote fetchpatch is a FOD—second hash to maintain.
Example C — intentional mismatch
src = fetchurl {
url = "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz";
hash = lib.fakeHash;
};First build prints the real hash; paste it back.
Example D — flake wiring
{
description = "FOD lab";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in {
packages.${system}.hello-fod = pkgs.callPackage ./pkgs/hello-fod.nix { };
packages.${system}.default = self.packages.${system}.hello-fod;
};
}Lab — multi-step
Suggested workspace: ~/lab/nixos/packaging/fod
Step 1 — Prefetch a known tarball
mkdir -p ~/lab/nixos/packaging/fod/pkgs
cd ~/lab/nixos/packaging/fod
nix store prefetch-file \
https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gzRecord the SRI hash.
Step 2 — Package from that tarball
Write pkgs/hello-fod.nix with fetchurl + your hash. Export from a flake:
nix build .#hello-fod -L
./result/bin/helloStep 3 — Force a mismatch
Change one character of the hash; rebuild; capture the error. Restore the correct hash.
Step 4 — GitHub fetcher
Pick a tiny tagged GitHub project (or a single-file tool). Package with fetchFromGitHub:
src = fetchFromGitHub {
owner = "…";
repo = "…";
rev = "vX.Y.Z";
hash = lib.fakeHash;
};Iterate until the hash stabilizes. Prefer a project with a clear install (copy binary/script).
Step 5 — Patch as FOD and as path
Add a local patch first:
patches = [ ./patches/fix-typo.patch ];Then replace with fetchpatch for one upstream commit if available—note the second hash.
Step 6 — Document the trust boundary
In NOTES.md:
- Where did bytes come from?
- Who could change them?
- What does updating
hashmean operationally?
- Difference between updating
flake.lockand updatingsrc.hash.
Step 7 — Offline check
After populate:
nix build .#hello-fod --offlineIf it works, your FOD is already in the store; purity is about definition, not “never used network once.”
Step 8 — Submodules experiment (optional)
Find a tiny repo with a submodule or skip with a written note on why fetchSubmodules changes hashes.
Exercises
- Prefetch the same URL twice; confirm hash identity.
- Change only the
nameof afetchurl(if supported) and observe whether the store path changes—document findings.
- Write a one-paragraph runbook: “upstream re-tagged v1.2.3 with different bytes.”
- Compare
nix flake prefetchoutput tofetchFromGitHubhash for the same rev—are they interchangeable? (Often related but not always drop-in; note what you observe.)
- List every FOD in one of your language packages (src + vendor/cargo/npm).
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Hash mismatch after “nothing changed” | Upstream re-released same tag; pin commit SHA |
fetchFromGitHub hash differs on Darwin vs Linux |
Unusual for pure archives; check name, filters, or LFS |
Using empty sha256 |
Prefer lib.fakeHash for clarity; never commit fakes |
| Network error inside normal derivation | Move download to fetcher |
| Submodules missing | fetchSubmodules = true (+ hash changes) |
Huge monorepo as src |
Sparse checkout / release tarball / sourceRoot |
| Private repo fetch | Mirror + FOD; careful with credentials |
| Patched inside FOD builder | Move patches to normal derivation |
| Confused flake lock with src hash | Update the pin that actually feeds the package |
leaveDotGit = true nondeterminism |
Avoid unless required; pin carefully |
Checkpoint
- Explain FOD vs normal derivation in one paragraph
- Packaged something with
fetchurland correct SRI hash
- Packaged something with
fetchFromGitHub
- Demonstrated and fixed a hash mismatch deliberately
- Notes on trust when updating hashes
- Offline rebuild succeeded for a cached FOD
- Distinguishes flake input pins from package
srchashes
Commit
git add .
git commit -m "packaging: fetchers and FOD hash workflow"Write three personal gotchas before continuing to language ecosystems.