Nix Everything
I think Nix is the future of machine configuration.
Nix has been around for a long time now. And up until recently, my impression was that it was exclusively for the uber nerds, sitting somewhere between Arch Linux and TempleOS. The benefits of Nix have always been hard to ignore, but it required a time investment I was not willing to make. Generally I’ve seen this same sentiment among my peers.
What I want to propose in this article is that this sentiment is no longer accurate. This is because, in my opinion, Nix is the ideal tool in its class for coding agents.
Wait, what’s a Nix? Why do the robots like it?
Nix describes a whole machine in code:
- Flakes pin every input to an exact revision via a lockfile.
- A single build produces the entire machine configuration that the code describes.
- Everything shares one vocabulary. A shell alias, a patched package, and a system service are edits to the same tree.
- Two machines on the same platform that build the same flake get the same result.
Agents edit a module, build the configuration, read the error, and try again. Building a configuration does not activate it, so failed attempts never touch the running system. When an activated change misbehaves, the previous generation is still there.
That loop lets me describe features instead of writing them. I tell the agent what I want my machine to do, and it iterates until the build passes and the feature works. The loop is the same for my shell, for a systemd service, for a patch to an upstream package, and for the agent’s own hooks and permissions. When something breaks, it’s isolated and easily reverted.
Why would a human ever dislike this?
My major issues with Nix, pre-2026 edition:
- The learning curve. Nix is a lazy functional language with its own module system, and its error messages are often opaque.
- The ecosystem has not settled. Flakes are still experimental. Multiple implementations, for example Nix and Lix, diverge in small ways.
- macOS is a second-class platform. nix-darwin covers most system settings and packages, but you still encounter things that must be changed via the settings/privacy GUI.
- On a managed laptop, MDM profiles can sometimes own the settings that Nix also wants to own, and the two can fight if you aren’t careful.
I argue that coding agents make the above items non-issues. The overall difficulty, general nuance, and complex interactions that come with Nix can be easily absorbed by coding agents circa 2026.
One major downside persists, inherent to Nix being a monolithic toolchain. It is most functional when used on teams with little to no fragmentation, where every team member, agent, and CI pipeline uses identical toolchains. Each exception brings back ad-hoc scripts and branching setup docs, which then results in bugs that only one laptop can reproduce. If alignment is an issue in your team/org, I recommend Dagger. Dagger is a much heavier tool, but it works much better in these scenarios.
One flake, many machines
My dotfiles are a single flake with several outputs:
- A few different Macs, through nix-darwin and home-manager. The flake owns their system settings, casks, shell, and every tool I use.
- A NixOS VM named
terrarium, where my coding agents run. - A home-manager configuration for a portable Linux container. CI builds it with Dagger and publishes the image on every push.
Every one of them imports the same home modules, so my shell, git, editor, tmux, and Claude Code configuration are the same everywhere. Host files set options, and the modules decide what those options mean on each platform.
Claude Code’s own configuration is one of those modules. It generates the
settings file, the hooks, the MCP servers, the skills, the permission rules, and
the sandbox allowlists from Nix. For example, the terrarium host sets three
options and inherits the rest:
dotfiles.claude = {
# Start Claude Code with permission prompts off. That is only safe inside the VM, so no other host sets it.
dangerouslySkipPermissions = true;
# Let me watch and steer sessions from my phone. (I don't like giving Anthropic free RCE on my Macs.)
remoteControl = true;
# Let kubectl inside the guest use the clusters that the guest created itself.
guestKubeconfigLocal = true;
};
As with my machine config, agents iterate on their own local Kubernetes clusters to arrive at working Talos cluster configs and Argo applications, rather than having access to my live clusters.
I never edit my machine’s dotfiles by hand. Home-manager links them from the repository on every activation and overwrites whatever drifted.
The agent sandbox
workmux runs my agents. It gives each task a git worktree and a tmux window, and its Lima backend runs the agent pane inside a VM. Home-manager generates its configuration too, and that configuration points the backend at the image:
sandbox:
enabled: true
backend: lima
image: file:///Users/jacobcolvin/.lima/_images/terrarium.qcow2
lima:
isolation: shared
projects_dir: /Users/jacobcolvin/Documents/repos
skip_default_provision: true
Lima mounts my repositories directory into the guest at the same path it has on the Mac. Worktree paths are therefore valid on both sides.
Testing machines
The VM is where I test changes to the machines. The agent runs inside a NixOS host that the flake built, with a writable checkout of the flake mounted in. It can rebuild its own machine via the following loop:
- Edit a module.
- Run
dagger check. Dagger runs each check function from the repository’s modules in its own container. The Nix module’s checks evaluate every host and home-manager configuration, validate the flake’s outputs, and lint the tree. Evaluation does not build anything, but it catches the type errors, missing options, and bad references that make up most mistakes. A change to a shared module must evaluate for every machine at once, not only for the one the agent runs on. - Run
nixos-rebuild switchfor the guest’s own host, so the new generation activates on the machine the agent is sitting in.
If the new generation misbehaves, the previous one is a reboot away. If the
agent breaks the VM beyond repair, I can delete it and create it again from the
image. The repositories survive because they live on my Mac, not in the VM.
Changes that work in the VM then go to the Macs with darwin-rebuild switch
on the host.
Sandbox isolation
The guest has none of the Mac’s SSH keys, cloud credentials, or kubeconfigs. The few narrowly scoped API tokens it needs arrive as environment variables that a wrapper reads from sops on the host. A shim from the VM lets MCP servers run specific commands on the host, where they have additional access and credentials. These are gated by workmux’s host-command allowlist and Claude Code permissions, giving me a narrow, deliberate way out of the sandbox. For example, agents can issue themselves new read-only k8s ServiceAccount tokens, even though they have no permissions to manage ServiceAccounts otherwise.
Network egress is the part of a sandbox that most setups leave open. I use Terrarium, my own egress filter built around Envoy. It reads Cilium network policy, so an allowlist of domains with HTTP rules is the same YAML I write for my Kubernetes clusters.
Patches
For most of my career, the software I used was whatever its maintainers decided it was. When a tool did something I disliked, I had three options:
- Open a pull request and hope for the best.
- Fork the project and maintain the fork forever.
- Live with it (most likely).
Coding agents utilizing Nix gave me a fourth option. Agents read unfamiliar codebases and write working patches in a few minutes, and Nix applies each patch every time it builds the program. Together they make changing someone else’s software about as cheap as changing a config file. My dotfiles apply several patches, spanning features, fixes, and performance improvements.
Nix builds every package from a recipe called a derivation, and you can edit that recipe without forking nixpkgs.
- An overlay is a function that receives the package set and returns the packages you want to replace.
- Inside it,
overrideAttrstakes an existing derivation and changes any of its attributes, including the list ofpatchesthat Nix applies to the source before it builds.
For example, here is an overlay that patches devbox:
devboxOverlay = _final: prev: {
devbox = prev.devbox.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ../pkgs/make-devbox-work-better.patch ];
});
};
Every program on my machines that depends on devbox now gets the patched build. A few properties of this setup make it safe to do casually:
- The patch lives in my repository next to the code that applies it, so each change has a history and a stated reason.
- If upstream rewrites the lines a patch touches, the patch fails to apply and the build stops. I learn about the breakage when I rebuild, not when the program misbehaves.
- Deleting the patch and rebuilding restores the stock package.
Nix has offered these properties for years, but writing and maintaining a patch like this was rarely worth the effort. Coding agents changed that. The examples that follow are all based on patches from my dotfiles.
Example: A One-Line Change
When workmux started Claude Code,
it passed --dangerously-skip-permissions, which enabled bypass mode and
overrode --permission-mode plan.
I wanted each pane to start in the permission mode that my settings choose, with bypass mode available when I ask for it. Claude Code has a flag for exactly that, so the fix is one line:
--- a/src/multiplexer/agent.rs
+++ b/src/multiplexer/agent.rs
@@ -91,7 +91,7 @@ impl AgentProfile for ClaudeProfile {
}
fn skip_permissions_flag(&self) -> Option<&'static str> {
- Some("--dangerously-skip-permissions")
+ Some("--allow-dangerously-skip-permissions")
}
fn auto_name_command(&self) -> Option<&'static str> {
Each pane now starts in the permission mode that my Claude Code settings choose, and I can still switch to bypass mode during the session.
Example: An Unmerged Fix
A Lix upgrade left me stuck between two versions:
- Lix 2.95 removed
builtins.fetchClosure. - A nixpkgs bump removed Lix 2.94, so I had to move to 2.95.
- Devbox used
fetchClosureto pull packages from the binary cache, and devbox 0.18.3 and later refused to start on Lix 2.95.
Someone had already opened
jetify-com/devbox#2997, which
replaces fetchClosure with builtins.appendContext and removes the Lix check.
Nobody had merged the pull request, and it did not apply cleanly to devbox
0.18.4 (the release that nixpkgs built at the time).
My devbox overlay applies that pull request, rebased onto 0.18.4. I moved to Lix 2.95 with the same nixpkgs bump, without waiting for a devbox release. When a devbox release includes the change, I will delete the patch file and the overlay.
Example: Closed Source
Patching does not always require source code. Claude Code ships as a single executable that Bun compiles from a minified JavaScript bundle, and the bundle sits inside the binary as plain text. If there’s no precompiled bytecode present (no longer the case as of Claude Code 2.1.250), an edit to that plain text bundle changes the program.
The Claude Code sandbox denied writes to any directory named config or hooks
at the root of the working directory, as a defense against bare git
repositories. The check never looked for the files that mark a bare repository,
such as HEAD, objects, and refs. As a result, every ordinary repository
with a top-level config/ directory became read-only whenever the sandbox ran.
While debugging, an agent searched the binary for the deny list and read the
surrounding code. The list was the literal ["hooks","config"]. A 60-line
Python script replaced it with an empty array of the same length:
PATTERN = b'["hooks","config"]'
REPLACEMENT = b"[" + b" " * 16 + b"]" # empty array, identical length
count = data.count(PATTERN)
if count != 1:
sys.exit(f"expected exactly 1 occurrence of {PATTERN!r}, found {count}")
with open(path, "wb") as f:
f.write(data.replace(PATTERN, REPLACEMENT))
A few details keep the edit safe:
- The padding keeps every byte after the list at its original offset. Bun’s table of module lengths, and the Mach-O and ELF layouts, stay valid.
- The minifier named the list’s variable differently on each platform,
Hon linux-arm64 andUon darwin-arm64. The script matches the array literal instead of the assignment for that reason. - When an upstream release changes the literal, the count check stops the build.
The overlay runs the script on the raw binary before Nix wraps it. Editing the binary breaks its code signature on macOS, so the overlay also adds the hook that signs the result again:
claude-code = prev.llm-agents.claude-code.overrideAttrs (old: {
nativeBuildInputs =
(old.nativeBuildInputs or [ ])
++ [ prev.python3 ]
++ prev.lib.optionals prev.stdenv.hostPlatform.isDarwin [
prev.darwin.autoSignDarwinBinariesHook
];
postInstall = (old.postInstall or "") + ''
python3 ${../pkgs/claude-code-unprotect-config-dir.py} $out/bin/claude
'';
});
The package’s version check then runs the patched, wrapped binary, so a broken edit fails the build and can be fixed immediately.
Magic wrappers
Nix can also wrap any binary with different flags and environment variables, or another binary entirely. For example, you can set defaults, sandbox the process, or intercept its traffic.
tflint runs each of its plugins
as a separate process and talks to it over Unix sockets through
go-plugin. go-plugin honored
PLUGIN_UNIX_SOCKET_DIR only in the plugin processes, so tflint itself always
created the socket for its plugins’ callbacks in the system temp directory. The
Claude Code sandbox pointed TMPDIR at a directory that blocked socket binds,
so tflint failed inside it.
My dotfiles fix tflint with a wrapper instead of a patch:
tflintWrapped = pkgs.symlinkJoin {
name = "tflint-wrapped";
paths = [ pkgs.tflint ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram $out/bin/tflint \
--run "mkdir -p ${config.home.homeDirectory}/.tflint.d/tmp" \
--set TMPDIR "${config.home.homeDirectory}/.tflint.d/tmp"
'';
};
wrapProgram
replaces bin/tflint with a shell script that creates the directory, sets
TMPDIR, and runs the original binary. The sandbox allows writes and socket
binds under ~/.tflint.d/tmp, so the plugins start inside it.
Binaries at the speed of shell
Small commands usually start as shell aliases or scripts. Scripts have no types,
no tests, and no dependency management, so they stay small and tend to be
slightly broken. Generally the reason you’d still use a shell script is
simplicity (just ./run.sh), not because of some inherent advantage. After
all, who wants thousands of lines of boilerplate and a CI pipeline that breaks
every 3 months?
Nix lets you have the best of both worlds. In my dotfiles, I have a collection
of small programs that each live in their own directory under tools/. For
example, a tiny Go program:
tools/cookie
├── cows/
├── fortunes/
├── go.mod
├── go.sum
├── main.go
├── main_test.go
└── package.nix
cookie prints a fortune, with an optional cow. It exists because I wanted one
on the neovim start screen. It is around 500 lines of Go.
The derivation in package.nix is the entire build and release system:
{
lib,
buildGoModule,
}:
buildGoModule {
pname = "cookie";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-yqtj+zCo7u2UwaQ12bHCHPNucgQNkvkN7nfkLynB67Y=";
meta = {
description = "Tiny fortune cookie generator";
homepage = "https://github.com/MacroPower/dotfiles/tree/main/tools/cookie";
license = lib.licenses.asl20;
mainProgram = "cookie";
};
}
buildGoModule
downloads the module’s dependencies into a separate derivation that vendorHash
pins, compiles the program, and runs go test ./... before it installs
anything.
One line in lib/default.nix turns the file into a package that every host
configuration can see:
cookie = final.callPackage paths.cookie { };
The store path for cookie is automatically added to the PATH (via
home-manager), and other configs can reference it at
${pkgs.cookie}/bin/cookie.
The next darwin-rebuild switch or nixos-rebuild switch builds every
derivation the configuration references, runs its tests, and links the result
in. There is no separate release step.
$ cookie -s -cow tux
╭──────────────────────────────────────────╮
│ Kaablamm! You hear an explosion in the │
│ distance. │
╰──────────────────────────────────────────╯
\
\
.--.
|o_o |
|:_/ |
// \ \
(| | )
/'\_ _/`\
\___)=(___/
The point of this is that programs are much easier for agents to iterate on, because they are significantly easier to test. With Nix acting as your entire build and release system, there’s very little downside to just using Go or Rust for basically everything.
Some of this just sounds like Docker…
Docker and Nix make the same promise: the thing you built is the thing you run. How they accomplish this is entirely different.
- Docker describes a guest filesystem and the process that starts inside it.
- Nix describes packages, and considers your entire machine to be just another package (one with a lot of dependencies).
Inherently, composition is very different between these two. Docker images compose with other images only by copying files out of them. With Nix, the entire machine and all of its software is just a collection of store paths, all of which can reference each other.
That difference is why one set of home modules produces a Mac, a VM, and a container. It is also why Nix can configure my entire machine, and Docker cannot.
That said, containers win as the unit of deployment; I do not fight that. For example, containers offer isolation primitives that Nix does not. And good luck getting that derivation running on your Kubernetes cluster.
Nix in Docker, and Docker in Nix
In many cases, Nix and Docker can complement each other.
For example, oci.jacobcolvin.com/shell is a portable container built from my
flake. A Dagger function starts from the Lix image,
mounts /nix as a cache volume, builds the dev@<arch> home-manager
configuration, and activates it. Trimmed to the steps that matter:
ctr.
WithDirectory("/dotfiles", m.Source).
WithWorkdir("/dotfiles").
WithExec([]string{"nix", "build", ".#homeConfigurations.\"dev@aarch64-linux\".activationPackage"}).
WithExec([]string{"./result/activate"})
It imports the same home modules as my Macs, so the image has the same fish, git, tmux, neovim, and Claude Code configuration. Nix decides the contents. Dagger handles the layers, zstd compression, the multi-arch manifest, and publishing. The image runs as a utility container that I can reach over SSH.
On the host side, Nix is what installs and configures Docker and keeps it consistent across all of my machines.
Upgrades
A skill in my dotfiles repository, /flake-upgrade, handles the upgrade with
more patience than I have:
- It bumps the lock and diffs it against the last commit.
- It spawns one read-only agent per changed input. Each agent reads the upstream changelog and reports what this repository must change.
- It applies the migrations, rebases any patch that stopped applying, and runs the checks.
- It reports what it adopted and what it left for me.
Inputs that only carry packages, such as nixpkgs, get a different treatment in step #2. The flake has an
inventoryoutput that lists every package each host installs. A second script evaluates the diff of this output to find all packages whose version changed. The agent then reads individual changelogs instead of thousands of nixpkgs commits.
In conclusion
The agents I used at the start of 2025 struggled to fix code that I pointed at explicitly. The agents I use in 2026 read a codebase they have never seen, find the function that caused some problem, comb through git logs to find when and why the behavior changed, then propose a working fix with little to no feedback. That ability changes what is reasonable to delegate to agents.
What an agent still needs is a loop. It must be able to make a change, run
something that says whether the change worked, and undo the change when it did
not. Imperative machine setup has no such loop. brew has no rollback, and
ad-hoc edits to your system settings have no git blame.
Nix gives machines the loop that traditional code has always had. The configuration is text, builds pass or print an error, and activation is reversible and reproducible. The whole machine can be built as a VM, handed to an agent, broken, and thrown away as needed.
In reality, the future of machine configuration may or may not be Nix, but for the above reasons I expect it will at least be something similar.