nixos-config/modules/nixos/linux/incus
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
..
incus-pet incus-pet: per-app incus container CLI; drop webapps/ (#118) 2026-05-23 16:28:46 -04:00
container.nix incus-pet: per-app incus container CLI; drop webapps/ (#118) 2026-05-23 16:28:46 -04:00
default.nix Enable incus on pureintent (#113) 2026-04-19 11:21:39 -04:00
README.md Enable incus on pureintent (#113) 2026-04-19 11:21:39 -04:00

Incus

This module turns on Incus with a bridged network (incusbr0), a dir storage pool, and the web UI. The host's user is added to incus and incus-admin. Preseed is declarative — the bridge, profile, and pool come up on first boot without incus admin init.

After the first activation

Log out and back in. The users.users.<you>.extraGroups change lands in /etc/group at activation but your shell still has the old groups. Check with id. If you don't see incus-admin, you haven't relogged.

Then:

incus launch images:nixos/unstable demo
incus list
incus exec demo -- bash

That's the whole smoke test. If it works, you're done.

Containers vs. VMs

Same command, one flag:

incus launch images:nixos/unstable demo          # container (fast, shared kernel)

incus launch images:nixos/unstable demo --vm \   # VM (full isolation)
  -c security.secureboot=false \
  -c limits.memory=8GiB \
  -c limits.cpu=4 \
  -d root,size=50GiB

Containers share the host kernel and boot in a second or two. VMs need /dev/kvm and take ~10s to boot, but run a different kernel. Pick the container unless you need the kernel boundary.

Why each VM flag:

  • security.secureboot=false — community NixOS images aren't signed for UEFI secure boot, so incus refuses to start them unless the check is off. A signed image would make this unneeded; none ships in the images: remote today.
  • limits.memory=8GiB — the incus default is too small for nixos-rebuild switch inside the VM. Symptoms of a too-small VM: builds die partway with no clear error, or incus console drops with websocket: close 1006 (abnormal closure) when the guest OOMs.
  • limits.cpu=4 — optional, but nixos-rebuild is CPU-bound on eval.
  • -d root,size=50GiB — the community image defaults to ~10 GiB. That's enough to boot and not much else; one nix run that fetches nixpkgs can fill it. Symptom: error: ... No space left on device partway through an eval. Resizing a running VM's root is fragile, so pick a generous size at launch.

The images: remote ships community NixOS builds for unstable, 25.05, 24.11 on amd64 and arm64. They're minimal — no users, no flakes, no home-manager. Customize post-launch with incus exec or rebuild from a flake inside the instance.

Getting a shell

Use incus exec — it routes through the incus agent inside the instance, so it works with zero setup on a fresh image:

incus exec demo -- bash

No SSH keys, no networking concerns, no users. If you specifically need real SSH (e.g. for rsync, editor remotes), push your pubkey into /root/.ssh/authorized_keys with incus file push and SSH to the IP shown in incus list.

Configuring the guest

The community image has its own /etc/nixos/configuration.nix — it's the guest's config, nothing to do with this host flake. Two edits you'll almost always want on a fresh VM:

{ pkgs, ... }:
{
  # Flakes + the new nix command
  nix.settings.experimental-features = [ "nix-command" "flakes" ];

  # Whatever ports your services listen on
  networking.firewall.allowedTCPPorts = [ 8080 ];
}

Apply with sudo nixos-rebuild switch. If the build dies mid-way or incus exec starts hanging afterward, the VM is under-provisioned — relaunch with higher limits.memory (see the VM section above).

Running and exposing a service

Say you want to host something on port 8080 inside the instance. Three layers have to cooperate, and each has its own failure mode.

1. Bind to the right interface. Servers that default to 127.0.0.1 won't be reachable from the host no matter what else you do. Make sure yours is listening on 0.0.0.0:8080 (or the bridge IP). From inside:

ss -tlnp | grep 8080

2. Open the instance's firewall. NixOS community images ship with networking.firewall.enable = true and no ports open. Symptom: curl from inside the instance works, curl from the host hangs. Fix in /etc/nixos/configuration.nix:

networking.firewall.allowedTCPPorts = [ 8080 ];

Then sudo nixos-rebuild switch. The host can now hit the instance on its incusbr0 IP (from incus list).

3. Publish to the LAN (optional). The incusbr0 IPs aren't reachable outside the host. To forward a host port into an instance:

incus config device add myvm web proxy \
  listen=tcp:0.0.0.0:8080 \
  connect=tcp:127.0.0.1:8080

Now anything that reaches the host on :8080 gets proxied in. Remember to open 8080 in the host's networking.firewall.allowedTCPPorts too — or bind the proxy to a Tailscale IP and skip the firewall edit.

The web UI

ui.enable = true bundles the UI assets but doesn't open a socket. Configure the listener on each host that imports this module:

virtualisation.incus.preseed.config."core.https_address" = "<ip>:8443";

Binding to a Tailscale IP (e.g. 100.x.y.z:8443) is the easy win: no firewall change needed because services.tailscale.enable already trusts tailscale0. Binding to 0.0.0.0:8443 needs 8443 added to networking.firewall.allowedTCPPorts.

First time you hit the UI, the browser has no client cert. Trust the self-signed server cert, then add your browser as a trusted client:

# On the host, grab the UI's fingerprint from the browser, then:
incus config trust add-certificate --name my-browser

Or generate one via CLI and import into the browser. The UI walks you through it.

When it breaks

"permission denied" on /var/lib/incus/unix.socket — your shell isn't in incus-admin yet. Log out and back in, or sudo -u <you> -i from another session.

Containers won't get an IP. The preseed bridge didn't come up. Check incus network listincusbr0 should be CREATED with an IPv4/IPv6 range. If it's missing, systemctl status incus-preseed will show the error.

user-1000 project weirdness. Incus auto-created a per-user project and you're stuck in it. Get out:

incus project switch default

Nuclear reset. When the daemon state is wedged past saving:

sudo systemctl stop incus incus.socket
sudo rm -rf /var/lib/lx* /var/lib/incus/
sudo systemctl start incus

Then re-activate the NixOS config so the preseed runs again. All instances and images are lost — don't do this casually.

Why a dir storage pool

The preseed picks driver = "dir" because it works anywhere without extra setup. It's also the slowest option and doesn't support instance snapshots. If you start running more than a handful of instances, switch to zfs or btrfs — edit the preseed and reset the pool (containers on the old pool have to be re-created).