Day 28 — Dev shells & direnv

Updated

July 30, 2026

Day 28 — Dev shells & direnv

Stage III · ~4h
Goal: Keep project toolchains in flake devShells, load them automatically with direnv (use flake), and stop stuffing every language toolchain into system or HM profiles.

Why this day exists

Two failure modes dominate Nix workstations:

  1. Profile bloat: Go, Node, Rust, Python, JDK all in home.packages forever
  2. Snowball shells: random nix-shell -p with no lock, unreproducible on Monday

The modern fix:

Layer Holds
NixOS / HM OS, editors, generic CLI, user services
Project flake devShells Compilers, linters, project CLIs
direnv Enters/exits the shell when you cd

This matches Day 8’s first project flake—and now integrates with the Stage III host.


Theory 1 — devShells in a flake

Minimal project flake (can be separate from the NixOS flake):

# ~/lab/demo-app/flake.nix
{
  description = "demo app toolchain";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }:
  let
    system = "x86_64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in {
    devShells.${system}.default = pkgs.mkShell {
      packages = with pkgs; [
        go_1_24  # adjust to versions available on your pin
        gopls
        git
      ];
      shellHook = ''
        export DEMO_APP=1
        echo "entered demo-app devShell"
      '';
    };
  };
}

Enter manually:

cd ~/lab/demo-app
nix develop

Why not always put shells in the NixOS flake?

You can expose devShells from the host flake, but project repos usually own their own flake so clones on other machines work without your entire OS config. Host flake remains for nixosConfigurations (+ later checks).


Theory 2 — direnv + nix-direnv

direnv loads environment variables when you enter a directory with .envrc.

nix-direnv (recommended) caches Nix flake shells efficiently so every cd is not a full re-eval disaster.

HM installation pattern

# homes/alice/direnv.nix
{ pkgs, ... }:
{
  programs.direnv = {
    enable = true;
    nix-direnv.enable = true;
    enableBashIntegration = true;
    # enableZshIntegration / fish as needed
  };
}

Rebuild host/HM, then new shells pick up the hook (eval "$(direnv hook bash)" is usually wired by the module).


Theory 3 — .envrc with use flake

In the project:

# ~/lab/demo-app/.envrc
use flake

Allow once:

direnv allow

Behavior:

Action Result
cd into project Loads devShell env
cd out Unloads
flake/lock change direnv reloads (may rebuild)

.envrc is not for secrets

Do not put API tokens in .envrc committed to git. Use:

  • Untracked .envrc.local patterns (with care)
  • Secret managers / sops (Stage IV)
  • Runtime direnv private files excluded by git
.envrc.local
.env

Theory 4 — System packages vs project shells

Put in HM/system Put in devShell
git, neovim, htop go, cargo, node, python venv tools
direnv itself project linters pinned to repo
SSH client DB client only for one service’s repo

Test: if two projects need different major versions, it belongs in the project shell.


Theory 5 — Pinning discipline for project flakes

Project flake.lock should be committed for apps you ship or share. For private throwaways, still locking saves future you.

Update project nixpkgs independently of the host flake—on purpose. Host OS pin ≠ every app pin.


Theory 6 — nix develop vs nix shell vs HM packages

Command Role
nix develop Flake devShell (hooks, packages, inputsFrom)
nix shell nixpkgs#foo Ad-hoc package on PATH
home.packages Always-on user profile

direnv automates nix develop-class environments.


Worked example — Multi-tool shell

devShells.${system}.default = pkgs.mkShell {
  packages = with pkgs; [
    python3
    python3Packages.pytest
    ruff
  ];
  shellHook = ''
    export PYTHONPATH="$PWD/src''${PYTHONPATH:+:$PYTHONPATH}"
  '';
};
# .envrc
use flake

Lab 1 — Install direnv via HM

Add programs.direnv with nix-direnv.enable = true. Rebuild. Verify:

command -v direnv
direnv version

Open a new login shell if hooks are missing.


Lab 2 — Create a project flake

mkdir -p ~/lab/90daysofx/02-nixos/day28/demo-app
cd ~/lab/90daysofx/02-nixos/day28/demo-app
# write flake.nix with mkShell and 2–3 tools
nix flake lock
nix develop -c which git

Lab 3 — Wire direnv

echo 'use flake' > .envrc
direnv allow
cd ..
cd demo-app
echo "DEMO or PATH check: $PATH" | head -c 200; echo
type python3 2>/dev/null || type go 2>/dev/null || true

Confirm leaving the directory drops the env (compare which before/after).


Lab 4 — Prove version isolation

If available, make two tiny projects with different tools (e.g. one with hello only, one with cowsay only—or different language tools). cd between them; show different PATH entries.


Lab 5 — Thin the profile (careful)

List home.packages / system packages. Move one language toolchain from always-on profile into a project shell. Rebuild HM. Confirm the tool is gone from bare shells but present under direnv.

Do not remove your editor mid-day without a plan.


Lab 6 — Document the workflow

Journal template:

cd project → direnv loads flake lock pin → tools available
change flake → direnv reload → maybe download/build
secrets → never in .envrc committed file

Common gotchas

Symptom / mistake What to do
direnv not hooking New shell; enable integration for your shell
use flake blocked direnv allow
Slow every cd Ensure nix-direnv enabled
Wrong system arch in flake Match system to the machine
Secrets in .envrc committed Rotate; use gitignored local files / sops
Host flake-only shells Fine for mono-lab; projects should still travel
shellHook prints annoyingly Keep hooks quiet in shared repos

Checkpoint

  • direnv + nix-direnv via HM
  • At least one project with devShells.default + lock
  • .envrc with use flake allowed
  • Enter/leave directory toggles tools
  • One toolchain removed from always-on profile (or plan written)
  • Secret policy for env files stated

Commit

# host flake
git add .
git commit -m "day28: direnv and project devShell workflow"

# also commit inside demo-app if it is its own git repo

Write three personal gotchas before continuing.


Tomorrow

Day 29 — flake-parts or discipline: reduce multi-output flake boilerplate—or refuse the dependency and keep a clean hand-rolled structure. Pick one house style.