writeShellScriptBin — Why Every Nix Flake Script Needs an Explicit Shell
5 min readThe devShell looks clean. A shellHook that defines a build function, a serve helper, maybe some environment setup. It works perfectly — on your machine. A colleague pulls, runs nix develop, types build, and gets:
fish: Unknown command 'build()'
They’re on fish. You’re on bash. Your shellHook is bash syntax. And nix develop just handed it to their shell verbatim.
The inheritance problem
When you enter a devShell with nix develop, Nix doesn’t spawn bash. It spawns your shell — whatever $SHELL points to. The shellHook runs inside that shell. If your hook uses bash syntax and the user runs fish, zsh, or nushell, anything beyond trivial export statements will break.
This isn’t a bug. It’s how mkShell works. The shellHook is shell code with no shebang — it runs in whatever interpreter shows up.
The ways it breaks
The list of bash-isms that fail in fish is longer than you’d expect:
set -euo pipefail— fish error:set: Unknown option '-e'[[ ... ]]double-bracket tests — fish error:Unknown command '[['if [ -z "$VAR" ]; then ... fi— fish usesif test -z "$VAR"; ... endfor f in *.txt; do ... done— fish usesfor f in *.txt; ... end- Array syntax
arr=(a b c)— fish usesset arr a b c - Process substitution
<(cmd)— doesn’t exist in fish source script.sh— fish tries to interpret the bash syntax and fails- Function definitions
build() { ... }— fish usesfunction build; ... end
Basically everything structural. Variables and export work. Control flow, functions, and error handling don’t.
Here’s what a typical broken shellHook looks like:
devShells.default = pkgs.mkShell {
shellHook = ''
build() {
set -euo pipefail
echo "Building..."
${pkgs.zola}/bin/zola build
}
'';
};
Bash user gets a working build function. Fish user gets a wall of syntax errors on shell entry. They’ll either switch to bash, delete the shellHook, or stop using your devShell — none of which are what you wanted.
And it’s not just shellHook. If you put a scripts/build.sh in the repo, add it to PATH, and forget the shebang, the user’s shell interprets it directly. Same problem, less obvious cause.
The fix: writeShellScriptBin
writeShellScriptBin creates a standalone executable in the Nix store with an explicit bash shebang:
serve = pkgs.writeShellScriptBin "serve" ''
echo "Serving at http://localhost:1111"
${pkgs.zola}/bin/zola serve --port 1111
'';
build = pkgs.writeShellScriptBin "build" ''
set -euo pipefail
echo "Building site..."
${pkgs.zola}/bin/zola build
echo "Done. Output in public/"
'';
devShells.default = pkgs.mkShell {
packages = [ serve build ];
};
Each script becomes a real executable at /nix/store/...-serve/bin/serve. The generated wrapper starts with #!/nix/store/.../bin/bash — not #!/bin/bash, which doesn’t exist on NixOS. The only thing in /bin is sh, for POSIX compatibility. You could use #!/usr/bin/env bash, but that depends on bash being in PATH at execution time. The Nix store path sidesteps both problems — it’s a direct, absolute reference to a specific bash derivation. When the user types serve, their shell finds the executable in PATH, sees the shebang, and hands it off to bash. Doesn’t matter if they’re running fish, zsh, nushell, or something they wrote themselves last weekend. The script always executes under bash.
Full-path dependencies
Notice the ${pkgs.zola}/bin/zola in the examples above. That Nix interpolation expands at build time to something like /nix/store/abc123-zola-0.22.0/bin/zola. The script doesn’t rely on zola being in PATH — it contains the absolute store path to the exact binary.
This matters. A script that calls bare zola works inside nix develop (where the devShell puts zola in PATH) but fails if someone runs it outside the shell, or if the PATH gets modified. Full store paths make the script self-contained. It works anywhere, any time, regardless of environment.
build = pkgs.writeShellScriptBin "build" ''
set -euo pipefail
TYPST_FONT_PATHS="${pkgs.inter}/share/fonts" \
${pkgs.typst}/bin/typst compile cv-template.typ static/cv.pdf
${pkgs.zola}/bin/zola build
'';
Every dependency is pinned to a specific Nix store path. The script’s closure captures exactly what it needs. This is the same property that makes NixOS system configurations reproducible — applied to your little helper scripts.
What shellHook should actually do
Keep shellHook minimal. It should only do things that are genuinely shell-agnostic:
- Setting environment variables —
exportworks in bash, zsh, and modern fish - Printing a welcome message —
echois universal - Setting a prompt hint — if you must
That’s about it. Anything with control flow, function definitions, or bash-specific syntax belongs in a writeShellScriptBin package. The boundary is clear: if it would break in fish, it doesn’t belong in shellHook.
devShells.default = pkgs.mkShell {
packages = [ serve build build-pdf ];
shellHook = ''
export PROJECT_ROOT="$(pwd)"
echo "Commands: serve, build, build-pdf"
'';
};
Environment setup in the hook, real logic in wrapped scripts. Your fish users stop filing issues, your bash users notice no difference.
writeShellApplication — the stricter variant
If you’re already wrapping scripts with writeShellScriptBin, consider writeShellApplication instead. It does three things on top:
- Adds
set -euo pipefailautomatically — you don’t need to remember it - Runs shellcheck at build time — catches bugs before the script ever executes
- Puts dependencies in
runtimeInputs— added to PATH, so you can use bare command names
build = pkgs.writeShellApplication {
name = "build";
runtimeInputs = [ pkgs.zola pkgs.typst ];
text = ''
echo "Building site..."
TYPST_FONT_PATHS="${pkgs.inter}/share/fonts" \
typst compile cv-template.typ static/cv.pdf
zola build
'';
};
The trade-off: runtimeInputs adds dependencies to PATH at runtime rather than using full store paths. This is slightly less hermetic — the script depends on PATH being set up correctly — but it’s more readable and shellcheck can actually lint the command names. For project devShell scripts where readability matters more than absolute hermeticity, this is usually the right call.
writeShellApplication also produces a slightly different output structure — the result is a derivation with bin/<name> like writeShellScriptBin, but the build process includes the shellcheck step. If shellcheck finds issues, the build fails. You’ll discover that your $variable should be "$variable" at nix build time instead of at 2 AM in production.
When to use which
writeShellScriptBin — when you want full control. You manage set -euo pipefail yourself, use full Nix store paths for dependencies, and don’t want shellcheck opinions about your quoting.
writeShellApplication — when you want guardrails. Automatic strict mode, shellcheck enforcement, cleaner dependency declaration. This is the default choice for most devShell scripts.
shellHook — only for environment variables and welcome messages. Nothing else.
The underlying principle is the same for both: an explicit shebang pointing at a Nix store bash, guaranteeing your script runs under the shell you wrote it for. Everything else is ergonomics.