I Declared My Entire Dev Environment in 150 Lines of Nix
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:
- flake-parts for modularity. The flake delegates everything to
./nix/devshell.nixso the root file stays clean. - devenv as the dev shell framework. It sits on top of Nix and adds ergonomic features like
enterShellhooks, language support, and pre-commit integration. - Multi-platform support. The
systemslist 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:
| Date | Commit | What landed |
|---|---|---|
| Mar 16 | add flake.nix | Initial Nix dev environment |
| Mar 17 | move to nix | Core tooling migration |
| Mar 18 | add tessl, CLAUDE.md, migrate opencode config | AI tool configs into devshell |
| Mar 18 | add AWS SSO config and granted | AWS credential management |
| Mar 18 | add zellij, zsh, and starship | Terminal environment |
| Mar 25 | refactor: modularize with flake-parts + devenv | Clean 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.
I Replaced a Year of Accumulated Mess With One File
The Confession
I had been lying to myself for about twelve months.
My development setup — the collection of tools, credentials, and configuration that lets me actually do my job — was a disaster. Different versions of Node.js installed at different times. AWS access credentials scattered across profiles that may or may not have been set up correctly. Three different AI coding tools, each with their own configuration, each needing slightly different things to exist in the environment before they’d work.
The moment of reckoning came one morning when I ran a deployment command and got a version mismatch error. The deployment tool on my laptop was a minor version behind the one in the codebase. I had fixed this exact problem before. I had apparently forgotten to write it down anywhere permanent.
That was the last straw. I spent two days replacing everything with a Nix flake.
What Even Is a Nix Flake
If you’ve heard of Nix but never touched it, here’s the pitch: it’s a way of writing down exactly which tools a project needs — and at exactly which versions — in a single file. Anyone who has Nix installed on their machine can get those exact tools, at those exact versions, just by stepping into the project directory.
No “make sure you have Node 22 installed.” No “did you install the AWS command-line tool?” No “which version of this other thing are you running?” The file describes what the environment should look like, and Nix makes it so.
For someone switching between AI coding tools every few weeks (guilty), this matters more than it sounds. Every time I tried a new AI assistant, I’d spend an hour configuring it to find the right tools and credentials. With this setup, the environment is the same regardless of which tool I’m using.
What Went Into the File
The configuration ended up covering everything in about 150 lines.
The core development tools: a pinned version of Node.js, the package manager, a faster runtime for one-off scripts.
AWS tools: the official command-line tool, plus a much friendlier wrapper called “granted” that makes it painless to switch between our development, staging, and production accounts. Instead of a multi-step login ritual, you type one command and you’re in.
The AWS configuration file itself — which profiles exist, which account each one points to — gets generated from the Nix file rather than maintained by hand. It can’t drift out of sync because it’s rebuilt fresh every time.
Secrets management: API keys and tokens get decrypted automatically when you open the project. They exist only as environment variables in the current session — never written to disk as plain text, never accidentally committed to the repository. Before this, I had a collection of .env files in various states of accuracy, some of which only existed on my laptop and were therefore slowly diverging from reality.
A terminal multiplexer that remembers your workspace layout across restarts. Search tools that several AI coding assistants rely on internally. Playwright browser testing with the browsers managed by Nix rather than downloaded fresh every time (which eliminates the “downloading Chromium…” step that plagues every new install).
On Linux, Docker for local testing — with the Docker service started automatically when you open the project.
The Part I Didn’t Expect to Care About
There’s a small welcome message that appears every time you open the project. It shows the current tool versions and confirms whether your AWS credentials are still valid.
Node.js: v22.14.0
pnpm: 10.6.5
AWS Credentials: logged in
This sounds trivial. It’s not. I used to routinely start working, get 20 minutes into debugging something, and only then discover my AWS session had expired. Now I know before I touch anything.
What Actually Changed Day to Day
The AI tools got easier. Every AI coding assistant needs to know where secrets and tools live. With environment variables set consistently by the Nix file, switching between assistants stopped being a configuration exercise.
Debugging got more predictable. When something breaks, I know the tool versions haven’t quietly changed underneath me. One variable eliminated.
Git worktrees just worked. I use separate working directories for different tasks to keep things isolated. Because the environment activates from the project root, every working directory inherits the same setup without any extra configuration.
The migration took two days. The first day was a minimal version with just the core tools. The second day layered in AWS, secrets, and terminal tooling.
The Honest Downsides
Nix is genuinely weird to learn. The configuration language is functional and unfamiliar, and error messages are often hostile. When something goes wrong during setup, you sometimes end up reading source code from the Nix project itself to understand why.
The first time you set it up on a new machine, it downloads a lot of packages. Subsequent uses are fast because everything is cached, but that first run takes a few minutes.
There’s also a small asterisk on the “fully reproducible” claim: reading secrets from an encrypted file requires a flag that technically weakens one of Nix’s stronger guarantees. In practice it hasn’t caused any problems, but it’s worth knowing.
Custom packages — tools that aren’t in the standard Nix repository — need some maintenance. When one of these tools releases a new version, I have to update a hash in the configuration file manually. It’s a few minutes of work, but it’s more hands-on than a normal package manager update.
Was It Worth It
Completely.
The scattered configuration, the version mismatch debugging, the “configure this AI tool from scratch again” ritual — none of it was dramatic enough on its own to fix. But accumulated over a year, it was hours of wasted time.
Now the entire development environment lives in one file that’s tracked in version control. Step into the directory, everything is there. Set up a fresh machine, everything is there. The environment stopped being a fragile thing I maintained through habit and became a precise specification that Nix just makes real.
← Back to posts