[ 2025-04-30 ]
When working in reproducible development environments, nix-shell
and direnv
form a powerful duo — more precisely nix-direnv. Combined, they allow per-project environments to be automatically loaded when entering a directory — no manual shenanigans needed. Nonetheless, one recurring pain point is the frustrating interaction between nix-shell
, direnv
, and Bash aliases.
While developing this personal blog using Zola, I had some scripts that I wanted to run on the background in an easy way — namely zola serve
and tailwindcss
with --watch
option. To make it easier I wanted to have a single Bash alias named serve
that would handle everything for me, even Ctrl+C
to stop the processes.
But… I ran into an annoying issue: my usual Bash aliases weren’t working inside my project’s nix-shell
environment, even though I was using direnv
to load everything automatically. After some digging, I realized this is a common pitfall when combining nix-shell
, direnv
, and Bash. Here’s what’s going on and how I worked around it using nix’s writeShellScriptBin
.
nix-shell
+ direnv
?🔗TLDR: direnv
doesn’t source .bashrc
or similar files in interactive shells.
If you are interested here and here are .direnv
Github issues, and here the nix-shell
Github issue.
Bash aliases are typically defined in interactive shell sessions, such as those initiated by .bashrc
. When you define an alias like:
λ -> alias gs='git status'
That alias exists only in the interactive shell session, like when you open a terminal.
If a script or tool spawns a non-interactive Bash shell, your aliases won’t be there. This means that, when entering a nix-shell
environment, especially when managed by direnv
, these aliases may not be available.
The root of the problem lies in how direnv
and nix-shell
operate.
direnv
Behavior🔗direnv
watches for .envrc
and re-evaluates it when you enter a directory — modifying the environment of the current shell session. This part works great.
However, direnv
does not start a new shell, and therefore, any aliases defined within shellHook
in shell.nix
are not propagated to the current shell and you will find that your previously gs
command is suddenly undefined.
Even if you define aliases directly inside .envrc
, they might not propagate into the spawned nix-shell
depending on the context and timing. So definitively not reproducible.
nix-shell
Behavior🔗While nix-shell
does start an interactive shell by default, aliases defined in shellHook
may not persist in certain scenarios, such as when using nix-shell --run
.
These behaviors lead to inconsistencies in the availability of aliases, causing frustration for users who rely on them for their workflows — that was me.
writeShellScriptBin
🔗To circumvent the limitations with aliases, a practical solution is to convert aliases into executable scripts using Nix’s writeShellScriptBin
function. This approach ensures that the desired commands are available in the environment’s $PATH
, regardless of how the shell is initiated.
nix-direnv
🔗I’m assuming you already have nix-direnv
in your system. Otherwise, You can find all the info on how to install it in here.
For completeness sake, this is how I have it setup using home-manager
and fish
as my terminal:
# ... import pgks ...
{
# ... other config ...
programs.direnv = {
enable = true;
enableFishIntegration = true;
nix-direnv.enable = true;
};
}
Once you have nix-direnv
you need to make it available in your system and folder. Just run the following commands:
λ -> echo "use nix" >> .envrc
λ -> direnv allow
This is how I have defined the shell.nix
in my repo. Make sure the name of the file is either shell.nix
or default.nix
as per the Nix documentation
{ pkgs ? import <nixpkgs> {} }:
let
scripts = [
(pkgs.writeShellScriptBin "serve" ''
trap 'kill 0' SIGINT; zola serve & tailwindcss -i static/input.css -o public/output.css --watch
'')
];
in
pkgs.mkShell rec {
buildInputs = with pkgs; [
# all needed packages -- removed for clarity --
] ++ scripts;
}
Note that I’m using…
writeShellScriptBin
to create an executable script — in this case named serve
. I put said script in the scripts
array that then I make available through the buildInputs
so I can call it inside the new nix-shell
.trap 'kill 0' SIGINT;
to create a group and send the SIGTERM
signal to all processes in the current process group when I press Ctrl+C
. This ensures that all background processes started by the script are terminated when the script is interrupted.zola serve
first and then tailwindcss ... --watch
. Otherwise the .css
file will not be loaded properly. I honestly spent too much time on this small issue…This setup is particularly useful for development workflows where you want to simultaneously watch for CSS changes and serve your site locally. Of course, the script can be easily updated to run multiple background processes — just add another & ...
inside the trap
group.
Aliases in nix-shell
with direnv
don’t work well, especially when defined in shellHook
. They’re not reliable. Using writeShellScriptBin
to create real scripts is a simple fix — it’s consistent, works across shells, and avoids alias issues.
I really like this solution. It allows me to stay fully within Nix, keep things reproducible, and still have flexibility. The only downside is needing to reload nix-shell
if I update a script, but that doesn’t come up often. Usually I discover patterns in my commands while working, then consolidate them later. I don’t try to predict them up front.