Adopting Nix Shells Before NixOS
If you're curious about Nix but hesitant to commit to a full NixOS installation, dev shells offer a low-risk entry point. You get reproducible development environments without changing your operating system.
Why Start with Dev Shells
Nix dev shells are safe to experiment with. Your host system remains unchanged—packages install to /nix/store and only become available when you enter the shell. Exit the shell, and your original environment returns.
This makes shells ideal for learning Nix incrementally:
- Start with one project's
flake.nix - Add direnv for automatic activation
- Gradually adopt more Nix patterns as you get comfortable
- Eventually consider NixOS if it fits your needs
If something goes wrong, you can always fall back to your existing tools. Having a containerized development environment (Docker, Podman) as backup can help during the learning curve, but it's not required.
A Complete flake.nix
Here's a multi-platform flake that works on Linux and macOS:
{
description = "Development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs = { self, nixpkgs, ... }: let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
devShells = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
pnpm
postgresql_16
];
shellHook = ''
echo "Development shell ready"
echo " Web: http://localhost:''${WEB_PORT:-3000}"
echo " DB: postgresql://localhost:''${DB_PORT:-5432}"
'';
};
});
};
}The forAllSystems pattern ensures your flake works across architectures. When you first run nix develop, Nix creates a flake.lock file that pins the exact nixpkgs revision. Commit this file—it guarantees everyone gets identical package versions.
The shellHook runs on shell entry. Use it to display helpful information about your environment's configuration.
Direnv Integration
Manually running nix develop every time gets tedious. Direnv automates this.
First, install direnv and add the hook to your shell config:
# ~/.zshrc or ~/.bashrc
eval "$(direnv hook zsh)" # or bashThen create an .envrc in your project:
use flake
dotenv_if_existsThe use flake directive activates your Nix dev shell automatically when you enter the directory. The dotenv_if_exists directive loads environment variables from .env files.
After creating the file, run direnv allow to trust it.
Environment Files Pattern
Use a two-file pattern for environment configuration:
.env.example (committed to git):
WEB_PORT=3000
DB_PORT=5432
DATABASE_URL=postgresql://localhost:5432/dev.env.local (gitignored):
WEB_PORT=3001
DB_PORT=5433
DATABASE_URL=postgresql://localhost:5433/dev
AUTH_SECRET=your-secret-hereThe example file documents available variables. The local file contains actual values and secrets. Add .env.local to your .gitignore.
Port Management for Worktrees
When using git worktrees or agentic workflows, you often run multiple versions of the same application simultaneously. Each needs unique ports to avoid conflicts.
Configure ports in .env.local for each worktree:
main/ → WEB_PORT=3000, DB_PORT=5432
feature-auth/ → WEB_PORT=3001, DB_PORT=5433
feature-api/ → WEB_PORT=3002, DB_PORT=5434Your scripts should use shell parameter expansion with defaults:
# Uses WEB_PORT if set, otherwise 3000
pnpm dev --port ${WEB_PORT:-3000}This way, the main branch uses default ports while feature worktrees override them via their own .env.local files.
Bin Scripts vs Makefile
Two common patterns exist for project scripts: Makefiles and Nix shell scripts. Each has trade-offs.
Makefile Approach
Makefiles are familiar to most developers and don't require Nix knowledge:
up:
pnpm concurrently \
--names "db,web" \
--prefix-colors "blue,green" \
"pg_ctl -D .data/postgres -l .data/postgres.log start" \
"pnpm dev --port $${WEB_PORT:-3000}"
down:
pg_ctl -D .data/postgres stop
lint:
pnpm eslint .
pnpm tsc --noEmitRun with make up, make down, etc.
Nix Shell Script Approach
For more complex tooling, define scripts directly in your flake using writeShellScriptBin:
packages = with pkgs; [
nodejs_22
pnpm
postgresql_16
(writeShellScriptBin "devup" ''
${concurrently}/bin/concurrently \
--names "db,web" \
--prefix-colors "blue,green" \
"pg_ctl -D .data/postgres -l .data/postgres.log start" \
"pnpm dev --port ''${WEB_PORT:-3000}"
'')
(writeShellScriptBin "devdown" ''
pg_ctl -D .data/postgres stop
'')
];Now devup and devdown are available as commands in your shell.
When to Use Each
| Aspect | Makefile | Nix Scripts | | ------------ | ----------------------------- | ----------------------------------- | | Familiarity | Most developers know Make | Requires Nix knowledge | | Dependencies | Assumes tools in PATH | Can reference Nix packages directly | | Portability | Works outside Nix shell | Only works inside Nix shell | | Complexity | Best for simple orchestration | Better for complex tooling |
For most projects, a Makefile with a handful of targets is sufficient. Reserve Nix scripts for when you need to reference Nix packages directly or create complex tooling.
Process Management with Concurrently
Development often requires running multiple services: a database, a web server, background workers. The concurrently package runs them in parallel with organized output.
Basic Setup
Install concurrently as a dev dependency:
pnpm add -D concurrentlyMakefile Pattern
up:
pnpm concurrently \
--names "db,web" \
--prefix-colors "blue,green" \
"pg_ctl -D .data/postgres -l .data/postgres.log start && sleep infinity" \
"pnpm dev --port $${WEB_PORT:-3000}"The --names flag labels each process. The --prefix-colors flag color-codes output for easy scanning. The sleep infinity after pg_ctl start keeps that process slot alive (since pg_ctl start returns immediately after launching postgres in the background).
Nix Script Pattern
(writeShellScriptBin "devup" ''
${pkgs.concurrently}/bin/concurrently \
--names "db,web" \
--prefix-colors "blue,green" \
"pg_ctl -D .data/postgres -l .data/postgres.log start && sleep infinity" \
"pnpm dev --port ''${WEB_PORT:-3000}"
'')Note the Nix string escaping: ''${WEB_PORT:-3000} produces ${WEB_PORT:-3000} in the output script. The '' before $ prevents Nix from interpreting it as a Nix variable.
Shell Alias Alternative
If you prefer aliases over Make or Nix scripts, add to your shellHook:
shellHook = ''
alias devup='pnpm concurrently --names "db,web" "pg_ctl -D .data/postgres start && sleep infinity" "pnpm dev"'
'';This creates the alias only within the dev shell context.
Getting Started
To adopt Nix shells in an existing project:
- Install Nix with flakes enabled
- Create a
flake.nixwith your project's dependencies - Run
nix developto test the shell - Add an
.envrcwithuse flakeanddotenv_if_exists - Run
direnv allow - Create
.env.exampledocumenting your environment variables - Add
.env.localto.gitignore - Add a Makefile or Nix scripts for common tasks
From here, each cd into your project activates the environment automatically. New team members clone the repo, run direnv allow, and have a working setup.