← Back to posts

I Declared My Entire Dev Environment in 150 Lines of Nix

· 11 min read

The Confession

I had a dirty secret. My development environment was a house of cards.

Over twelve months of building our startup’s platform, I’d accumulated a sprawling mess of tool configurations. Homebrew packages installed at different times. A .tool-versions file that was perpetually out of date. AWS credentials scattered across profiles that may or may not have been configured correctly. Three different AI coding tools - Cursor, OpenCode, Claude Code - each with their own MCP server configs, each expecting slightly different environment variables to be set.

The workspace itself is a collection of 4+ git repos managed as submodules. Each one needs the same Node.js version, the same pnpm, the same AWS tooling. I was maintaining this consistency through hope and muscle memory.

Then one morning I ran cdk deploy and got a version mismatch error. The CDK CLI on my machine was a minor version behind the CDK library in the monorepo. I’d been bitten by this before, fixed it, and apparently forgotten to pin it anywhere persistent.

That was the last straw. I spent two days replacing all of it with a Nix flake.

Why Nix, Why Now

If you’ve heard of Nix but haven’t tried it, here’s the pitch in one sentence: Nix lets you declare every tool your project needs in a file, and anyone (or any machine) with Nix installed gets the exact same tools at the exact same versions by entering the project directory.

No “install Node 22 and make sure you’re on pnpm 10.” No “did you remember to install the AWS CLI?” No “which version of granted are you running?” The flake describes reality, and Nix makes it so.

For a solo developer cycling through AI tools, this matters more than you’d think. Each time I tried a new AI coding assistant, I’d spend an hour configuring its environment, making sure it could find the right binaries, setting up MCP servers. With Nix, the environment is the same regardless of which editor or AI tool I’m driving from.

The Flake

The entry point is flake.nix. It’s almost comically short:

{
  description = "Workspace";

  inputs = {
    devenv-root = {
      url = "file+file:///dev/null";
      flake = false;
    };
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-parts.url = "github:hercules-ci/flake-parts";
    devenv.url = "github:cachix/devenv";
  };

  outputs = inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "x86_64-linux" "aarch64-linux"
        "x86_64-darwin" "aarch64-darwin"
      ];
      imports = [
        inputs.devenv.flakeModule
        ./nix/devshell.nix
      ];
    };
}

Three key decisions here:

  1. flake-parts for modularity. The flake delegates everything to ./nix/devshell.nix so the root file stays clean.
  2. devenv as the dev shell framework. It sits on top of Nix and adds ergonomic features like enterShell hooks, language support, and pre-commit integration.
  3. Multi-platform support. The systems list covers both x86 and ARM on Linux and macOS. I develop on Linux, but if I ever need to hand this workspace to someone on a Mac, it should just work.

The devenv-root input is a workaround for pure flake evaluation - it lets devenv know where the project root is without breaking Nix’s hermetic evaluation model.

The Dev Shell

The real work happens in nix/devshell.nix. Here’s the package list:

devenv.shells.default = {
  packages = with pkgs; [
    nodejs_22
    pnpm
    bun
    awscli2
    granted
    bash
    coreutils
    findutils
    gnugrep
    gawk
    gnused
    git
    jq
    gnumake
    openssl
    pkg-config
    tessl
    orgFormation
    opentofu
    playwright-test
    zellij
    ripgrep
    fd
    sentry-cli
    firecrawlCli
    imapEmailMcp
    # helper scripts
    awsSsoCheck
    awsSsoLogin
    awsCredsStatus
  ] ++ lib.optionals pkgs.stdenv.isLinux [
    docker
    docker-compose
    rootlesskit
    slirp4netns
  ];
};

Let me walk through why each group of tools is here.

The TypeScript Stack

nodejs_22 and pnpm are the foundation. The monorepo runs on Node 22 and pnpm workspaces. Having them pinned in the flake means every submodule, every worktree, every CI debug session starts with the same runtime. bun is there for one-off scripting where startup time matters.

Before Nix, I was using nvm and .nvmrc files. It worked until it didn’t - usually when a new terminal forgot to auto-switch, or when an AI tool spawned a subprocess that inherited the system Node instead of the nvm-managed one.

AWS Tooling

awscli2 gives us the AWS CLI v2. granted is the real star here - it’s an open-source tool that makes assuming AWS SSO roles painless. Instead of the multi-step aws sso login dance followed by manually exporting credentials, you type assume dev-profile and you’re in.

The flake generates the entire AWS config file from a Nix expression:

awsConfigFile = import ./aws-config.nix { inherit pkgs; };
env = {
  AWS_CONFIG_FILE = "${awsConfigFile}";
  AWS_SDK_LOAD_CONFIG = "1";
  AWS_DEFAULT_REGION = "eu-west-2";
  AWS_PROFILE = "dev";
  GRANTED_ALIAS_CONFIGURED = "true";
};

The AWS config defines profiles for each account in our multi-account setup - dev, staging, production, devops, data, operations. Instead of maintaining ~/.aws/config by hand and hoping it stays in sync, the config is generated from Nix and lives in the Nix store. It’s immutable. It can’t drift.

There’s also a shell function that wraps granted’s assume command, parsing its output and exporting the right environment variables:

assume() {
  local output
  output=$(assumego "$@")
  local status=$?
  read -r flag c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 <<< "$output"
  case "$flag" in
    GrantedAssume)
      export AWS_ACCESS_KEY_ID="$c1"
      export AWS_SECRET_ACCESS_KEY="$c2"
      export AWS_SESSION_TOKEN="$c3"
      export AWS_PROFILE="$c4"
      # ... and so on
      ;;
  esac
  return $status
}

This means assume staging works from any shell inside the devshell - zsh, a terminal pane in zellij, a subprocess spawned by an AI tool. The credentials propagate correctly because they’re plain environment variables.

Secrets Management

The enterShell hook runs every time you activate the devshell. It decrypts secrets from sops and exports them as environment variables:

enterShell = ''
  export PROJECT_ROOT="$(pwd)"

  if clickup_token=$(sops -d --extract '["clickup_token"]' \
    "$HOME/nixos-config/secrets.yaml" 2>/dev/null); then
    export CLICKUP_TOKEN="$clickup_token"
    unset clickup_token
  fi

  if firecrawl_key=$(sops -d --extract '["firecrawl_api_key"]' \
    "$HOME/nixos-config/secrets.yaml" 2>/dev/null); then
    export FIRECRAWL_API_KEY="$firecrawl_key"
    unset firecrawl_key
  fi

  # ... more secrets: Sentry, IMAP, Brave Search
'';

This is the pattern I settled on. Secrets live in a sops-encrypted YAML file managed alongside my NixOS config. The devshell decrypts them at activation time. They exist only as environment variables in the shell session - never written to disk as plaintext, never committed to the repo.

Before this, I had .env files. Plural. Some committed (redacted), some in .gitignore, some that existed only on my machine. Every time I set up a new tool, I’d forget to add a token and spend 20 minutes debugging why the MCP server couldn’t connect.

Terminal and Dev Tooling

zellij is a terminal multiplexer (think tmux but with sane defaults). It lets me run a persistent workspace layout - editor in one pane, dev server in another, tests in a third. The layout survives terminal restarts.

playwright-test is interesting. Nix manages the Playwright browsers alongside the test runner, so I don’t need Playwright’s own browser download mechanism:

env = {
  PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-test}/lib/playwright";
  PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
};

This eliminates the “downloading Chromium…” step that plagues every fresh Playwright install and saves several hundred megabytes of redundant browser binaries.

tessl and sentry-cli are AI-adjacent tools and error tracking respectively. ripgrep and fd are fast search tools that several AI coding assistants rely on internally.

Custom Nix Packages

Not everything is in nixpkgs. For tools distributed as npm packages or standalone binaries, I wrote small Nix derivations:

nix/packages/
  tessl.nix
  org-formation.nix
  firecrawl-cli.nix
  imap-email-mcp.nix

Each one fetches a specific version and builds it reproducibly. When I want to upgrade tessl, I change the hash in one file, run nix flake update, and every tool in the workspace sees the new version.

Linux-Specific Additions

Docker runs rootless on Linux. The flake conditionally adds Docker tooling only on Linux systems:

++ lib.optionals pkgs.stdenv.isLinux [
  docker
  docker-compose
  rootlesskit
  slirp4netns
]

The welcome script even starts the rootless Docker daemon automatically if it’s not already running. On macOS, Docker Desktop handles its own lifecycle, so these packages are skipped entirely.

The direnv Glue

The last piece is .envrc, which integrates Nix with direnv:

watch_file flake.nix
watch_file flake.lock
watch_file nix/devshell.nix

mkdir -p "$PWD/.devenv"
DEVENV_ROOT_FILE="$PWD/.devenv/root"
printf %s "$PWD" >"$DEVENV_ROOT_FILE"

use flake . --override-input devenv-root \
  "file+file://$DEVENV_ROOT_FILE" --impure

With nix-direnv installed, the devshell activates automatically when I cd into the workspace. It watches the flake files and rebuilds when they change. The --impure flag is needed because the shell reads secrets from the filesystem at activation time.

The first activation takes a minute or two as Nix downloads and builds everything. After that, it’s cached. Subsequent activations are near-instant.

The Migration Timeline

I started on March 16, 2026 with add flake.nix - a minimal shell with just Node.js and pnpm. Over the next two days:

DateCommitWhat landed
Mar 16add flake.nixInitial Nix dev environment
Mar 17move to nixCore tooling migration
Mar 18add tessl, CLAUDE.md, migrate opencode configAI tool configs into devshell
Mar 18add AWS SSO config and grantedAWS credential management
Mar 18add zellij, zsh, and starshipTerminal environment
Mar 25refactor: modularize with flake-parts + devenvClean architecture

Two days to go from scattered configs to a single declarative file. The March 25 refactor split the monolithic flake.nix into the modular flake-parts + devenv structure that exists today.

What I Didn’t Expect

The AI tools got easier. Each AI coding tool (Cursor, Claude Code, OpenCode) needs MCP servers configured with API keys and tool paths. With the devshell exporting all the necessary environment variables, MCP server configs just reference $FIRECRAWL_API_KEY or $SENTRY_AUTH_TOKEN. Switching between AI tools no longer means re-configuring secrets.

Worktrees just worked. The workspace uses git worktrees heavily - each task gets its own worktree with an isolated working directory. Because direnv activates the flake from the workspace root, every worktree inherits the same environment. No per-worktree setup.

Debugging got more predictable. When something breaks, I know the tool versions haven’t drifted. node --version returns the same thing in every terminal, every worktree, every subprocess. One variable eliminated.

The welcome banner was more useful than expected. A simple shell script that prints versions and credential status on activation:

==========================================
  Dev Environment
==========================================
  Node.js: v22.14.0
  pnpm: 10.6.5
  Docker: Docker version 27.5.1, build 9f9e405

  AWS Credentials:
    ✓ AWS SSO logged in: arn:aws:sts::***:assumed-role/...

  Platform commands:
    cd modules/platform
    pnpm dev              # Start all apps
    pnpm test             # Run tests
==========================================

It takes two seconds to glance at and confirms everything is healthy. I catch expired SSO sessions before they bite me mid-deploy.

The Tradeoffs

Nix has a learning curve. The language is functional and weird. Error messages range from cryptic to actively hostile. When a derivation fails to build, you sometimes end up reading Nix source code to understand why.

The initial build is slow. First activation downloads hundreds of megabytes of packages. Subsequent builds are cached, but modifying the flake means waiting for a rebuild.

The --impure flag bothers me. Pure Nix evaluation can’t read files from the host filesystem at build time, but sops needs to decrypt secrets from ~/nixos-config/secrets.yaml. The workaround is --impure, which weakens Nix’s reproducibility guarantees slightly. In practice it hasn’t caused issues, but it’s an asterisk on the “fully reproducible” claim.

Custom packages require maintenance. When tessl or firecrawl-cli release a new version, I need to update the hash in the Nix derivation manually. There’s no npm update equivalent.

Was It Worth It

Unambiguously yes.

The time I spent maintaining scattered configs, debugging version mismatches, and re-setting-up tools after trying a new AI assistant was death by a thousand cuts. None of it was dramatic enough to fix on its own. But accumulated over twelve months, it was hours of wasted effort.

Now the entire development environment - runtime, toolchain, AWS profiles, secrets, terminal layout, AI tool configuration - is described in roughly 150 lines of Nix. cd into the directory and everything is there. Clone the repo on a fresh NixOS machine and everything is there.

“Works on my machine” became “works on my Nix flake.” And unlike my machine, the flake is version-controlled.