There are many things that are uncertain in our industry right now, but one thing I am certain about, and have been for almost 13 years, is that Nix is a terrible programming language.
I remember when I first learned it, it felt like pushing shit uphill. The learning cliff is ferocious, and it took me a couple of years to master it because there was no AI back then, but I stuck with it. There's some magic here, and in this post, I'm going to show you why Nix should be your primary choice for all your software projects.
First, I'm going to open with this: many pieces of technology are in our heads as really hard to learn, complex, and maybe that's stopped you from picking one up, but I want to encourage you to falsify those thoughts any time you start thinking along those lines. Now that we have AI, things that used to be advanced power tools, hard to use or designed for masters, are now accessible to everyone. You can just prompt for outcomes.
first, some introductory knowledge #
The term Nix is overloaded. It means many different things; when someone says they use Nix, the first thing you should do is ask, "How do you use Nix?" and "What is Nix to you?" because there are many ways to use it; it's not just a package manager or a build system; it can also be an operating system.
In this post, I'll focus on Nix's versatility and utility, and why it's so powerful in the age of AI. There's a reason the labs are using this to build the models you are consuming...
agent and developer experience #
By using tools like https://devenv.sh/, you can define a single source of truth for your compilation toolchain and required third-party dependencies. Humans can use this single source of truth in CI/CD and in ephemeral sandbox development environments used by agents.
I keep coming across clients with drift between how a local laptop is configured and how their CI/CD is configured. And now we've got ephemeral sandbox environments; they're heading down a path of triplicating that drift. This is utter madness. It is not needed. Stop it.
You might be thinking, "Well, there's mise for this." Trust me, mise isn't good enough. Mise is a low-power, low-IQ tool. It does one thing, and it does it well, but it doesn't enable your agents to truly fly.
not just a package manager #
nix is super composable. That same expression that defines your human developer environment setup, your CI/CD setup, and your ephemeral sandbox setup can also be reused to build performant Docker images.
But perhaps... the real reason I love Nix is its power to compose an operating system. With a standard operating system such as Debian or Ubuntu, what happens if an agent is given sudo access to do things on your machine?
You'd be pretty scared, right?
What if I told you that when I'm doing agentic development, I develop on NixOS and I explicitly prompt my agents to use sudo as part of my loop engineering, and it is safe because NixOS is designed to make it nearly impossible to break a machine, and if it does, you can instantly roll back that change.
why the operating system matters #
When others do loop engineering with their agents, they're likely running loops with building the application, perhaps Postgres, but rarely anything more. The primary difference between how others do loop engineering and how I do is that, when I run loops, I put the entire system under test, and the entire System Under Test IS the operating system.
You might think, "Whoa, what the —" Like, how the hell do you do this?
ah! My sweet summer child. It is really simple.
NixOS has a testing framework (runNixOSTest), built in, and for anything you could ever want to assert about how an operating system is configured, or even across many machines, you can spin up a cluster. You can use a number of machines in a test, assert the network rules between them are correct, and that the right IP tables are forwarded or dropped. You do machines of machines, and you actually test the interoperability of that environment against your application.
Here's an example of what this looks like when you've defined an operating system in NixOS and want to assert the network is configured correctly with your firewall zones, and that network activity works for your application before you deploy it.
#
#
#
#
#
#
{ lib, ... }:
let
frontendVlan = 1;
backendVlan = 2;
haproxyNode = 1;
webNode = 2;
haproxyFrontendIp = "192.168.${toString frontendVlan}.${toString haproxyNode}";
haproxyBackendIp = "192.168.${toString backendVlan}.${toString haproxyNode}";
haproxyUntrustedIp = "192.168.${toString backendVlan}.50";
webIp = "192.168.${toString backendVlan}.${toString webNode}";
frontendPort = 80;
webPort = 8080;
helloBody = "hello from python";
in
{
name = "haproxy-hello";
nodes = {
haproxy = { pkgs, ... }: {
virtualisation.vlans = [ frontendVlan backendVlan ];
virtualisation.memorySize = 512;
environment.systemPackages = [ pkgs.curl ];
networking.interfaces.eth${toString backendVlan}.ipv4.addresses = [
{
address = haproxyUntrustedIp;
prefixLength = 24;
}
];
networking.firewall = {
enable = true;
interfaces.eth${toString frontendVlan}.allowedTCPPorts = [ frontendPort ];
};
services.haproxy = {
enable = true;
config = ''
defaults
mode http
timeout connect 5s
timeout client 10s
timeout server 10s
timeout check 5s
frontend fe_http
bind ${haproxyFrontendIp}:${toString frontendPort}
default_backend be_hello
backend be_hello
option httpchk GET /
http-check expect status 200
server hello ${webIp}:${toString webPort} check
'';
};
};
web = { pkgs, ... }: {
virtualisation.vlans = [ backendVlan ];
virtualisation.memorySize = 512;
environment.systemPackages = [ pkgs.curl ];
networking.firewall = {
enable = true;
backend = "iptables";
allowPing = false;
logRefusedPackets = true;
allowedTCPPorts = [ ];
extraCommands = ''
iptables -A nixos-fw -p tcp -s ${haproxyBackendIp} --dport ${toString webPort} -j nixos-fw-accept
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -j REJECT --reject-with icmp-host-prohibited
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
ip6tables -A OUTPUT -j REJECT --reject-with icmp6-adm-prohibited
'';
extraStopCommands = ''
iptables -D nixos-fw -p tcp -s ${haproxyBackendIp} --dport ${toString webPort} -j nixos-fw-accept || true
iptables -D OUTPUT -o lo -j ACCEPT || true
iptables -D OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT || true
iptables -D OUTPUT -j REJECT --reject-with icmp-host-prohibited || true
ip6tables -D OUTPUT -o lo -j ACCEPT || true
ip6tables -D OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT || true
ip6tables -D OUTPUT -j REJECT --reject-with icmp6-adm-prohibited || true
'';
};
systemd.services.hello-world = {
description = "Python hello-world HTTP server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${pkgs.writeText "hello.py" ''
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BODY = b"${helloBody}\n"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, fmt, *args):
return
if __name__ == "__main__":
ThreadingHTTPServer(("${webIp}", ${toString webPort}), Handler).serve_forever()
''}";
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
Restart = "on-failure";
};
};
};
};
testScript = ''
start_all()
haproxy.wait_for_unit("haproxy.service")
web.wait_for_unit("hello-world.service")
web.wait_for_unit("firewall.service")
haproxy.succeed("ip -4 addr show dev eth${toString frontendVlan} | grep -q '${haproxyFrontendIp}/24'")
haproxy.succeed("ip -4 addr show dev eth${toString backendVlan} | grep -q '${haproxyBackendIp}/24'")
haproxy.succeed("ip -4 addr show dev eth${toString backendVlan} | grep -q '${haproxyUntrustedIp}/24'")
web.succeed("ip -4 addr show dev eth${toString backendVlan} | grep -q '${webIp}/24'")
with subtest("backend accepts only haproxy on the hello port"):
haproxy.wait_until_succeeds(
"curl -fsS --interface ${haproxyBackendIp} http://${webIp}:${toString webPort}/ | grep -F '${helloBody}'"
)
haproxy.fail(
"curl -fsS --max-time 5 --interface ${haproxyUntrustedIp} http://${webIp}:${toString webPort}/"
)
haproxy.fail(
"curl -fsS --max-time 5 --interface ${haproxyBackendIp} http://${webIp}:9/"
)
with subtest("haproxy on the frontend vlan proxies to python"):
haproxy.wait_until_succeeds(
"curl -fsS http://${haproxyFrontendIp}/ | grep -F '${helloBody}'"
)
with subtest("python host cannot open new outbound connections"):
web.fail("ping -c 1 -W 3 ${haproxyBackendIp}")
web.fail("curl -fsS --max-time 5 http://${haproxyBackendIp}:${toString frontendPort}/")
web.succeed("iptables -S OUTPUT | grep -q 'REJECT'")
web.succeed(
"iptables -S nixos-fw | grep -q -- '-s ${haproxyBackendIp}/32 -p tcp -m tcp --dport ${toString webPort} -j nixos-fw-accept'"
)
'';
}
There's an old saying that bare metal was complicated and it was a mess. This is no longer true now that we have Nix. Honestly, one of the most galaxy-brain moves you can make these days is to ditch hyperscalers like AWS and acquire bare metal to build that fleet with NixOS, because business margins are going to get compressed by AI.
Sure, you'll need to find an older, more experienced sysadmin, but once you get the right patterns in place, one or two of those people can operate with leverage equivalent to a team of 50 "cloud certified" monkeys. I shit you not, no exaggeration, it is insane what you can do with this composability.
single sources of truth #
Nix, when architected correctly, enables you to define a single source of truth for any piece of software in your stack, including dependencies.
Let's say there is a security vulnerability, and there is, like, a critical OpenSSL vulnerability. How long would it take you to identify all the different OpenSSL versions deployed within your organization? How long would it take to patch, including all dependencies of third-party software that depends on that particular version within your organization, including software dependencies linked against it?
With nix, this can be achieved through a couple of lines
{ pkgs, ... }: {
nixpkgs.overlays = [
(final: prev: {
openssl = prev.openssl.overrideAttrs (old: {
version = "3.0.7";
src = prev.fetchurl {
url = "https://www.openssl.org/source/openssl-3.0.7.tar.gz";
hash = "sha256-...";
};
}
what you just saw was an overlay #
An overlay is the key primitive which makes Nix absolutely amazing. I want you to think about how you use open source software. What happens when you find a bug? Do you go to GitHub and ask someone to take your pull request? Or maybe you fork it. But when you fork it, you have to figure out how you'll build it and where you'll host the resulting artifact (ugh, Artifactory) so it can be consumed.
I'll show you a concrete example. It's here on GitHub. It shows a pattern I've used for years: in my agent sandbox environments, I customize the Git binary and remove the agent's ability to force-push by removing that functionality from Git itself. It also includes all the patterns shared in this blog post, from building a Docker image to building a NixOS virtual machine and testing that git push --force has been removed.
👉 https://github.com/ghuntley/nix-demo 👈
What happens if what you need to customize isn't just a standard command-line tool? What happens if it's a low-level thing, like OpenSSL, that many things depend on, and you need to rebuild the world? Think about that graph. Think about the pain.
With Nix, every change is just an overlay; all software becomes infinitely customizable at all levels, all the way down to the Linux kernel itself, and you can patch anything by asking an agent to customize it. The only restriction on what you can do is your imagination and ambition. These LLMs know Nix very well because the labs themselves are using Nix on their journey toward reaching RSI...
ps. #
You might be wondering about Bazel and Buck2. I also use these alongside Nix, but the best bang for the buck right now is just to use Nix. There are a lot of problems with Nix, for example, how the derivation store works and what that means for incremental caching, something Bazel and Buck2 excel at, but I'm not going to complicate things right now by going into these details. Move past this and just start using Nix to customize all your software.