mkServiceVm presumes the wrong axis: separate a public-host base from a stateful-service layer #55

Closed
opened 2026-08-28 06:05:51 +01:00 by vnprc-agent · 3 comments

Replace the mkServiceVm placeholder with the axis that actually separates these machines, so the first public-host archetype is not built around a name that presumes the wrong answer.

Primary goals:

  • Name the real axis — machines differ by what must be carried across a recreation, not by service-versus-dev or VM-versus-VPS.
  • Two layers, not one archetype — a public-host base that every internet-facing machine needs, and a stateful-service layer that only some need.
  • Guest-ness is not a barrier — the framework already builds a non-guest machine, so a rented host needs no new concept, only a builder that does not assume a hypervisor.
  • Decide by measurement — a stateless public host is the negative control that draws the layer boundary empirically instead of by guesswork.

Current state

flake.nix:1160 reads # mkServiceVm = TBD when first service VM is added, and builders maps dev, privacy, and hypervisor. The name presumes two things that do not hold.

First, it presumes guest-ness. mkDevVm and mkPrivacyVm take runtime ? machines.${name}.runtime and map it to a guest module, failing with "no guest module for runtime" when there is none. mkHypervisor takes no runtime at all, because the hypervisor is bare metal. So the framework already builds a machine with no hypervisor edge; a rented internet-facing host is structurally the same shape, and needs no new concept beyond a builder that does not demand a guest module.

Second, it presumes "service" is the distinguishing property. It is not. A static site and a database-backed service are both services and have almost nothing in common operationally.

The axis that does separate them

Every machine here is recreatable — that is where the resilience comes from, and a parallel rebuild from a flake definition plus a backup is the normal recovery path, not an exotic one. What differs is what has to be carried across a recreation:

Machine Carried across a recreation
dev guest nothing; reprovision and continue
stateless public host (static site) nothing; it is a build artifact of a repository
stateful public host application state, plus an identity that clients have pinned

Dev guests and a static site are the same in the only way that matters operationally, despite one being a guest on the LAN and the other a rented host on the internet. A stateful public host is the different one.

Two layers

Public host base, needed by every internet-facing machine: a public DNS name, ACME and TLS, a reverse proxy, a public firewall posture with fail2ban, remote installation, and administrative SSH. No hypervisor edge, no guest module.

Stateful service layer, needed only by machines carrying irreplaceable state:

  • a backup with a verified restore path, not merely a backup;
  • a restore-before-serve gate, because serving empty state is worse than serving nothing — an empty repository host reads as data loss, an empty relay silently drops history, an empty mint is catastrophic;
  • secrets that must never be regenerated on a fresh boot, because regeneration invalidates existing data, which means overriding upstream modules that helpfully generate a missing secret;
  • version constraints driven by data compatibility, since a dump restores only into the same or a newer version, rather than by fleet uniformity;
  • identity continuity for whatever clients have pinned.

An existing Forgejo deployment already implements every item in the second list, and none of that implementation mentions Forgejo. That is the evidence the layer is real rather than one service's peculiarities.

How to decide rather than guess

A stateless public host is the cheap negative control. It needs the base and none of the stateful layer, so the diff between it and a stateful deployment is the layer boundary, measured. Building a second stateful service first would only confirm what is already suspected. That experiment is tracked separately.

Scope

In scope once the experiment reports: a public-host builder and a stateful-service module, named for the axis rather than for "service" or "VM".

Explicitly not in scope: folding any particular service's module into this repository. This repository is the composition framework; giving it a dependency on every service the fleet ever runs is how it stops being one. Service modules stay in their own repositories and are consumed as inputs.

Open question for the builder's shape: a stateful deployment module observed in the wild sets services.caddy, services.openssh, services.fail2ban, and networking.firewall at machine scope rather than scoping them to the service. That is correct while one service owns one host, and it is exactly what has to change before two services can share a host. Whether colocation is wanted at all is a separate decision — for a service handling money it is probably wrong on blast-radius grounds regardless of what the code permits.

Replace the `mkServiceVm` placeholder with the axis that actually separates these machines, so the first public-host archetype is not built around a name that presumes the wrong answer. Primary goals: - **Name the real axis** — machines differ by what must be carried across a recreation, not by service-versus-dev or VM-versus-VPS. - **Two layers, not one archetype** — a public-host base that every internet-facing machine needs, and a stateful-service layer that only some need. - **Guest-ness is not a barrier** — the framework already builds a non-guest machine, so a rented host needs no new concept, only a builder that does not assume a hypervisor. - **Decide by measurement** — a stateless public host is the negative control that draws the layer boundary empirically instead of by guesswork. ### Current state `flake.nix:1160` reads `# mkServiceVm = TBD when first service VM is added`, and `builders` maps `dev`, `privacy`, and `hypervisor`. The name presumes two things that do not hold. First, it presumes guest-ness. `mkDevVm` and `mkPrivacyVm` take `runtime ? machines.${name}.runtime` and map it to a guest module, failing with "no guest module for runtime" when there is none. `mkHypervisor` takes no `runtime` at all, because the hypervisor is bare metal. So the framework already builds a machine with no hypervisor edge; a rented internet-facing host is structurally the same shape, and needs no new concept beyond a builder that does not demand a guest module. Second, it presumes "service" is the distinguishing property. It is not. A static site and a database-backed service are both services and have almost nothing in common operationally. ### The axis that does separate them Every machine here is recreatable — that is where the resilience comes from, and a parallel rebuild from a flake definition plus a backup is the normal recovery path, not an exotic one. What differs is **what has to be carried across a recreation**: | Machine | Carried across a recreation | | --- | --- | | dev guest | nothing; reprovision and continue | | stateless public host (static site) | nothing; it is a build artifact of a repository | | stateful public host | application state, plus an identity that clients have pinned | Dev guests and a static site are the same in the only way that matters operationally, despite one being a guest on the LAN and the other a rented host on the internet. A stateful public host is the different one. ### Two layers **Public host base**, needed by every internet-facing machine: a public DNS name, ACME and TLS, a reverse proxy, a public firewall posture with fail2ban, remote installation, and administrative SSH. No hypervisor edge, no guest module. **Stateful service layer**, needed only by machines carrying irreplaceable state: - a backup with a verified restore path, not merely a backup; - a restore-before-serve gate, because serving empty state is worse than serving nothing — an empty repository host reads as data loss, an empty relay silently drops history, an empty mint is catastrophic; - secrets that must never be regenerated on a fresh boot, because regeneration invalidates existing data, which means overriding upstream modules that helpfully generate a missing secret; - version constraints driven by data compatibility, since a dump restores only into the same or a newer version, rather than by fleet uniformity; - identity continuity for whatever clients have pinned. An existing Forgejo deployment already implements every item in the second list, and none of that implementation mentions Forgejo. That is the evidence the layer is real rather than one service's peculiarities. ### How to decide rather than guess A stateless public host is the cheap negative control. It needs the base and none of the stateful layer, so the diff between it and a stateful deployment is the layer boundary, measured. Building a second stateful service first would only confirm what is already suspected. That experiment is tracked separately. ### Scope In scope once the experiment reports: a public-host builder and a stateful-service module, named for the axis rather than for "service" or "VM". Explicitly not in scope: folding any particular service's module into this repository. This repository is the composition framework; giving it a dependency on every service the fleet ever runs is how it stops being one. Service modules stay in their own repositories and are consumed as inputs. Open question for the builder's shape: a stateful deployment module observed in the wild sets `services.caddy`, `services.openssh`, `services.fail2ban`, and `networking.firewall` at machine scope rather than scoping them to the service. That is correct while one service owns one host, and it is exactly what has to change before two services can share a host. Whether colocation is wanted at all is a separate decision — for a service handling money it is probably wrong on blast-radius grounds regardless of what the code permits.
Author

Outcome: the base is built, and the guarantee it promised was removed

Settled 2026-08-28. nixosModules.publicHost exists as one squashed commit on agent/public-host-base, awaiting a human to relay it into this repository.

What it is. 221 lines. Caddy with ACME and a required contact address, administrative SSH on a caller-chosen non-standard port with keys only, fail2ban, and a firewall opening 80, 443, the administrative port, and whatever a service contributes. A service module contributes a virtual host and its ports; it does not touch machine-wide configuration. rentedKvmGuest is offered separately, because the boot and disk layout the two machines share measures one provider rather than anything a public host needs.

What was removed, and why it matters for anyone extending this. An earlier draft carried a port ledger: every open TCP port had to name a fail2ban jail or a written accepted-exposure reason, and the module claimed this was unbypassable. Successive adversarial review kept finding routes past it — per-interface firewall lists, per-interface ranges, trusted interfaces, raw iptables and nftables rules, networking.nftables.ruleset, foreign tables, the firewalld backend, networking.nat.forwardPorts, disabling the owned services outright, and then disabling their generated systemd units one level below that. Each round closed the enumerated routes and the next round found more, because the set of ways a port becomes reachable is not enumerable.

The ledger and its audits are gone: 1517 lines deleted, 147 kept. The module now states plainly that it is a default posture rather than a guarantee — a consumer can open a port another way or switch a service off, and the base does not try to stop them.

Two assertions survived, on a rule worth reusing: keep what catches a machine that would fail to come up, drop what defends against a configuration written against its owner's intent. Those are that the administrative port may not take 80 or 443, and that services.openssh.ports is exactly that port so a second definition cannot move the daemon somewhere the firewall has not opened.

The transferable lesson. When successive reviews keep finding new instances of the same class, the design is wrong rather than the implementation incomplete. That signal appeared at the second round and was misread as "keep patching" for four more.

### Outcome: the base is built, and the guarantee it promised was removed Settled 2026-08-28. `nixosModules.publicHost` exists as one squashed commit on `agent/public-host-base`, awaiting a human to relay it into this repository. **What it is.** 221 lines. Caddy with ACME and a required contact address, administrative SSH on a caller-chosen non-standard port with keys only, fail2ban, and a firewall opening 80, 443, the administrative port, and whatever a service contributes. A service module contributes a virtual host and its ports; it does not touch machine-wide configuration. `rentedKvmGuest` is offered separately, because the boot and disk layout the two machines share measures one provider rather than anything a public host needs. **What was removed, and why it matters for anyone extending this.** An earlier draft carried a port ledger: every open TCP port had to name a fail2ban jail or a written accepted-exposure reason, and the module claimed this was unbypassable. Successive adversarial review kept finding routes past it — per-interface firewall lists, per-interface ranges, trusted interfaces, raw iptables and nftables rules, `networking.nftables.ruleset`, foreign tables, the firewalld backend, `networking.nat.forwardPorts`, disabling the owned services outright, and then disabling their generated systemd units one level below that. Each round closed the enumerated routes and the next round found more, because the set of ways a port becomes reachable is not enumerable. The ledger and its audits are gone: 1517 lines deleted, 147 kept. The module now states plainly that it is a default posture rather than a guarantee — a consumer can open a port another way or switch a service off, and the base does not try to stop them. Two assertions survived, on a rule worth reusing: keep what catches a machine that would **fail to come up**, drop what defends against a configuration written against its owner's intent. Those are that the administrative port may not take 80 or 443, and that `services.openssh.ports` is exactly that port so a second definition cannot move the daemon somewhere the firewall has not opened. **The transferable lesson.** When successive reviews keep finding new instances of the same class, the design is wrong rather than the implementation incomplete. That signal appeared at the second round and was misread as "keep patching" for four more.
Member

Progress after #58 merged: nixosModules.publicHost is on master at 3ba8082, and the negative-control result is now consumed by the static-site module and Zola builder at e6a3ee4.

This issue should remain open. The public-host half is complete, but the stateful-service layer and builder are not, and #58 deliberately did not answer the inventory design question for rented machines that have a platform but no meaningful runtime.

Progress after #58 merged: `nixosModules.publicHost` is on master at `3ba8082`, and the negative-control result is now consumed by the static-site module and Zola builder at `e6a3ee4`. This issue should remain open. The public-host half is complete, but the stateful-service layer and builder are not, and #58 deliberately did not answer the inventory design question for rented machines that have a platform but no meaningful runtime.
Member

Closing: this issue's question has been answered, half by measurement and half by a failed attempt.

The half that was right, and is settled. The axis is what must be carried across a recreation, not service-versus-dev or VM-versus-VPS. A stateless public host and a dev guest are the same in the way that matters; a stateful public host is the different one. That produced the two layers this issue asked for: nixosModules.publicHost is merged and is the base every internet-facing machine composes, and a statefulService layer remains the correct next one — modules/public-host.nix names it explicitly as the deliberately-excluded neighbour. static-site-parts's DIFF.md is the measurement this issue asked for, taken against a real machine rather than asserted.

The half that was wrong. The bridging clause — "guest-ness is not a barrier, the framework already builds a non-guest machine, so a rented host needs no new concept, only a builder that does not assume a hypervisor" — does not hold. The hypervisor is a non-guest but a full three-legged inventory machine: platform, hardware module, LAN presence, identity, profile definition. A builder in this framework is the join of inventory × secrets × profiles, and a rented static-site host has none of those legs. Acting on that clause produced allod/archetypes#60, which was reviewed four times and closed unmerged; the reasoning is there.

So the layering survives and the builder does not. A rented host is a nixosConfigurations entry composed from the exported modules, in the management path and outside the data-model path.

What is left for the stateful case is not this issue. A forge host needs a secrets identity where a static host needs none — that, rather than guest-ness, is the axis that decides where a machine's data lives. It also needs backups, a restore gate, and a restore story that survives the forge being the thing that is down. That deserves its own issue written against a real machine, in the repository that will hold it.

Closing: this issue's question has been answered, half by measurement and half by a failed attempt. **The half that was right, and is settled.** The axis is what must be carried across a recreation, not service-versus-dev or VM-versus-VPS. A stateless public host and a dev guest are the same in the way that matters; a stateful public host is the different one. That produced the two layers this issue asked for: `nixosModules.publicHost` is merged and is the base every internet-facing machine composes, and a `statefulService` layer remains the correct next one — `modules/public-host.nix` names it explicitly as the deliberately-excluded neighbour. `static-site-parts`'s `DIFF.md` is the measurement this issue asked for, taken against a real machine rather than asserted. **The half that was wrong.** The bridging clause — "guest-ness is not a barrier, the framework already builds a non-guest machine, so a rented host needs no new concept, only a builder that does not assume a hypervisor" — does not hold. The hypervisor is a non-guest but a full three-legged inventory machine: platform, hardware module, LAN presence, identity, profile definition. A builder in this framework is the join of inventory × secrets × profiles, and a rented static-site host has none of those legs. Acting on that clause produced `allod/archetypes#60`, which was reviewed four times and closed unmerged; the reasoning is there. So the layering survives and the builder does not. A rented host is a `nixosConfigurations` entry composed from the exported modules, in the management path and outside the data-model path. **What is left for the stateful case** is not this issue. A forge host needs a `secrets` identity where a static host needs none — that, rather than guest-ness, is the axis that decides where a machine's data lives. It also needs backups, a restore gate, and a restore story that survives the forge being the thing that is down. That deserves its own issue written against a real machine, in the repository that will hold it.
Sign in to join this conversation.
No description provided.