Working Derivations bash builder $out store-copied path — Pill 7
A .drv without a filled $out is a plan. A bash builder that writes $out is a product. Paths in args are copied into the store before the build runs.
<!-- hal:authoritative:yaml -->
A .drv without a filled $out is a plan. A bash builder that writes $out is a product. Paths in args are copied into the store before the build runs.
§I — Frame
Asr session 05. Fifth live fire of the Nix language track. Week 2 fire 2. The page is Nix Pills Working Derivation, Pill 7. Luca Bruno wrote the series. License CC BY-SA 4.0. Ground from the on-disk EPUB at 09-Tomes/Polyglot-Dev/Backend-Stack/Nix/nix-pills.epub, chapter OEBPS/07-working-derivation.html. The live URL is canonical if the EPUB drifts: https://nixos.org/guides/nix-pills/07-working-derivation.html.
Session 04 was Pill 6. You named name, system, and builder. You watched evaluation write a .drv without building. You separated instantiate from realise. That folder stays closed for re-teaching. This session is the next move: a derivation that actually builds something, then a small C program packaged the same way. Not install. Not Pills 1 through 3. Not generic builders. Pill 8 waits for session 06. Dolstra 2006 stays on the shelf. NAR and store hashing as a thesis topic are not this lesson. Blandy is a Rust tome. Empty-by-scope for Nix. No flakes. No Python wrapping.
Done-criteria from the syllabus: you can use a bash builder, $out, and a path (./builder.sh) so Nix copies it to the store.
Launch nix repl when you have Nix. Type expressions. Exit with :q. Help with :?. The expressions below match the Pill. Store hashes may differ on your machine. Attribute the Pill's example hashes to the Pill. If Nix is not on the machine today, read the results here. Do not install Nix in this session. The Pill reminds how to enter an existing profile with source ~/.nix-profile/etc/profile.d/nix.sh. That is context only.
§II — A script as the builder
The easiest way to run a sequence of build commands is a bash script. Write builder.sh and ask the derivation to run bash with that script as an argument. Do not put a shebang in builder.sh. At the moment you write the file, you do not know the store path of bash. Even bash lives in the nix store. Do not use /usr/bin/env either. That would lean on the host environment and break the stateless property. During a build, PATH is cleared anyway, so env would not find bash.
The derivation function accepts an optional args attribute. Those values become arguments to the builder executable. The builder is bash. The argument is builder.sh.
Write builder.sh in the current directory:
declare -xp
echo foo > $out
declare -xp lists exported variables. It is a bash builtin, so you do not need env from coreutils. Nix already computed the output path when it wrote the .drv. One of the environment variables passed to the builder is $out. Your job is to create a file or a directory at that path. Here you create a file whose contents are foo.
Load nixpkgs in the repl and ask for bash's out path:
:l <nixpkgs>
"${bash}"
Result shape (Pill example): "/nix/store/...-bash-...". String-interpolate to reach bin/bash, then build the derivation:
# repl: d = derivation { name = "foo"; builder = "${bash}/bin/bash"; args = [ ./builder.sh ]; system = builtins.currentSystem; }
:b d
Realise succeeds. The out path holds foo. That is the first working derivation in this track: evaluate wrote the .drv, realise ran bash with the script, and $out was filled.
§III — Path, not string
Use ./builder.sh, not "./builder.sh". The bare form is a Nix path. Nix performs store magic on paths used this way. Try the string form and the build cannot find builder.sh, because the builder looks relative to the temporary build directory, where your working-tree file does not live.
Hold that distinction. Path values in args (and in other attrs you will add later) are candidates for copying into the store. String values that look like paths are only text. Pill 4 already taught path versus division. Here the same path type decides whether Nix stages your script before the build.
§IV — The builder environment
Inspect the build log:
nix-store --read-log /nix/store/...-foo
You will see exported variables similar to the Pill's listing. Read them as a contract, not as your login shell:
$HOMEis/homeless-shelter, which does not exist. Packages must not depend on a real home during build.$PATHis/path-not-set. Same game. No host tools by accident.$NIX_BUILD_CORES,$NIX_STORE, and the temp-directory family ($PWD,$TMP,$NIX_BUILD_TOP, and siblings) are Nix configuration and the scratch sandbox for this build.$builder,$name,$out, and$systemcome from the.drvenvironment. That is how$outreached your script.
Nix reserved a store slot. You filled it. In autotools language, $out is the --prefix path, not DESTDIR. Stateless packaging installs into an isolated store path, not into a shared /usr.
Notice what is absent from that environment. There is no project directory from your laptop. There is no user profile. There is no accidental gcc from Homebrew or apt. Everything the builder may use must arrive as an attribute that became an environment variable, or as a path Nix already copied into the store. That is why Pill 6's true builder failed: exit status alone does not create $out. This Pill's script succeeds because it writes the reserved path on purpose.
§V — What changed in the .drv
Show the derivation:
nix derivation show /nix/store/...-foo.drv
Compared with Pill 6, two facts matter for this session.
First, args lists the store path of builder.sh, not your working-tree path. Nix copied the file into the store so the build cannot see a mutable workspace file change mid-build, and so the deployment does not depend on the building machine's layout.
Second, inputSrcs lists that same store path. builder.sh is a plain file, so it has no .drv of its own. Its store path is computed from the filename and the hash of its contents. Store-path hashing in depth waits for a later Pill. For today: a path argument became an input source, and the builder received the store copy.
inputDrvs still lists bash, because the builder executable came from the bash derivation. Instantiation wrote those edges. Realisation built missing inputs first, then ran your script.
Keep the two lists separate when you read a .drv. inputSrcs are fixed files and directories already hashed into the store. inputDrvs are other derivations whose outputs must exist before this build runs. Your builder.sh is an input source. Bash is an input derivation. Mixing those words is the same class of error as mixing instantiate and realise.
§VI — Package a simple C program
Write simple.c:
#include <stdio.h>
void main() {
puts("Simple!");
}
Write simple_builder.sh:
export PATH="$coreutils/bin:$gcc/bin"
mkdir $out
gcc -o $out/simple $src
The variables $coreutils, $gcc, and $src are not magic shell names. They will be environment variables produced from attributes on the derivation set. Build it in the repl:
:l <nixpkgs>
# repl: simple = derivation { name = "simple"; builder = "${bash}/bin/bash"; args = [ ./simple_builder.sh ]; gcc = gcc; coreutils = coreutils; src = ./simple.c; system = builtins.currentSystem; }
:b simple
The out path is a directory containing simple. Run that binary from the store path. You packaged a real program with a blessed toolchain from nixpkgs.
§VII — How attrs become the builder's environment
Two new attributes, gcc and coreutils, sit on the derivation set. On the left is the attribute name. On the right is the derivation from nixpkgs (after :l <nixpkgs>). src = ./simple.c is likewise just an attribute whose value is a path.
Every attribute in the set passed to derivation is converted to a string and passed to the builder as an environment variable. Derivations stringify to their out paths. So $gcc and $coreutils become store roots, and appending /bin finds the tools. $src becomes the store path of simple.c, because the path was copied like builder.sh.
Pretty-print the .drv. You will see simple_builder.sh and simple.c among the input sources, and bash, gcc, and coreutils among the input derivations. The new environment variables appear in env.
In the builder script, PATH is set so mkdir and gcc resolve. You could also call $gcc/bin/gcc explicitly. $out is created as a directory, and the binary lands inside it.
§VIII — Leave the repl: simple.nix and nix-build
Write simple.nix:
let
pkgs = import <nixpkgs> { };
in
derivation {
name = "simple";
builder = "${pkgs.bash}/bin/bash";
args = [ ./simple_builder.sh ];
gcc = pkgs.gcc;
coreutils = pkgs.coreutils;
src = ./simple.c;
system = builtins.currentSystem;
}
Build with nix-build simple.nix. That creates a result symlink in the current directory pointing at the out path.
nix-build does two jobs in order:
nix-instantiate: parse and evaluatesimple.nix, return the.drv.nix-store -r: realise that.drv.
Then it creates the symlink. Session 04 named instantiate versus realise. Here the shell tool that pairs them is nix-build.
The import <nixpkgs> { } line loads nixpkgs as a function and calls it with an empty set. Pill 5 already covered import and calling a function that returns a set. The result is bound as pkgs in a let. Unlike :l <nixpkgs> in the repl, file form requires pkgs.bash, pkgs.gcc, and pkgs.coreutils.
A tighter spelling uses inherit:
let
pkgs = import <nixpkgs> { };
in
derivation {
name = "simple";
builder = "${pkgs.bash}/bin/bash";
args = [ ./simple_builder.sh ];
inherit (pkgs) gcc coreutils;
src = ./simple.c;
system = builtins.currentSystem;
}
inherit (pkgs) gcc coreutils; means gcc = pkgs.gcc; coreutils = pkgs.coreutils;. No magic. It only avoids repeating the same name for attribute and value. It is valid inside sets.
§IX — Five proofs
Proof 1. Bash plus args plus $out.
A derivation with builder = "${bash}/bin/bash", args = [ ./builder.sh ], and a script that writes to $out realises to a filled store path. If you still expect a shebang or /usr/bin/env to locate bash, walk §II again.
Proof 2. Path stages; string does not.
./builder.sh as a path is copied into the store and listed in inputSrcs. "./builder.sh" as a string fails at build time relative to the temp directory. If those felt equivalent, walk §III again.
Proof 3. Builder env is not your shell.
$HOME is /homeless-shelter. $PATH is /path-not-set. $out comes from the .drv. If you expected login-shell tools inside the builder, walk §IV again.
Proof 4. Attrs become environment variables.
gcc = gcc, coreutils = coreutils, and src = ./simple.c become $gcc, $coreutils, and $src for the builder. Derivations stringify to out paths. Paths stringify to their store copies. If the C build seemed to invent those names from nowhere, walk §VI and §VII again.
Proof 5. nix-build pairs instantiate and realise.
nix-build simple.nix instantiates, realises, and symlinks result. inherit (pkgs) gcc coreutils is sugar for attribute assignment from pkgs. If nix-build still sounded like a third phase of Nix, walk §VIII again.
Closing
Pill 7 turns the empty out path from Pill 6 into a filled product. You used bash as the builder with args pointing at a script. You wrote $out. You used a Nix path so the script was copied into the store and recorded in inputSrcs. You read the builder environment and saw why host $HOME and $PATH do not apply. You packaged a small C program by hanging gcc, coreutils, and src on the derivation set so they became environment variables. You left the repl for simple.nix, nix-build, and inherit.
The through-line from session 04 is intact. Instantiation still writes the plan. Realisation still runs it. What changed is the builder's contract: create $out, using only what Nix placed in the environment and in inputSrcs. Once that contract holds, a derivation is no longer an empty prediction beside a finished .drv. It is a store object you can run or link.
Name the mechanism when someone asks how a working derivation differs from Pill 6's plan-only .drv: bash runs a store-copied script, and that script creates $out.
Session 06 is Pills 8: generic builders. Do not start it in this folder. Do not write it today. The Pill's own close is the rule: here you studied fundamentals with two hand-written builders; factoring a shared builder waits for the next fire.
Examine well. Bash-plus-$out is the first proof. Path-versus-string staging is the second. Builder-env isolation is the third. Attr-to-environment wiring is the fourth. nix-build plus inherit is the fifth. The door is a store path that contains what the script wrote.
Related
- Syllabus: Asr Nix syllabus, session 05
- Prior: Asr session 04 · first derivation, instantiate vs realise
- Prior: Asr session 03 · functions and imports
- Prior: Asr session 02 · language recap
- Prior: Asr session 01 · language basics
- Grounding tome: Nix Pills EPUB (Pill 7)
- Tome hub: Nix language tomes (CC BY-SA 4.0 note)
- Live URL: https://nixos.org/guides/nix-pills/07-working-derivation.html
- Next fire: session 06, Pills 8 generic builders (unwritten)
🛡️ ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-09-04 · Asr · session 05 · Nix Pills 7 · working derivation (bash, $out, store-copied path)