Building Multi-File Artifacts

Updated

September 12, 2026

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.txt

Output:

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

Output:

#!/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-status

Output:

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

Output:

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

  • $out is a directory for anything with more than one file. mkdir -p first.
  • Executables live in $out/bin and are executable.
  • Prefer writeShellApplication for scripts.
  • Shebangs should be store paths, not /usr/bin/env.
  • Smoke-test ./result/bin/… before you add the package to a profile.

Try this

  1. Add $out/share/man/man1/desk-status.1 with a one-line man page. Rebuild and ls result/share/man/man1.
  2. Drop chmod +x from Case 1, rebuild, and run ./result/bin/desk-status. Restore chmod after you see Permission denied.
  3. 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.
  4. Convert Case 1 to writeShellApplication and add pkgs.hello to runtimeInputs. Call hello from the script.