Building Multi-File Artifacts
Building Multi-File Artifacts
$out can be a file or a directory. Real packages are directories: bin/, lib/, share/. The boring default is: mkdir -p $out/bin first, write files, chmod +x the executables, and smoke-test with ./result/bin/….
Mental model
Nix does not care about FHS except as a convention other tools expect. If you put a binary at $out/bin/desk-status, nix-env, Home Manager, and environment.systemPackages will put $out/bin on PATH. If you put it at $out/desk-status, they will not.
| Path | Usual contents |
|---|---|
$out/bin |
Executables on PATH |
$out/lib |
Shared libraries (RUNPATH targets) |
$out/share |
Data, man pages, completions |
$out/etc |
Default config (not live /etc) |
pkgs.runCommand is enough for scripts and generated trees. Compilers come later (stdenv.mkDerivation).
Worked examples
Case 1: A directory with a script and a data file
Save as multifile.nix:
# multifile.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.runCommand "desk-assets" { } ''
mkdir -p $out/bin $out/share
cat > $out/bin/desk-status << 'EOF'
#!/bin/sh
echo status: ready
EOF
chmod +x $out/bin/desk-status
echo 'Desk version 1.0' > $out/share/version.txt
''nix-build multifile.nix
./result/bin/desk-status
cat result/share/version.txtOutput:
/nix/store/…-desk-assets
status: ready
Desk version 1.0
Case 2: Patch the shebang so the sandbox shell is not required at runtime
#!/bin/sh works on NixOS (there is a /bin/sh). It is still sloppy. Point the shebang at store bash:
Save as shebang.nix:
# shebang.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.runCommand "desk-status" { inherit (pkgs) bash; } ''
mkdir -p $out/bin
cat > $out/bin/desk-status << EOF
#!${pkgs.bash}/bin/bash
echo status: ready
EOF
chmod +x $out/bin/desk-status
''nix-build shebang.nix
head -1 result/bin/desk-statusOutput:
#!/nix/store/…-bash-5.2/bin/bash
nix-store -q --references result now includes bash. That is correct: the script runs bash.
Case 3: writeShellApplication — the boring wrapper
Save as write.nix:
# write.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.writeShellApplication {
name = "desk-status";
runtimeInputs = [ pkgs.coreutils ];
text = ''
echo "status: ready"
date -u +%Y-%m-%d
'';
}nix-build write.nix
./result/bin/desk-statusOutput:
status: ready
2026-09-09
writeShellApplication sets the shebang, set -o errexit / nounset / pipefail, and puts runtimeInputs on PATH via a wrapper. Prefer it over hand-rolled echo > $out/bin.
Case 4: Multiple outputs are a later tool
Some packages split $out and $dev (headers) so runtime closures stay small. You will meet outputs = [ "out" "dev" ]; in the packaging part. For desk scripts, one $out is enough. Do not invent extra outputs until a closure-size measurement says headers are the problem.
Case 5: Install into a profile to get PATH
nix-build write.nix
nix profile install ./result
desk-statusOutput:
status: ready
2026-09-09
On NixOS, the same package in environment.systemPackages is the system-wide equivalent. ./result/bin/… is the lab check; a profile is how humans run it tomorrow.
The trap
The trap is forgetting chmod +x. The file is in $out/bin, activation puts the directory on PATH, and the kernel says Permission denied. The build succeeded.
The other trap is writing a shebang of #!/usr/bin/env bash and then not putting bash on PATH at runtime. env looks up bash in the user’s PATH, which may be missing or may be the host’s bash. Store-path shebangs (writeShellApplication) do not guess.
The boring rule
$outis a directory for anything with more than one file.mkdir -pfirst.- Executables live in
$out/binand are executable. - Prefer
writeShellApplicationfor scripts. - Shebangs should be store paths, not
/usr/bin/env. - Smoke-test
./result/bin/…before you add the package to a profile.
Try this
- Add
$out/share/man/man1/desk-status.1with a one-line man page. Rebuild andls result/share/man/man1. - Drop
chmod +xfrom Case 1, rebuild, and run./result/bin/desk-status. Restore chmod after you seePermission denied. nix-store -q --references $(nix-build shebang.nix --no-out-link)and find bash. Then do the same for Case 1 (#!/bin/sh) and compare.- Convert Case 1 to
writeShellApplicationand addpkgs.hellotoruntimeInputs. Callhellofrom the script.