Keystone SystemsKS Systems

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:

  1. Start with one project's flake.nix
  2. Add direnv for automatic activation
  3. Gradually adopt more Nix patterns as you get comfortable
  4. 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 bash

Then create an .envrc in your project:

use flake
dotenv_if_exists

The 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-here

The 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=5434

Your 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 --noEmit

Run 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 concurrently

Makefile 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:

  1. Install Nix with flakes enabled
  2. Create a flake.nix with your project's dependencies
  3. Run nix develop to test the shell
  4. Add an .envrc with use flake and dotenv_if_exists
  5. Run direnv allow
  6. Create .env.example documenting your environment variables
  7. Add .env.local to .gitignore
  8. 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.