Nothing below the deployment plan evaluates Nix

#design#deployment#nix

Part 2 of 7. Part 1 argued that a service is the right atom.

The architecture is split in two, and a single file is the entire interface between the halves. Above it, a planner reads service modules and topology written in Nix and compiles them into that file. Below it, an orchestrator and a per-host agent start processes and report what happened. Nothing below evaluates Nix.

That file is the deployment plan. It is the only thing the two halves have to agree on, and it is flat, keyed, and free of expressions.

service modulesmachines and tagsplannerdeployment planorchestrator: diff, order, gate, roll backagent on NixOSagent on macOSagent on rootless Ubuntuno Nix below this line
{
  "mygame:server@alpha": {
    "key": "sha256-a12c3f9d",
    "closure": "/nix/store/9k2h...-mygame-2.3.1",
    "dependsOn": [
      "mygame:db@alpha#sha256-7f3a1b02",
      "machine:alpha@sha256-77ff5c1e"
    ],
    "env": { "DB_DSN": "postgresql://postgres@/run/pg-gamedb/gamedb" }
  }
}

Every reference in it is a literal store path, every address is already resolved, and every dependency edge is a key that appears elsewhere in the same file. The machine is one of those entries, with a key of its own, because a service depends on the firewall rules and kernel settings its machine ended up with. Nothing here needs evaluating, which is what makes the layer below simple enough to run anywhere.

A key is <instance>:<service>@<machine>, so a deployment that cuts a service with members.db.enable = false produces a plan with no key for it. Not an empty entry and not a disabled one. The entry is absent. The entries of the services the instance kept are what they would have been anyway, byte for byte, apart from the instance name in their keys and in dependsOn.

notes/examples/one-daemon-two-networks/plan/zerotier.json shows it. The instance that cut its controller has two member entries and no controller entry, and each of those two is the matching entry from an uncut instance with the name changed and one edge aimed elsewhere. What the cut costs the plan is the edge that used to be internal: the kept service still asks for a network identifier, and the deployment now says where it comes from.

What the deployment plan buys

The restriction is copied from Disnix, where the command line enforces it rather than a convention. disnix-deploy takes a prebuilt plan and has no flag for a service, infrastructure, or distribution model, reads no NIX_PATH, and offers no way to name an expression. The only Nix commands it runs are nix-store, nix-env, and nix-collect-garbage, always against paths it read out of that file. Four properties follow.

You can build now and deploy later, because the plan names store paths that already exist.

Rollback is deploying an older file. A previous plan is a complete description of a previous state, so nothing has to replay an inverse of each step.

Losing the coordinator is survivable, because the plan is a persisted artifact rather than state in a running process. Disnix goes further and rebuilds a lost coordinator plan from the per-target profiles, since every target keeps its own copy of what it was given.

A target that cannot evaluate Nix is still a target, which is the point of part 1.

Next: a module asks first and receives later.

© 2026 Qubasa · Galaxy Deploy