Day 18 — Debug Builds, nixpkgs Layout & Binary Caches

Updated

July 30, 2025

Day 18 — Debug Builds, nixpkgs Layout & Binary Caches

Day 51 — Debug builds

Stage V · ~4h (lab-heavy)
Goal: Recover from failed package builds using nix log, -L, --keep-failed, breakpointHook, and structured isolation of phase failures—without disabling the sandbox as a first resort.

Why this day exists

Packaging skill is half writing expressions and half reading sandboxes under stress. Teams lose days because someone only knows “it failed” and rebuilds with random flags. Today you build a deliberate debug playbook.


Theory 1 — Where information lives

Tool Shows When
nix build -L / --print-build-logs Live phase output First failure
nix log <drv\|path> Stored build log After failure/success
nix show-derivation JSON-ish drv env Input/env inspection
--keep-failed Leaves tmp build dir Interactive inspect
breakpointHook Drops into shell on failure Deep phase debug
NIX_LOG_FD / verbose Extra noise Rare
nix build .#broken -L
nix log .#broken
nix derivation show .#broken | less

Modern CLI accepts installables (.#pkg); classic nix-store -l /nix/store/...drv still works.


Theory 2 — Keep failed trees

nix build .#broken --keep-failed -L

On failure, Nix prints a path under /tmp/nix-build-… (or configured temp). You can:

cd /tmp/nix-build-foo-*
# inspect source tree after patchPhase, object files, config.log, etc.
Warning

The kept tree is not the sandbox re-entered with the same env. It is a corpse for forensics. Re-running make there may work or lie—prefer breakpointHook for a live env.


Theory 3 — breakpointHook

{ stdenv, breakpointHook }:
stdenv.mkDerivation {
  pname = "explode";
  version = "0.0.1";
  dontUnpack = true;
  nativeBuildInputs = [ breakpointHook ];
  buildPhase = ''
    echo "about to fail"
    false
  '';
}

When the build fails, you get a shell inside the build environment (with caveats on platforms and interactive settings). Fix hypotheses, then exit and fix the Nix expression permanently.

Also related: pkgs.busybox / extra tools in nativeBuildInputs temporarily for inspection (strace, file)—remove before merge.


Theory 4 — Common failure classes

Class Signals First moves
Hash / FOD hash mismatch Day 46 workflow
Configure config.log, missing headers nativeBuildInputs, PKG_CONFIG_PATH
Compile error:, missing symbols deps, flags, patches
Link undefined reference buildInputs, order, static/shared
Install empty $out, wrong paths installPhase, multi-out
Check test suite doCheck, sandbox network, golden files
Patch rejects refresh patch
Purity banned network/path FOD or remove impurity
Eval before build fix Nix syntax/attrs—not nix log of build

Separate eval failures from build failures immediately:

nix eval .#packages.x86_64-linux.foo.drvPath
# works? then build issue. fails? expression issue.

Theory 5 — Sandbox: don’t disable casually

Flag / knob Effect Use?
--option sandbox false Allows host impurities Last resort local diagnosis only
--no-sandbox Same family Never in CI policy
require-sigs / substituters Cache trust Unrelated to compile fails
__noChroot (legacy patterns) Avoid Prefer proper FODs

If it only builds with sandbox off, your package is not done.


Theory 6 — Reproducing CI failures locally

# Clean-ish rebuild of one attr
nix build .#foo --rebuild -L

# Ignore binary cache for this attr (force local compile)
nix build .#foo --option substitute false -L

# Show why a dep is present
nix why-depends .#foo .#bar

Match CI: same flake lock, same system, same Nix version band (book baseline: 2.34.x, nixpkgs 26.05).


Worked example — scripted break/fix series

# pkgs/debug-lab.nix
{ stdenv, lib, breakpointHook }:
stdenv.mkDerivation {
  pname = "debug-lab";
  version = "0.1.0";
  src = ../src/debug-lab; # contains broken.c / fixed later

  # Toggle while learning:
  # nativeBuildInputs = [ breakpointHook ];

  buildPhase = ''
    runHook preBuild
    $CC broken.c -o debug-lab
    runHook postBuild
  '';

  installPhase = ''
    runHook preInstall
    install -Dm755 debug-lab $out/bin/debug-lab
    runHook postInstall
  '';

  meta = {
    mainProgram = "debug-lab";
    license = lib.licenses.mit;
  };
}
/* broken.c — missing semicolon */
#include <stdio.h>
int main(void) {
  puts("debug-lab")
  return 0;
}

Fix:

  puts("debug-lab");

Lab — multi-step

Suggested workspace: ~/lab/90daysofx/02-nixos/day51

Step 1 — Baseline success package

Start from Day 45 addone or Day 47 tool. Confirm green build + log:

nix build .#addone -L
nix log .#addone | tail -n 50

Step 2 — Inject a compile failure

Break a source file. Capture:

nix build .#addone -L 2>&1 | tee fail-compile.txt
nix log .#addone | tee fail-compile-log.txt

Step 3 — --keep-failed

nix build .#addone --keep-failed -L || true
# locate printed /tmp/nix-build-* path; list files; find .o / sources

Document what you could see vs not see.

Step 4 — Inject an install failure

Fix compile; break installPhase (mkdir missing, wrong path). Repeat log + keep-failed.

Step 5 — FOD failure recall

Temporarily wrong src.hash; confirm you recognize the error class in under 10 seconds.

Step 7 — Write a personal runbook

DEBUG-RUNBOOK.md with your ordered steps (max one page). Example skeleton:

  1. Eval or build?
  2. -L first failure phase
  3. nix log
  4. hash vs compile vs install
  5. --keep-failed
  6. hook / extra tools
  7. only then consider sandbox experiments

Step 8 — Force local rebuild

nix build .#addone --rebuild --option substitute false -L

Note time vs cached build.



Theory 7 — Reading nix log paths

nix log /nix/store/….drv 2>/dev/null || true
# or from failed build output: link to log under /nix/var/log/nix/drvs

Always save the drv path from a failure before GC. Without it, debugging becomes archaeology.


Theory 8 — --keep-failed and dirty trees

nix build -L --keep-failed .#brokenPkg || true
# inspect /tmp/nix-build-… when printed

Compare with pure rebuilds—never commit keep-failed trees. They are for diagnosis only.


Worked example — bisect mental model

When a pin update breaks a package:

old lock (green) ── update input ──> new lock (red)
                 narrow: which input?
                 then: which package rebuild?
                 then: which phase?

Day 24 lock discipline + today’s phase logs = faster bisect.


Lab — capture a complete failure dossier

For one intentional break, write:

  1. Command
  2. Eval or build?
  3. Phase (if build)
  4. First error line
  5. Fix
  6. Verification command

Store under notes/debug-dossier.md.


Lab — sandbox proof

# show sandbox setting
nix show-config | rg -i sandbox

State whether you ever disabled sandbox (you should not for normal work) and why that is dangerous (undeclared deps “work on my machine”).

Common gotchas

Symptom / mistake What to do
nix log says no build log Build never started (eval) or GC’d; rebuild with -L
Kept temp already deleted Re-run with --keep-failed immediately
breakpointHook “does nothing” Platform/interactive limits; use keep-failed + better logs
Fixed tree by hand in /tmp only Always port fix into Nix sources
Disabled sandbox “to move on” Track impurity; fix properly before Day 56 gate
CI-only failure Align system, lock, substituters, case-sensitive paths
Silent doCheck fails Watch for checkPhase in log; set doCheck explicitly

Checkpoint

  • Used -L and nix log on a real failure
  • Used --keep-failed and inspected the tree
  • Classified at least three failure kinds
  • Tried breakpointHook or documented why not
  • Personal debug runbook committed

Commit

git add .
git commit -m "day51: debug builds runbook and failure drills"

Write three personal gotchas before continuing.

Tomorrow

Day 52 — nixpkgs layout & by-name: conventions for PR-shaped packages.


Day 52 — nixpkgs layout & by-name

Stage V · ~4h
Goal: Navigate modern nixpkgs package layout—especially pkgs/by-name—and shape a package so it could become an upstream PR (submission optional).

Why this day exists

Copy-pasting random default.nix from a blog creates private snowflakes. Knowing where packages live, how they are called, and what reviewers expect makes your flake packages portable and your future contributions reviewable.


Theory 1 — Mental map of nixpkgs (2026)

Path Role
pkgs/by-name/<p>/<pname>/package.nix Preferred home for many new single-package expressions
pkgs/top-level/all-packages.nix Historic wiring; less hand-editing for new pkgs
pkgs/development/… Older category tree still holds many packages
pkgs/applications/… Same—legacy category layout
lib/ Nix library functions
nixos/modules/ NixOS modules (not package expressions)
maintainers/maintainer-list.nix Maintainer identities

by-name rules of thumb

pkgs/by-name/he/hello/package.nix
             ││  │
             ││  └─ package directory = pname
             └┴─ first two lowercase letters of pname
Rule Detail
File name Usually package.nix (not always default.nix)
Shard toLower (substring 0 2 pname)
Scope One package per dir; keep it self-contained
Call Auto-wired for eligible packages—follow current nixpkgs docs if adding upstream

Your flake can still use callPackage ./pkgs/foo.nix { } without sharding; by-name is the upstream shape.


Theory 2 — Anatomy of a PR-shaped package.nix

{
  lib,
  stdenv,
  fetchFromGitHub,
  # only real deps
}:

stdenv.mkDerivation (finalAttrs: {
  pname = "cooltool";
  version = "1.2.3";

  src = fetchFromGitHub {
    owner = "acme";
    repo = "cooltool";
    rev = "v${finalAttrs.version}";
    hash = "sha256-…";
  };

  nativeBuildInputs = [ /* … */ ];
  buildInputs = [ /* … */ ];

  meta = {
    description = "One-line description ending without a period sometimes—match nixpkgs style you see nearby";
    homepage = "https://github.com/acme/cooltool";
    license = lib.licenses.mit;
    maintainers = with lib.maintainers; [ /* your handle if upstream */ ];
    platforms = lib.platforms.unix;
    mainProgram = "cooltool";
  };
})

Modern habits

Habit Why
finalAttrs: pattern Version/src self-reference without rec pitfalls
Explicit function head args callPackage injects deps
meta.mainProgram nix run nixpkgs#cooltool
Real license Policy and binary caches
No unrelated formatting of huge trees Reviewer happiness

Theory 3 — callPackage and fixed-points

pkgs.callPackage ./package.nix { }
# equivalent spirit: package.nix is a function of deps
# Override only one dep
pkgs.callPackage ./package.nix {
  openssl = pkgs.openssl_3;
}

Overlays often do:

final: prev: {
  cooltool = final.callPackage ./cooltool/package.nix { };
}

Use final.callPackage so deps see other overlay packages.


Theory 4 — Reading existing packages as literature

# If you have a nixpkgs checkout:
rg -n "pname = \"ripgrep\"" -S pkgs | head
# Or query without full clone:
nix edit nixpkgs#ripgrep
# opens the file in $EDITOR when configured

Read three packages in your Day 47 language:

  1. Tiny CLI
  2. One with patches
  3. One with multiple outputs

Note patterns: hooks, finalAttrs, meta, tests.


Theory 5 — Tests and passthru (awareness)

Many nixpkgs packages expose:

passthru.tests = { /* … */ };
passthru.updateScript = /* nix-update support */;

Optional today; required mental model for “done upstream.” Your flake can mirror with checks.


Worked example — flake package laid out like by-name

day52/
  flake.nix
  pkgs/
    by-name/
      co/cooltool/package.nix
  overlays/default.nix
  NOTES.md
# overlays/default.nix
final: prev: {
  cooltool = final.callPackage ../pkgs/by-name/co/cooltool/package.nix { };
}
# flake.nix
{
  description = "Day 52 by-name shaped package";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        overlays = [ (import ./overlays/default.nix) ];
      };
    in {
      packages.${system}.default = pkgs.cooltool;
      packages.${system}.cooltool = pkgs.cooltool;
      # PR checklist as a check: evaluate meta
      checks.${system}.meta-license =
        assert pkgs.cooltool.meta ? license;
        pkgs.runCommand "meta-ok" { } "touch $out";
    };
}

Lab — multi-step

Suggested workspace: ~/lab/90daysofx/02-nixos/day52

Step 1 — Locate packages

nix edit nixpkgs#hello || true
nix eval nixpkgs#hello.meta --json | jq .

Record: description, license, platforms, maintainers shape.

Step 2 — Reshape your best Stage V package

Take Day 45–48 best package and move it to:

pkgs/by-name/<shard>/<pname>/package.nix

Use finalAttrs if it fits.

Step 3 — Overlay + flake export

callPackage through overlay; nix build .#cooltool.

Step 4 — Meta completeness pass

Ensure:

  • description
  • homepage (if any)
  • license
  • mainProgram
  • platforms or rely on sensible default

Step 5 — PR-shaped checklist (write even if not submitting)

PR-CHECKLIST.md:

  1. Builds on your system
  2. nixpkgs-review awareness (optional run if you have checkout)
  3. No secrets in expression
  4. Hashes pinned
  5. Follows naming
  6. Commit message style (cooltool: init at 1.2.3)

Step 6 — Optional: clone nixpkgs and dry-place the file

# shallow clone is large; optional
# place file in correct by-name path; do not push unless you intend a real PR

Step 7 — Compare category tree vs by-name

Find one package still under pkgs/applications/… and one under by-name. Note one reviewer-facing difference (discovery, automation, structure).


Common gotchas

Symptom / mistake What to do
Wrong two-letter shard Use first two chars of pname, lowercase
default.nix in by-name Prefer package.nix per current convention
Recursive rec version bugs Prefer finalAttrs
Giant unrelated deps in head Only list what the function uses
Meta missing mainProgram Multi-bin packages: set correctly or omit carefully
Assuming auto-discovery in your flake Flakes need explicit callPackage/overlay

Checkpoint

  • Explained by-name sharding
  • Package lives in by-name-shaped path
  • Overlay + callPackage works
  • Meta filled to nixpkgs-ish bar
  • PR checklist written (submit optional)

Commit

git add .
git commit -m "day52: by-name shaped package + meta"

Write three personal gotchas before continuing.

Tomorrow

Day 53 — Binary caches: Cachix, Attic/Harmonia, trust, and push/pull workflows.


Day 53 — Binary caches

Stage V · ~4h (lab-heavy)
Goal: Pull and push substitutes with cache.nixos.org, Cachix, and/or self-hosted Attic / Harmonia—with a clear model of trust and signatures.

Why this day exists

Without substitutes, Stage V and fleet deploys become compile farms. With careless substituters, you outsource trust to whoever can serve a path. Caches are a performance and supply-chain topic, not just “make CI fast.”


Theory 1 — Substituters vs builders

eval → wanted store path P
         ├─ trusted substituter has P?  → download (+ verify sigs)
         └─ else                        → build (local or remote builder)
Term Meaning
Substituter HTTP(S) NAR store endpoint (cache.nixos.org)
Binary cache Popular name for substituter + signing
Signed NAR Artifact with signature by a cache key
Trusted key Public keys your Nix is willing to accept
Priority Order among substituters
# NixOS example
nix.settings = {
  substituters = [
    "https://cache.nixos.org"
    "https://myorg.cachix.org"
  ];
  trusted-public-keys = [
    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
    "myorg.cachix.org-1:BASE64KEY…"
  ];
};

Flake/nix.conf user settings similar under ~/.config/nix/nix.conf or daemon config.


Theory 2 — Trust model (non-negotiable)

Claim Reality
“HTTPS means safe binaries” No—HTTPS protects transport, not who built the bits
“I added a cache URL only” Incomplete without matching public key (or explicit none)
“Private cache = trusted” Only if builders and keys are controlled
“cache.nixos.org is magic” Trusted by default for official paths; still a third party

Rule: Every substituter you add is part of your TCB (trusted computing base) for that machine class.


Theory 4 — Self-hosted: Attic & Harmonia

Project Role (approx.) When
Attic Modern multi-tenant binary cache server Teams wanting self-host push/pull
Harmonia cache.nixos.org-compatible server in Rust Serve local store as substituter
nix-serve / nix-serve-ng Classic signing servers Legacy blogs

Harmonia sketch (NixOS)

services.harmonia = {
  enable = true;
  # signKeyPath = "/var/lib/secrets/harmonia.secret"; # manage via sops-nix
  # settings…
};
# nginx TLS reverse proxy in front in production

Attic sketch (conceptual)

# server configured with tokens / buckets (see upstream Attic docs)
attic login local http://cache.example.internal:8080 "$TOKEN"
attic push local:lab ./result

Exact CLI flags evolve—read current Attic docs for 26.05-era packaging; the lab below allows Cachix or a local Harmonia/Attic path.


Theory 5 — Push what matters

Push Skip
Your packages & CI deps Entire nixpkgs rebuilds by accident
Closure of deploy targets Personal /tmp experiments
Signed artifacts from trusted CI Unsigned ad-hoc laptop builds for prod (policy choice)
# Push full runtime closure
nix-store -qR ./result | cachix push myorg
# modern:
nix copy --to 'http://…' ./result   # when endpoint supports it

Worked examples

NixOS module fragment for team cache

{ lib, ... }:
{
  nix.settings = {
    substituters = lib.mkAfter [
      "https://myorg.cachix.org"
    ];
    trusted-public-keys = lib.mkAfter [
      "myorg.cachix.org-1:REPLACE_ME="
    ];
    # optional: allow user to add more without daemon edit fights
    trusted-users = [ "root" "@wheel" ];
  };
}

Query whether a path would substitute

nix path-info --store https://cache.nixos.org --json $(nix build nixpkgs#hello --print-out-paths) | jq .

Build without substitutes (contrast)

nix build .#cooltool --option substitute false -L

Lab — multi-step

Suggested workspace: ~/lab/90daysofx/02-nixos/day53

Step 1 — Observe default cache

nix build nixpkgs#hello -L
# note download lines mentioning cache.nixos.org
nix config show | rg -i 'substituter|trusted-public'

Step 2 — Build your Stage V package cold-ish

nix build .#cooltool --rebuild -L

Time it roughly.

Step 3 — Choose push target

Path A — Cachix: create free/personal cache if allowed; cachix use; push ./result.
Path B — Self-host lab: enable Harmonia or Attic on a lab VM; configure key; push/copy.
Path C — Document-only: if no account/VM, write full config you would apply and simulate with nix copy --to file:///… local store dump.

# Path C example: local file nar cache
nix copy --to file://$PWD/nar-cache ./result
nix copy --from file://$PWD/nar-cache --to $NIX_STORE ./result

Step 4 — Second machine / second user story

On another user or VM (or after GC of the path if safe):

nix build .#cooltool -L
# should substitute if cache wired and path present

Step 5 — Trust write-up

TRUST.md:

  1. Who can push?
  2. Who holds signing keys?
  3. What hosts trust the pubkey?
  4. What is the revoke plan?

Step 6 — CI note

Add a short paragraph linking to Day 66: push from GitHub Actions only with secrets, not from random laptops, if that is your house rule.

Step 7 — Negative test

Point at a cache without adding the public key (on a disposable Nix config if you can). Observe failure mode; restore.



Theory 6 — Substituter order and priority

nix.settings = {
  substituters = [
    "https://cache.nixos.org"
    # "https://myorg.cachix.org"
  ];
  trusted-public-keys = [
    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
    # "myorg.cachix.org-1:…"
  ];
};
Idea Practice
Official cache first Common default
Extra caches append Org/project binaries
Keys must match Silent ignore / fail if mismatch depending on config

Never add a substituter without its public key. Trust is cryptographic, not vibes.


Theory 7 — What “cache miss” really means

eval → derive output path hash
  → query substituters for that path
     → hit: download NAR
     → miss: build locally (or remote builder)

A miss is normal for private packages. A miss on hello from cache.nixos.org means network, config, or exotic platform issues.


Worked example — query without installing

nix path-info --store https://cache.nixos.org nixpkgs#hello
nix store ping --store https://cache.nixos.org

Lab — document lab cache policy

In docs/cache.md:

  1. Which substituters are enabled
  2. Which keys
  3. Whether you push CI builds
  4. Who may add a new cache (code review rule)

Lab — force a local build once

nix build nixpkgs#hello --option substitute false -L

Feel the difference; re-enable substitutes after. Do not leave substitute false in user config.

Common gotchas

Symptom / mistake What to do
“don’t know how to trust” / unsigned Add correct trusted-public-keys or sign
User nix.conf ignored by daemon Configure system/daemon NixOS settings
Pushed wrong path Push derivation outputs you care about; verify with another client
Cache hit but old content Paths are immutable; rebuild means new hash
Leaked Cachix token Rotate; treat like deploy key
Priority fights Order substituters; understand fallback to build
Assumed private URL = auth Many caches need tokens separately from Nix substituter URL

Checkpoint

  • Explain substituter + signature trust
  • Built with and without substitutes
  • Pushed or file-store simulated push
  • Second client pull story demonstrated/documented
  • TRUST.md written

Commit

git add .
git commit -m "day53: binary caches push/pull and trust notes"

Write three personal gotchas before continuing.

Tomorrow

Day 54 — Remote builders: offload builds to another machine safely.