Files
doc/nuc/nuc-install.md
T
Julien LutranandClaude Fable 5 a0e3a7dd86 doc: split per-host READMEs, gitea cross-repo links, consistency pass
- nuc/README.md and ks4/README.md carry the host sections (+ network
  flows) that lived in the top-level README; links rebased
- top README: repo links (doc/scripts on git.lutran.fr), index points
  at the new per-host pages
- cross-repo references now use https://git.lutran.fr/julien/scripts
  instead of relative ../scripts paths that resolve nowhere
- plakar-s3-data.md and plakar-incus-integration.md marked SUPERSEDED
  / RETIRED with pointers to restic-backup.md; their measurements and
  rationale kept
- install.md, local-backup-cron.md, incus-copy.md: crontab sections
  updated to the live schedule (01:00 replicas, 05:00 restic, Sun
  maintenance); retired legs labelled as such
- restic-backup.md: status live, cutover recorded, post-GC memory
  estimate, seed plan dated
- seafile-gc.md: online GC noted, stale 'crons commented out' removed
- ks2/: what-ks2-does-today rewritten (nothing writes to it any more),
  legs table and gates reflect restic, decommission steps updated
- db-exclude replaces the plakar-era config name (script keeps a
  fallback)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:04:07 +02:00

10 KiB

nuc — bare-metal reinstall procedure

How to rebuild the Incus host from scratch if /dev/sda (512 GB SSD, XINCUU-512GB) has to be replaced.

Hardware

  • NUC-class mini PC, Intel Alder Lake-N (iGPU i915, HDMI to projector/TV)
  • sda: 512 GB SSD — OS and ZFS pool data (all Incus instance disks)
  • sdb: 4 TB WD Red (USB enclosure) — ZFS pool usb4t: usb4t/backup/backup (incus exports for nuc + ks4, 1 TB quota), usb4t/media/srv/media (media library)
  • USB: Pioneer USB audio (08e4:0176), Logitech Unifying receiver (K400), CSCTEK USB Audio and HID
  • NIC: enp1s0 (static 192.168.0.3/24, gw 192.168.0.2)

⚠️ What dies with sda

The ZFS pool data lives on sda5all instance root disks are lost with the OS. The USB pool usb4t (backups + media) survives — all instances are replicated onto it with scripts/incus-copy.sh (deployed at /root/scripts/ on nuc; set up 2026-08-09):

# one-time setup (already done; re-run only on a fresh install)
incus storage create nucbackup zfs source=usb4t/backup/nuc
incus project create backup -c features.images=false -c features.profiles=false

# refresh the replicas (all instances, stopped ones included)
/root/scripts/incus-copy.sh -p backup -s nucbackup

Runs nightly via /etc/cron.d/incus-copy at 03:30 (30 min after the profile-scheduled 03:00 instance snapshots, so VM refreshes stay incremental), logging to /var/log/incus-copy.log (logrotate: /etc/logrotate.d/incus-copy). Note the script's flock is global: if a future 02:30 ks4 pull overruns past 03:30, the local run aborts loudly for that night instead of overlapping.

Replicas sit stopped in project backup with boot.autostart=false (the script enforces it). Containers refresh in seconds; the homeassistant VM re-sends its full volume unless the source has snapshots (snapshots.schedule) to diff against.

jellyfin-server / jellyfin-client are also rebuildable from jellyfin-server.md / jellyfin-client.md; the media library lives on usb4t/media (originals on the NAS //192.168.0.10/Shared).

1. Install Debian 13 (trixie)

Netinst ISO, manual partitioning of the new sda (GPT/UEFI):

Part Size Type / FS Mount
sda1 ~1 GB EFI vfat /boot/efi
sda2 ~2 GB ext4 /boot
sda3 ~47 GB ext4 /
sda4 ~1 GB swap
sda5 rest (~427 GB) leave unformatted (ZFS later)
  • Tasks: only "SSH server" + standard utilities (no desktop).
  • Sources: main contrib non-free-firmware (contrib is required for zfs-dkms).

2. Base system

apt update && apt full-upgrade -y
apt install -y \
  firmware-intel-graphics firmware-iwlwifi firmware-realtek \
  firmware-sof-signed intel-microcode \
  linux-headers-amd64 zfs-dkms zfsutils-linux zfs-zed \
  cifs-utils curl vim htop ripgrep sysstat dmidecode pciutils usbutils

Static network — /etc/network/interfaces (ifupdown):

source /etc/network/interfaces.d/*

auto lo
iface lo inet loopback

allow-hotplug enp1s0
iface enp1s0 inet static
    address 192.168.0.3
    netmask 255.255.255.0
    gateway 192.168.0.2
    dns-nameservers 1.1.1.1 9.9.9.9

Restore /root/.ssh/authorized_keys (3 keys; one is id_rsa_claude.pub from this repo).

3. ZFS pools

# instance pool on the new SSD
ls -l /dev/disk/by-id/ | grep sda5
zpool create data /dev/disk/by-id/<new-ssd-id>-part5

# USB pool (backups + media) survived — just import it
zpool import usb4t

(If the old sda still works and only the OS was reinstalled elsewhere: zpool import data instead — the instances survive.)

usb4t reference (as created 2026-08: whole disk, ashift=12, compression=zstd, atime=off, xattr=sa, acltype=posixacl; datasets usb4t/backup/backup with quota=1T, subdatasets nuc/ks4, and usb4t/media/srv/media).

4. Incus (Zabbly stable repo)

mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
cat > /etc/apt/sources.list.d/zabbly-incus-stable.sources <<EOF
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/stable
Suites: trixie
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/zabbly.asc
EOF
apt update && apt install -y incus

Initialize with the existing pool and the macvlan network (this is what gives every instance a real LAN IP):

cat <<EOF | incus admin init --preseed
storage_pools:
- name: data
  driver: zfs
  config:
    source: data          # use the existing/just-created zpool
networks:
- name: macvlan
  type: macvlan
  config:
    parent: enp1s0
profiles:
- name: default
  devices:
    eth0:
      name: eth0
      network: macvlan
      type: nic
    root:
      path: /
      pool: data
      type: disk
EOF

Known macvlan quirk: the host cannot talk to its own instances (and vice versa) over macvlan — management is via incus exec, and other LAN hosts reach them normally. (So test a container's LAN service from inside the container or from an external LAN host — never by pinging its IP from the nuc or a sibling container; that always fails by design.)

LAN gateway note: the router/gateway is 192.168.0.2 (migrated from 192.168.0.1, 2026-08 — .1 is gone). DHCP-configured instances pick the new gateway up automatically; statically-configured ones must be updated by hand. Current static holdouts: privoxy (/etc/systemd/network/eth0.network, Gateway=) and transmission-bt (netplan routes: via: + the WG kill-switch /32). Symptom of a missed one: the service is up and its port answers, but nothing it fetches works.

Let julien run harmless incus commands (list/info/config/show…) without a password — mutating ones (exec, start/stop, delete) still prompt:

cat > /etc/sudoers.d/incus <<'EOF'
## Allow harmless incus commands to be called through sudo
## without a password.
##
## CAUTION: Any syntax error introduced here will break sudo.
## Always edit/validate with: visudo -cf /etc/sudoers.d/incus
##
## Note: "incus config *" also covers config set/edit and device add,
## which can escalate (e.g. security.privileged, host disk mounts).
## Trim to "config show/get" if strict read-only is wanted.

## Cmnd alias specification
Cmnd_Alias C_INCUS = \
  /usr/bin/incus config, /usr/bin/incus config *, \
  /usr/bin/incus list, /usr/bin/incus list *, \
  /usr/bin/incus info, /usr/bin/incus info *, \
  /usr/bin/incus version, \
  /usr/bin/incus top, \
  /usr/bin/incus monitor, /usr/bin/incus monitor *, \
  /usr/bin/incus snapshot list, /usr/bin/incus snapshot list *, \
  /usr/bin/incus image list, /usr/bin/incus image list *, \
  /usr/bin/incus image info *, \
  /usr/bin/incus profile list, /usr/bin/incus profile show *, \
  /usr/bin/incus project list, /usr/bin/incus project info *, \
  /usr/bin/incus network list, /usr/bin/incus network list *, \
  /usr/bin/incus network show *, /usr/bin/incus network info *, \
  /usr/bin/incus storage list, /usr/bin/incus storage show *, \
  /usr/bin/incus storage info *, \
  /usr/bin/incus storage volume list, /usr/bin/incus storage volume list *, \
  /usr/bin/incus operation list, /usr/bin/incus operation show *, \
  /usr/bin/incus remote list, \
  /usr/bin/incus warning list, /usr/bin/incus warning show *

## allow julien to use harmless incus commands without a password
julien ALL = (root) NOPASSWD: C_INCUS
EOF
chmod 0440 /etc/sudoers.d/incus
visudo -cf /etc/sudoers.d/incus   # must print "parsed OK"

5. Restore instances

The replicas live on usb4t/backup/nuc and survived. Re-register them with the fresh Incus, then copy back onto the rebuilt data pool:

# recreate the containers' home first (section 4), then:
incus project create backup -c features.images=false -c features.profiles=false
incus admin recover        # point it at pool nucbackup (zfs, source=usb4t/backup/nuc)

# copy each replica back to the default project / SSD pool:
incus copy blocky blocky --project backup --target-project default -s data
incus config set blocky boot.autostart=true
incus start blocky
# … same for privoxy, homeassistant, jellyfin-*

(jellyfin-* can alternatively be rebuilt from jellyfin-server.md / jellyfin-client.md; /srv/media is already there once usb4t is imported.)

6. Nightly automation

When What Where
03:00 instance snapshots (snapshots.schedule on the default profile, expiry 7d) incus
03:30 replicate all instances to the USB pool (incus-copy.sh -p backup -s nucbackup) /etc/cron.d/incus-copy/var/log/incus-copy.log
05:00 apt dist-upgrade all running containers (incus-container-upgrade.sh; VMs and non-apt containers skipped; jellyfin pinned to the 10.11 series in-container) /etc/cron.d/incus-container-upgrade/var/log/incus-container-upgrade.log

The ordering is deliberate: snapshot → backup → upgrade, so a broken upgrade is always one snapshot-restore away and the replicas predate it. Both logs rotate monthly (/etc/logrotate.d/incus-*).

7. Post-install checklist

  • zpool status healthy (both data and usb4t), incus list shows expected instances
  • incus ordered after ZFS mounts (instances bind-mount /srv/media): /etc/systemd/system/incus.service.d/after-zfs.conf with [Unit] / After=zfs-mount.service zfs.target
  • boot.autostart=true on blocky, privoxy, jellyfin-*
  • Timezone Europe/Paris on host and in every container (incus exec <ct> -- timedatectl set-timezone Europe/Paris; the install scripts in the per-container docs include it)
  • Host boots to multi-user.target, nothing grabs the GPU (required by the jellyfin-client kiosk)
  • Jellyfin web at http://192.168.0.5:8096, kiosk UI on HDMI, sound on the Pioneer, "Pioneer A-70" visible in Spotify Connect
  • LAN DNS: clients use blocky at 192.168.0.254 (host itself uses public 1.1.1.1 + 9.9.9.9, never blocky, to avoid a bootstrap loop)
  • Optional (only if QSV low-power encoders are wanted): echo 'options i915 enable_guc=3' > /etc/modprobe.d/i915.conf && update-initramfs -u