nixos-config/modules/nixos/linux/incus
Sridhar Ratnakumar 776c4425b0 incus-pet: shared /nix/store, restart-to-deploy, drop anywhen flake input
The incus-pet CLI gains three big changes that together cut per-container
disk usage by ~5GB, drop nix-rebuild + nix-copy from the deploy path, and
make `deploy` actually pull latest on every invocation. anywhen migrates
from a host-installed service to an incus-pet container in the process.

* Shared /nix/store mount. Each container gets host's /nix/store
  bind-mounted read-only (with shift=true for idmapped unprivileged
  containers). Zero per-container duplication of nixpkgs closures. The
  CLI builds toplevels on the host; the container sees them via the
  shared mount, no `nix copy` needed.

* Restart-to-deploy. switch-to-configuration is removed entirely (it
  was stalling on dbus-broker reload and hitting the same lock-
  contention bug across redeploys). New activation: update
  /nix/var/nix/profiles/system, repoint /sbin/init through the profile
  symlink, `incus restart`. The new toplevel's own init script handles
  /etc setup on boot. Trade-off: ~5s of container reboot per deploy.

* Marshaling flake exposes only nixosModules.default. No nixpkgs
  input, no nixosConfigurations entry. The CLI picks nixpkgs at build
  time (pinned to nixos-25.11 — matches the LXC image baseline, dodges
  unstable regressions) and assembles the full nixosSystem via
  `nix build --impure --expr`. Lets us bump nixpkgs without touching
  per-deploy state.

* `nix flake update --refresh` on every deploy. Was `nix flake lock`,
  which only locks first-seen inputs — re-deploying `github:srid/anywhen`
  stayed pinned at the first commit ever resolved. Now every deploy
  bumps the target input to the current tip of its ref and bypasses
  nix's fetcher cache.

* /sbin/init repoint. The NixOS LXC image hard-links /sbin/init to its
  own toplevel's init path. Without switch-to-configuration, `incus
  restart` re-execs that old init, which sets up /etc from the old
  toplevel — running services keep using the OLD anywhen binary even
  though the profile pointer moved. CLI now also does
  `ln -sfn /nix/var/nix/profiles/system/init /sbin/init` so the profile
  actually drives boot. Idempotent.

* Host gcroot via `nix-store --add-root --indirect`. Container's nix DB
  doesn't know about the path (it lives behind the shared mount), so
  without a host-side gcroot the host could nix-collect-garbage the
  running system out from under the container. Symlink at
  ~/.local/state/incus-pet/<name>/system. Only the LATEST toplevel is
  pinned per app — no rollback today (file a 5-line ring buffer when
  rollback matters).

* anywhen flake input removed. `modules/nixos/linux/anywhen.nix` is
  gone; the host config no longer imports anywhen as a flake input.
  The deploy command takes the flake ref as an argument, so locking it
  here was just bloat. `just pureintent anywhen-deploy` runs the
  current invocation.

* pureintent ops recipes. New `configurations/nixos/pureintent/mod.just`
  with anywhen-{deploy,rm,backup,restore,status,shell}. Namespaced via
  `mod pureintent` so each host's recipes live behind their own prefix.

* Drop ssh from deploy path. Bootstrap still pushes the operator's
  pubkey (for interactive debugging), but activation goes through
  `incus exec` exclusively. No NIX_SSHOPTS, no nix-copy-via-ssh,
  no host-key TOFU dance during deploys.

Tested end-to-end on pureintent: anywhen migrated from host-installed
(21-task SQLite DB at /var/lib/anywhen) to an incus-pet container with
the DB intact; hello-web deployed fresh with shared /nix/store; both
serve over the tailscale proxy at 100.122.32.106:{6111,8081}; lock
bumps + new toplevels confirmed after master advances.
2026-05-23 18:50:10 -04:00
..
incus-pet incus-pet: shared /nix/store, restart-to-deploy, drop anywhen flake input 2026-05-23 18:50:10 -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).