Trivial Builders

Updated

September 12, 2026

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 result

Output:

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-status

Output:

{"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/version

Use 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/bin

One 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.json

Good 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

  1. Case 2: add pkgs.hello to runtimeInputs and call hello.
  2. symlinkJoin jq+hello; nix path-info -Shr result | tail -1.
  3. Replace writeShellApplication with writeScriptBin and drop jq from the host PATH in a clean nix shell --pure; watch it fail; restore.
  4. writeText a unit snippet and cat it; confirm $out is a file, not a directory.