Skip to content

Deploying a fleet

punix service deploy deploys one stack to one target. punix fleet apply deploys many stacks across many hosts from one command. It orchestrates over the same per-host deploy path, so each host's deploy stays atomic and independently rollback-able.

The Fleet module

# fleet.pcl
module Fleet {
  hosts = [
    { file = "hosts/web.pcl"     stack = "WebStack"   target = "ssh://deploy@web.example.com" }
    { file = "hosts/db.pcl"      stack = "DbStack"    target = "ssh://deploy@db.example.com" }
    { file = "hosts/worker.pcl"  stack = "WorkerStack" target = "ssh://deploy@worker.example.com" }
  ]
}

Each host names its PCL, the stack module inside it, and the deploy target. Host file paths are resolved relative to the fleet file.

A host may name more than one file, and then it composes all of them:

module Fleet {
  hosts = [
    { file = ["base.pcl", "hosts/web.pcl"]  stack = "WebStack"  target = "ssh://deploy@web" }
    { file = ["base.pcl", "hosts/db.pcl"]   stack = "DbStack"   target = "ssh://deploy@db" }
  ]
}

That is how two hosts share a module: write it once in base.pcl and name that file from both, where otherwise each host's file carries its own copy. Composition across files already works everywhere else in PCL (whole-tree composition), so a shared module can declare an option and each host contribute its own value, which is the pattern a NixOS module gives you.

PCL lists are homogeneous, so within one fleet every host writes file the same way: all single strings, or all lists. That is the language's rule showing through, and a fleet reads better for picking one.

punix fleet apply fleet.pcl --key ~/.ssh/punix-deploy

Failure isolation

By default a failing host is recorded and the run continues; the command exits 1 if any host failed, with a per-host report. --fail-fast stops at the first failure. Each host's result is one of four statuses: ok, failed (its own deploy raised), blocked (a host it depends on via a cross-host artifact failed or was blocked, so it is skipped without being attempted), or not-attempted (under --fail-fast, never reached).

host web.example.com    → deployed: gen-004
host db.example.com     → FAILED: [E11] secret(s) not set: from_vault:db_pw
host worker.example.com → deployed: gen-002

Flags

fleet apply takes the fleet file as a positional argument, plus:

  • --fail-fast: stop at the first failing host (default: continue).
  • --dry-run: RealiseDryRun for every host (no real builds or writes).
  • --key / --known-hosts: SSH credentials for ssh:// targets.
  • --tls-certs DIR / --vault-secrets FILE / --activate: same as service deploy, applied per host.
  • --transport-root PATH: deploy each host to <root>/<target>/ via LocalTransport instead of SSH (for tests and local runs).
  • --scenario NAME.

Cross-host wiring through config

PCL has no imports, so each host is a separate evaluation. There is no fleet-wide reference resolution. A service on one host reaches another by address or secret (its config), never by a cross-module PCL reference. Don't expect WebStack to read a binding from DbStack.

Deploy ordering & cross-host artifacts

The one cross-host coupling that isn't a static address is a deploy-time artifact: a fact that only exists after a host deploys (a cert digest for DANE/TLSA, a DKIM key). A host producesArtifacts; another consumes them via an { artifact = { host, name } } reference. fleet apply builds the producer→consumer graph up front, deploys producers before consumers (stable topological order; a cycle is rejected before any host runs), and threads only the digest across. The preimage never crosses the wire. See Cross-host deploy-time artifacts for the full recipe.

Where in the code

  • src/punix/deploy/fleet.py: read_fleet, apply_fleet (pure orchestration, no Transport).
  • src/punix/cli/fleet.py: the fleet apply command wires it to the per-host deploy.