nixos-config/modules/nixos/linux/incus/incus-pet/README.md
Sridhar Ratnakumar 0794bcc151
incus-pet: per-app incus container CLI; drop webapps/ (#118)
* incus-pet: per-app incus container CLI ("pet PaaS"); drop webapps/

Phase 1 of the incus-pet design. Adds the deployment infrastructure
under modules/nixos/linux/incus/ — a CLI, a container-essentials NixOS
module, a SKILL.md for agent-driven adoption — and deletes the webapps/
tree (nspawn-based, weaker isolation defaults, no live consumers).

Pairs with srid/anywhen#15 (nixosModules.incus on the anywhen flake)
for end-to-end deployment of anywhen as an incus container.

Surface added:

- modules/nixos/linux/incus/container.nix    container essentials
  (sshd, hostname, flakes, firewall, base packages); imported by the
  marshaling flake incus-pet generates per app
- modules/nixos/linux/incus/incus-pet/       CLI tree:
    default.nix   writeShellApplication; subcommands deploy/list/rm
    SKILL.md      3-branch agent recipe (deploy / wrapper / add
                  contract upstream)
    README.md     human docs with the full network-model section
- modules/flake-parts/incus-pet.nix          exposes packages.incus-pet

Surface removed:

- modules/nixos/linux/anywhen.nix            anywhen runs as a
  container now, not as a host-installed service
- configurations/nixos/pureintent            drops the anywhen import
  and the services.anywhen.host wiring
- webapps/                                   deleted entirely

Port convention: every containerized service binds 8080 inside its
own netns. The host-side <listen-ip>:<host-port> is unique per app,
chosen at first deploy via --port + --listen (or INCUS_PET_LISTEN
env), and recorded in container metadata so re-deploys are flagless.

Run:
  nix run .#incus-pet -- deploy github:srid/anywhen \
    --port 7700 --listen 100.122.32.106

See modules/nixos/linux/incus/incus-pet/README.md for the operator
flow and the network model in full.

* incus-pet: fixes discovered while deploying the hello-web example

Three small fixes that fall out of doing the first live deploy on
pureintent. None change the design — they make the documented flow
actually work against the real images:nixos/25.11 container image and
the real `nixos-rebuild --target-host` activation path.

* container.nix imports `${modulesPath}/virtualisation/lxc-container.nix`
  so the rebuilt config knows it's a container — without it,
  `nixos-rebuild` fails the `boot.loader.grub.devices` / `fileSystems`
  assertions. Same module the upstream NixOS incus image's
  /etc/nixos/configuration.nix imports.

* container.nix carries the same dbus-broker reload workaround
  pureintent already has (NixOS#180175-ish symptom — broker has long-
  lived clients holding the bus, reload times out,
  switch-to-configuration exits 4 despite activation succeeding).

* incus-pet/default.nix:
  - bootstrap step simplified: the official nixos/25.11 image already
    runs sshd via systemd socket activation on :22 — we only need to
    push the operator's pubkey. The earlier in-container
    `nixos-rebuild switch` is unnecessary (and breaks because the
    image ships without a nixpkgs channel).
  - `NIX_SSHOPTS='-o StrictHostKeyChecking=accept-new'` for the
    --target-host rebuild (fresh containers have fresh host keys).
  - `incus config set <name> key=value` form (the space-separated
    form deprecated in incus 6.x; was emitting a warning per metadata
    key on every deploy).
  - Image baseline `images:nixos/25.05` → `images:nixos/25.11`
    (25.05 isn't published on the LXC image server; 25.11 is the
    current stable).

End-to-end verified: `incus-pet deploy github:srid/anywhen` deploys
into a container reachable on the host's tailscale IP; the same path
works for the hello-web example (stacked PR).

* incus-pet: hello-web example flake — minimal contract demonstration

Self-contained reference under
modules/nixos/linux/incus/incus-pet/example/hello-web/ that satisfies
the incus-pet contract in the smallest possible form:

  - packages.<sys>.default    a darkhttpd wrapper that serves a
                              one-page index.html, reading HOST/PORT
                              from the environment
  - nixosModules.default      services.hello-web.{enable, package,
                              host, port} + a DynamicUser=true systemd
                              unit
  - nixosModules.incus        the deploy contract: services.hello-web
                              bound to 8080, hostname "hello-web"

Useful as a copy-paste template for new apps. Less moving parts than
the anywhen reference (no bun, no SQLite, no state dir, no e2e tests
to keep green) — just three flake outputs and a static HTML response.

Live-deployed end-to-end on pureintent during this PR's bring-up:

  $ incus-pet deploy path:./.../example/hello-web hello-web \
      --port 8081 --listen 100.122.32.106
  $ curl http://100.122.32.106:8081/
  <!doctype html>
  <h1>Hello from incus-pet</h1>

Idempotent re-deploy verified (no flags needed — host-port + listen
read back from container metadata; container name auto-detected from
incus.container.hostname).

* incus-pet: drop anywhen wiring from this PR — defer to follow-up

Per request: keep this PR strictly to the incus-pet infrastructure +
the hello-web example. The anywhen migration (replacing the host-
installed services.anywhen on pureintent with an incus-pet container)
lands in a separate follow-up PR after srid/anywhen#15 merges.

Restored from master:
- modules/nixos/linux/anywhen.nix             (was deleted)
- configurations/nixos/pureintent/default.nix (had anywhen import
                                              + services.anywhen.host
                                              removed; now back)
- flake.nix anywhen.url                       (was repinned to
                                              incus-contract; back to
                                              abject-turn)
- flake.lock anywhen entry                    (matches the abject-turn
                                              pin again)

The hello-web example remains the live verification for this PR.
2026-05-23 16:28:46 -04:00

6.9 KiB

incus-pet

Per-app incus container CLI ("pet PaaS"). Takes a NixOS service flake, materialises it into a dedicated incus container on the local host, and exposes the service on a chosen <listen-ip>:<host-port> that proxies to a fixed 8080 inside the container.

incus-pet deploy github:srid/anywhen --port 7700 --listen 100.122.32.106
incus-pet list
incus-pet rm anywhen

See SKILL.md for the agent-facing recipe; this README is for humans running the CLI directly.

Prerequisites

  • The host runs the incus daemon (this repo's modules/nixos/linux/incus enables it).
  • The operator's user is in the incus-admin group.
  • An SSH key exists at ~/.ssh/id_ed25519.pub (or ~/.ssh/id_rsa.pub) that root inside the container will trust.
  • The target flake ships nixosModules.incus (see SKILL.md Branch C for what that means, or the anywhen flake for a worked example).

The deploy unit — what a flake must ship

# in the app's flake.nix outputs
nixosModules.incus = { config, lib, pkgs, ... }: {
  imports = [ self.nixosModules.default ];
  incus.container = {
    enable   = true;
    hostname = lib.mkDefault "anywhen";
  };
  system.stateVersion = "25.05";
  services.anywhen = {
    enable  = true;
    package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
    host    = lib.mkDefault "0.0.0.0";
    port    = 8080;  # the incus-pet contract — fixed
  };
};

The CLI's marshaling flake imports this alongside container.nix (the in-tree essentials) and runs nixos-rebuild switch --target-host against the container.

Network model — fixed inside, unique outside

This is the section worth reading twice.

Inside the container

Every containerized service binds the same port: 8080. This is a convention, declared as a readOnly option (incus.container.servicePort) in container.nix. App authors do not pick a port — they wire services.<app>.port = config.incus.container.servicePort and move on. Cross-app port collisions become impossible because there is only one port to collide with, and each container has its own network namespace.

Outside the container (the host)

The host runs an incus proxy device per container that translates a unique <listen-ip>:<host-port> on the host into a connection to the container's 127.0.0.1:8080:

incus config device add <name> web proxy \
  listen=tcp:<listen-ip>:<host-port> \
  connect=tcp:127.0.0.1:8080

incus-pet deploy wires this for you idempotently. The <host-port> and <listen-ip> are stored in container metadata (user.incus-pet.host-port, user.incus-pet.listen) so re-running incus-pet deploy <flake-ref> doesn't need the flags repeated.

Picking a listen IP

<listen-ip> can be any address bound on the host. The three meaningful choices:

Listen IP Reach Firewall edit needed?
0.0.0.0 Anyone who can route to this host's NIC Yes — open <host-port> in networking.firewall.allowedTCPPorts
Host's LAN IP Same as above, scoped to that NIC Yes — same as above
Host's tailnet IP (e.g. 100.122.32.106) Tailnet only Notailscale0 is in networking.firewall.trustedInterfaces (see configurations/nixos/pureintent/default.nix:78), so traffic on that interface bypasses the host firewall

For "internal apps on my box, reachable only from my tailnet" (the common case), pick the tailscale IP. On a host where you always want that, export the IP once:

# in your shell profile, or via environment.variables in the host config
export INCUS_PET_LISTEN=100.122.32.106

…and every incus-pet deploy binds there automatically.

What the container's own firewall does

container.nix opens 8080 inside the container, so the proxy device's connect=tcp:127.0.0.1:8080 reaches the service. The container has its own veth on incusbr0; nothing on the host's interfaces is involved until traffic hits the proxy device on <listen-ip>:<host-port>.

Operator flow

First deploy of an app

incus-pet deploy github:srid/anywhen --port 7700 --listen 100.122.32.106

This:

  1. Synthesises a marshaling flake under ~/.local/state/incus-pet/anywhen/.
  2. Launches images:nixos/25.11 as container anywhen (with security.nesting=true so nixos-rebuild works inside).
  3. Bootstraps sshd + your pubkey via incus exec.
  4. Runs nixos-rebuild switch --target-host root@<container-ip>.
  5. Records --port and --listen in container metadata.
  6. Adds the web proxy device.

Subsequent deploys

incus-pet deploy github:srid/anywhen   # picks up new commits on the branch

No flags needed — --port and --listen are read from metadata.

Listing what's running

incus-pet list

Filters incus list to only containers tagged with user.incus-pet.flake-ref.

Removing

incus-pet rm anywhen

Stops and deletes the container, removes the marshaling flake from ~/.local/state/incus-pet/anywhen/. Container metadata goes with the container.

Failure modes

  • incus not in PATH — the host needs the incus daemon installed and the operator's user in incus-admin (log out and back in after the group is added).
  • first deploy of <name> needs --port N — pass --port; it will be recorded in container metadata for next time.
  • container did not get an IPv4 address within 30s — usually means incusbr0 didn't come up. Check incus network list and systemctl status incus-preseed.
  • Failed to start Firewall. inside the container — the launch flag -c security.nesting=true is supposed to handle this; if it recurs, see ../README.md and lxc/incus#526.
  • SSH fails after first deploy — the bootstrap step writes /etc/nixos/incus-pet-bootstrap.nix and runs nixos-rebuild inside the container; if that fails, incus exec <name> -- journalctl -xeu nixos-rebuild is the place to look.

State

What Where
Marshaling flake + lock ~/.local/state/incus-pet/<name>/{flake.nix, flake.lock}
Operator-chosen host port incus config get <name> user.incus-pet.host-port
Operator-chosen listen IP incus config get <name> user.incus-pet.listen
Source flake ref incus config get <name> user.incus-pet.flake-ref
Service data Inside the container, per the app's services.<app>.stateDir

The marshaling flake carries no app-specific values — only the assembly. App config lives entirely in the upstream flake's nixosModules.incus.