Trivial Builders
Trivial Builders
Not every package needs stdenv.mkDerivation and a compiler. The boring default is: writeShellApplication / writeText / runCommand / symlinkJoin for scripts, files, and combining closures — reach for mkDerivation when you compile.
Mental model
nixpkgs “trivial builders” are functions that produce derivations without a full stdenv phase dance.
| Builder | Produces |
|---|---|
writeText / writeTextFile |
One file at $out |
writeTextDir |
$out/name |
writeScript / writeScriptBin |
Executable (weak shebang) |
writeShellApplication |
Script + runtimeInputs + set -euo pipefail |
runCommand |
Tiny custom builder |
symlinkJoin |
One dir of symlinks to many packages |
copyPathToStore |
Copy a path as a store object |
writeShellApplication is the desk default for shell. writeScriptBin is the older, sloppier cousin.
Worked examples
Case 1: writeText
Save as banner.nix:
# banner.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.writeText "desk-banner.txt" ''
desk operations — window A
''nix-build banner.nix
cat resultOutput:
desk operations — window A
$out is the file. No bin/.
Case 2: writeShellApplication
Save as tool.nix:
# tool.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.writeShellApplication {
name = "desk-status";
runtimeInputs = [ pkgs.coreutils pkgs.jq ];
text = ''
echo '{"window":"A","ok":true}' | jq -c .
'';
}nix-build tool.nix
./result/bin/desk-statusOutput:
{"ok":true,"window":"A"}
jq is on PATH inside the wrapper only.
Case 3: runCommand
Save as run.nix:
# run.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.runCommand "desk-tree" { } ''
mkdir -p $out/share/desk
echo 1.0 > $out/share/desk/version
ln -s ${pkgs.hello}/bin/hello $out/hello-link
''nix-build run.nix
cat result/share/desk/versionUse runCommand when you need a few mkdir/cp lines. If it grows a configurePhase, you wanted mkDerivation.
Case 4: symlinkJoin
Save as join.nix:
# join.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.symlinkJoin {
name = "desk-cli";
paths = [ pkgs.jq pkgs.ripgrep pkgs.hello ];
}nix-build join.nix
ls result/binOne GC root, several tools. Home Manager home.packages is this idea at user scale. A “kitchen sink” join of gcc+python+node is how closures explode — join what operators actually run.
Case 5: writeTextDir for a default config
# conf.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.writeTextDir "desk/config.json" (builtins.toJSON {
window = "A";
replicas = 2;
})nix-build conf.nix
cat result/desk/config.jsonGood for embedding JSON next to a service. Not for secrets.
The trap
The trap is writeScriptBin with curl in the script and no runtimeInputs. It works on your PATH and dies in systemd. writeShellApplication + runtimeInputs.
The other trap is runCommand that compiles C. That belongs in stdenv. Trivial builders are trivial.
The boring rule
- Scripts:
writeShellApplication. - One file:
writeText/writeTextDir. - Glue:
runCommand. Union of bins:symlinkJoin. - Compilers:
mkDerivation/ language builders. - No secrets in
writeText.
Try this
- Case 2: add
pkgs.hellotoruntimeInputsand callhello. symlinkJoinjq+hello;nix path-info -Shr result | tail -1.- Replace
writeShellApplicationwithwriteScriptBinand drop jq from the host PATH in a cleannix shell --pure; watch it fail; restore. writeTexta unit snippet andcatit; confirm$outis a file, not a directory.