Compare commits

..
3 Commits
Author SHA1 Message Date
Julien LutranandClaude Opus 5 203728674f nuc: media over NFS from nas, on-demand role, i915 and input traps
nuc keeps only what needs its iGPU. blocky, privoxy and transmission-bt
moved to nas so the box can be powered off when not watching Jellyfin or
using the Spotify kiosk.

- /srv/media is now an NFSv4 mount from nas; jellyfin-server reads it
  with shift=false (idmapped mounts are unsupported on NFS, as the
  container doc already noted for CIFS) and readonly=true
- replication to nas is a systemd timer with Persistent=true, not cron —
  an on-demand host misses its 03:30 window and cron cannot catch up

Two failures documented in full, both diagnosed from the wrong layer
first:

- booting with the TV connected and powered on kills the i915 probe
  (drm_WARN_ON in intel_modeset_setup_hw_state), so /dev/dri never
  appears, snd_hda_intel deferred-probes forever holding the PCI device
  lock, and incusd blocks in sriov_numvfs_show — no container starts at
  all, including LAN DNS. Identical on 6.12.107 and 6.12.105.
- the kiosk input gid mismatch is real but was NOT the cause of the
  2026-08-30 outage (flat K400 batteries were); seatd opens input devices
  as root, so kiosk group membership is not on that path. Records the
  one-line raw capture that settles hardware-vs-software immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:53:30 +02:00
Julien LutranandClaude Opus 5 66c7bca28f ks2: ks4 pull leg and its WireGuard tunnel move from nuc to nas
nuc-seed.md -> nas-seed.md. The leg was designed around nuc's USB pool,
which is exactly the device it must not depend on. Target pool ks4backup
now lives on tank; nas becomes WG peer 10.8.0.22 and nuc's tunnel retires
once seeded — nuc no longer needs one at all, since transmission-bt (the
only other user) moved to nas with its own in-container tunnel.

ks4 needs no change: traffic arrives masqueraded as the wireguard
container whichever peer sent it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:53:30 +02:00
Julien LutranandClaude Opus 5 58dcaa5d41 nas: new storage host on A1SAi-2750F — 4 TB off USB onto SATA
Builds the box that ends the usb4t dropouts: the JMicron bridge was the
least reliable device in the setup and it held the intended off-site copy
of ks4. The 4 TB now sits on direct SATA as pool `tank`.

- OS on mdraid RAID1 across two 120 GB SSDs (Intel 330 + Toshiba Q300),
  both ESPs bootable; `incus` ZFS mirror on their tails, ~22% left
  unallocated as over-provisioning
- media at /export/media, exported read-only over NFSv4 to nuc
- transmission-bt moves here (its WireGuard tunnel is in-container, so
  ks4 needed no change) and writes to the dataset locally
- backup pools nucbackup / ks4backup / nasbackup
- monitoring live: msmtp (submission+auth, verified 250), zed with
  NOTIFY_DATA, zpool-health.sh every 15 min, smartd on all three disks

Traps recorded because none of them point at their own cause: booting
with the display active kills the i915 probe and wedges incus; d-i picks
grub-pc vs grub-efi from how the installer booted; `incus storage create`
hangs forever on a mountpoint=none dataset; the BMC is deliberately never
cabled, so there is no out-of-band console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:53:30 +02:00
12 changed files with 1237 additions and 88 deletions
+3 -1
View File
@@ -12,7 +12,9 @@ backed up, by which tool, on what schedule, and how to restore.
service to ks4 (and getting it backed up automatically).
**Tech notes**
- [`nuc/`](nuc/README.md) — home lab on `nuc`: host, storage, instances
- [`nuc/`](nuc/README.md) — home lab on `nuc`: host, iGPU instances
- [`nas/`](nas/README.md) — storage + backup host `nas` (Supermicro
A1SAi-2750F): the 4 TB on direct SATA, media over NFS, backup pools
- [`ks4/`](ks4/README.md) — prod server `ks4` at OVH: host, services, network flows
- [`ks2/`](ks2/) — legacy backup server being [decommissioned](ks2/plan.md)
- [`archer-c7/`](archer-c7/) — home router (TP-Link Archer C7 v5 running on OpenWrt)
+2 -2
View File
@@ -8,8 +8,8 @@ few days of margin.
- [ ] restic S3 leg running nightly for ≥ a week, `done (rc=0)`,
restore test passed ([restic-backup.md](../ks4/restic-backup.md) §7)
- [ ] nuc pull leg seeded and one instance test-restored
([nuc-seed.md](nuc-seed.md))
- [ ] **nas** pull leg seeded and one instance test-restored
([nas-seed.md](nas-seed.md))
- [ ] local leg (sdb5) cron green in `/var/log/incus-copy.log`
## 1. Cut the last flows to ks2 (root on ks4)
+96
View File
@@ -0,0 +1,96 @@
# ks4 pull leg — seed after FTTH
Status: **prepared, waiting on the FTTH link.**
⚠️ **Changed 2026-08-30: this leg lands on `nas`, not on nuc.** It was
originally designed for nuc's USB pool `usb4t`, but that pool proved to
be the least reliable device in the setup
([nuc/usb4t-dropouts.md](../nuc/usb4t-dropouts.md)) — which is exactly
what an off-site copy of ks4 must not be. The 4 TB disk moved to direct
SATA on the new host `nas` (`192.168.0.4`,
[nas/nas-install.md](../nas/nas-install.md)), and the target pool
`ks4backup` moved with it.
Consequences versus the original plan:
- Target pool `ks4backup` is now backed by `tank/backup/ks4` on nas.
- The **WireGuard tunnel moves too**: nas becomes peer `10.8.0.22`;
nuc's `wg-ks4` (`10.8.0.20`) is retired once this works. nuc no longer
needs a tunnel at all — `transmission-bt`, its only other user, now
runs on nas and carries its own in-container tunnel (`10.8.0.21`,
unchanged, ks4 needs no edit for it).
- ks4's ufw rule is unchanged: traffic arrives masqueraded as the
`wireguard` container (`192.168.1.18`) whichever peer sent it.
## Prerequisites
- [ ] `tank` running on SATA for ≥ 7 days with zero pool suspensions —
the gate that replaces "fix the USB enclosure"
- [ ] FTTH up (the first pass moves ~1.75 TiB)
## Setup (root on nas)
```sh
# 1. peer nas on ks4's wireguard container
# (run on ks4) — <nas-pubkey> from /etc/wireguard/wg-ks4.key on nas
incus exec wireguard -- wg set wg0 peer <nas-pubkey> allowed-ips 10.8.0.22/32
incus exec wireguard -- wg-quick save wg0
# 2. tunnel on nas: /etc/wireguard/wg-ks4.conf, modelled on nuc's
# Address = 10.8.0.22/32, peer pubkey TVs6d7…,
# Endpoint = 193.70.35.17:51845,
# AllowedIPs = 10.8.0.0/24, 192.168.1.1/32, keepalive 25
systemctl enable --now wg-quick@wg-ks4
# 3. incus remote over the tunnel
incus remote add ks4 https://192.168.1.1:8443 --accept-certificate --token '…'
incus list ks4: | head # sanity: remote reachable
```
## Seed
```sh
# full pull of every ks4 instance into pool ks4backup (tmux — first pass
# moves ~1.75 TiB through the WG tunnel)
/root/scripts/incus-copy.sh -r ks4 -s ks4backup 2>&1 | tee -a /var/log/incus-copy-ks4.log
```
Notes:
- First pass is a full send per instance; later refreshes are
ZFS-incremental **as long as they run at least every
`snapshots.expiry` (7 d on ks4)** — same caveat as
[ks4's local leg](../ks4/local-backup-cron.md).
- Replicas arrive stopped with `boot.autostart=false` (the script does
this) — they must never come up on the LAN with ks4's proxy devices.
## Cron (after the seed)
Add to nas's root crontab, offset from the 03:30 nuc→nas push, the
04:00 nas→nuc push and ks4's own 01:00/05:00 jobs:
```cron
0 5 * * * /root/scripts/incus-copy.sh -r ks4 -s ks4backup >> /var/log/incus-copy-ks4.log 2>&1
```
## Verification (release gate for ks2)
```sh
incus list --project backup -c ns -f csv # all ks4 instances present
# test-restore one instance: copy a replica to the local pool,
# start it isolated, check the service answers, then delete it
incus copy solar solar-restoretest -s incus
incus start solar-restoretest && incus exec solar-restoretest -- systemctl is-system-running
incus delete -f solar-restoretest
```
Once verified, tick the nas gate in the [ks2 plan](plan.md) and retire
nuc's tunnel:
```sh
# on nuc
systemctl disable --now wg-quick@wg-ks4 && rm /etc/wireguard/wg-ks4.conf
# on ks4
incus exec wireguard -- wg set wg0 peer <nuc-pubkey> remove
incus exec wireguard -- wg-quick save wg0
```
-49
View File
@@ -1,49 +0,0 @@
# nuc pull leg — seed after FTTH
Status: **prepared, waiting on the FTTH link.** Everything is already
configured on nuc (see the main [README](../README.md)): incus remote
`ks4` over the WireGuard tunnel (`wg-ks4`, 10.8.0.20 → 10.8.0.1),
target pool `ks4backup` on the USB ZFS pool (`usb4t/backup/ks4`) —
only the seed itself waited on bandwidth.
## Seed (root on nuc)
```sh
# sanity: remote reachable through the tunnel
incus list ks4: | head
# full pull of every ks4 instance into pool ks4backup (screen/tmux —
# first pass moves ~1.7 T through the WG tunnel)
/root/scripts/incus-copy.sh -r ks4 -s ks4backup 2>&1 | tee -a /var/log/incus-copy-ks4.log
```
Notes:
- First pass is a full send per instance; later refreshes are
ZFS-incremental **as long as they run at least every
`snapshots.expiry` (7 d on ks4)** — same caveat as
[ks4's local leg](../ks4/local-backup-cron.md).
- Replicas arrive stopped with `boot.autostart=false` (script does
this) — they must never come up on the LAN with ks4's proxy devices.
## Cron (after the seed)
Add to nuc's root crontab, offset from the 03:30 local nucbackup copy
and ks4's own 01:00/05:00 jobs:
```cron
0 5 * * * /root/scripts/incus-copy.sh -r ks4 -s ks4backup >> /var/log/incus-copy-ks4.log 2>&1
```
## Verification (release gate for ks2)
```sh
incus list --project backup 2>/dev/null || incus list | grep -c . # all ks4 instances present
# test-restore one instance: copy a replica to the default pool,
# start it isolated, check the service answers, then delete it
incus copy solar solar-restoretest -s default
incus start solar-restoretest && incus exec solar-restoretest -- systemctl is-system-running
incus delete -f solar-restoretest
```
Once verified, tick the nuc gate in the [ks2 plan](plan.md).
+8 -7
View File
@@ -17,10 +17,11 @@ What remains on the box is **cold history**: instance replicas on pool
`/backup/ns3061243` on pool `backup` (last refreshed 2026-08-28),
snapshotted daily there (`zfs-auto-snapshot.sh`, 2-month expiry).
⚠️ While the nuc leg waits for FTTH, instances have no *fresh*
⚠️ While the nas leg waits for FTTH, instances have no *fresh*
off-site copy — the ks2 push is to be re-enabled as soon as the
initial restic sync finishes (decided 2026-08-28), and retired again
when nuc takes over.
when nas takes over. (The leg moved from nuc to the new host `nas`
on 2026-08-30 — see [nas-seed.md](nas-seed.md).)
## Inventory findings (2026-08-22)
@@ -51,8 +52,8 @@ when nuc takes over.
| local, 2nd disk | `incus-copy.sh -p backup -s backup` → sdb5 pool | **live** (01:00) |
| off-site, S3 (data+DB) | **restic** → bucket `restic-data` ([restic-backup.md](../ks4/restic-backup.md)) | **live** (05:00) |
| off-site, S3 (instances) | restic over `incus file mount` of the `backup`-project replicas | **shelved** 2026-08-28 (replication covers instances) |
| off-site, nuc | nuc pulls `ks4:*` → pool `ks4backup` over WG ([nuc-seed.md](nuc-seed.md)) | waiting FTTH (< Sep 30) |
| off-site, ks2 (interim) | `incus-copy.sh -d ks2 -m push` at 02:00 until the nuc leg seeds | to re-enable once the restic seed finishes |
| off-site, nas | **nas** pulls `ks4:*` → pool `ks4backup` over WG ([nas-seed.md](nas-seed.md)) | waiting FTTH (< Sep 30); moved off nuc 2026-08-30 |
| off-site, ks2 (interim) | `incus-copy.sh -d ks2 -m push` at 02:00 until the nas leg seeds | to re-enable once the restic seed finishes |
## Actions (backup work documented in [`../ks4/`](../ks4/), ks2-only tasks here)
@@ -65,9 +66,9 @@ when nuc takes over.
([restic-backup.md](../ks4/restic-backup.md), live 2026-08-28; the
predecessor's doc is kept as reference)
4. ~~instance leg to S3~~**shelved 2026-08-28**: instances are
protected by replication (sdb + nuc/ks2), their data and configs by
protected by replication (sdb + nas/ks2), their data and configs by
`restic-data`
5. [nuc-seed.md](nuc-seed.md) — **prepared**; after FTTH: seed nuc
5. [nas-seed.md](nas-seed.md) — **prepared**; after FTTH: seed the nas
pull leg, verify all instances, test-restore one
6. [decommission.md](decommission.md) — **prepared**; cut flows,
final diff of `/backup/ns3061243`, wipe pools, terminate at OVH
@@ -77,7 +78,7 @@ when nuc takes over.
- [x] biwiki + spot consciously abandoned (2026-08-22)
- [x] local leg cron running since 2026-08-22, **18/18 instances**
replicated (verified 2026-08-28)
- [ ] nuc leg fully seeded **and** one instance test-restored
- [ ] **nas** leg fully seeded **and** one instance test-restored
- [x] restic S3 backups live (05:00) **and** restore drill passed
2026-08-28: tree restored byte-identical to live, dump restored
and loaded into a scratch MariaDB (12/12 tables)
+50
View File
@@ -0,0 +1,50 @@
# Storage: nas
Storage + backup host on the LAN, added 2026-08.
- **Always-on host.** nuc is now an on-demand media box (see
[nuc/README.md](../nuc/README.md)), so everything that must stay up —
LAN DNS, the HTTP proxy, torrents — lives here.
- Debian 13, Supermicro **A1SAi-2750F** / Intel Atom C2750 (8 c, 20 W,
ECC DDR3) — build procedure: [nas-install.md](nas-install.md)
- SSH: `ssh -i id_rsa_claude root@192.168.0.4`
- **No iGPU** (Avoton is headless; video is the AST2400 BMC). Anything
needing hardware transcoding stays on nuc.
- Pools:
- `incus` — ZFS mirror across the last partition of both 120 GB SSDs;
holds this host's container roots. OS itself is on mdraid RAID1 +
ext4 across the same disks (rationale in
[nas-install.md](nas-install.md) §2).
- `tank` — the 4 TB WD Red, **on direct SATA**. This is the whole
point of the box: the disk used to hang off a JMicron USB bridge on
nuc that suspended the pool 61 times in 30 days
([nuc/usb4t-dropouts.md](../nuc/usb4t-dropouts.md)). Single vdev,
accepted — nothing on it is irreplaceable.
- `tank/media``/export/media`: the media library. Exported
**read-only over NFSv4 to nuc**, where `jellyfin-server` reads it;
written locally only by `transmission-bt`.
- Backups: nuc ↔ nas **cross-replication** (each host's instances live
on the other), plus the ks4 pull leg —
[nas-install.md](nas-install.md) §9,
[ks2/nas-seed.md](../ks2/nas-seed.md).
## Instances
| Name | IP | Doc | Features |
|---|---|---|---|
| blocky | 192.168.0.254 | — | unprivileged, autostart; DNS ad-blocker for the LAN. Moved from nuc 2026-08-30 so it survives nuc being powered off |
| privoxy | 192.168.0.11 | — | unprivileged, autostart; filtering HTTP proxy, listens on **:3128** (not privoxy's default 8118); static config in `/etc/systemd/network/eth0.network`, `DNS=192.168.0.254`. Moved from nuc 2026-08-30 |
| [transmission-bt](transmission-bt.md) | 192.168.0.7 | ✅ | unprivileged, autostart; always-on WireGuard full tunnel → ks4 (egress = 193.70.35.17, kill switch: no default route); `/export/media` disk device (`shift=true`), downloads to `/media/downloads`; web UI :9091 (LAN only). Moved from nuc 2026-08-30 |
## Backup pools hosted here
| incus pool | dataset | receives |
|---|---|---|
| `nucbackup` | `tank/backup/nuc` | nuc's instances (pushed nightly, 03:30) |
| `ks4backup` | `tank/backup/ks4` | ks4's instances (pulled over WG, 05:00 — after FTTH) |
| `nasbackup` | `tank/backup/nas` | **nas's own** instances (local copy, 04:00) |
nas's own instances are replicated **locally** rather than to nuc: nuc is
an on-demand box and usually powered off, so it is not a usable backup
target. `nasbackup` lives on `tank`, a different pool from the `incus`
SSD mirror the instances run on.
+838
View File
@@ -0,0 +1,838 @@
# nas — build procedure
New storage host on the LAN (`192.168.0.4`), built 2026-08 from a
Supermicro A1SAi-2750F. It exists to solve one specific problem:
[usb4t-dropouts.md](../nuc/usb4t-dropouts.md) ruled out the cable and
USB power management and left the **JMicron 152d:0578 bridge** as the
cause of 61 disconnects in 30 days. The durable fix named there is a
direct SATA connection — this box provides six of them.
Why it matters beyond the media library: `usb4t` is the intended home
of the ks4 off-site replicas, and *"the pool holding the off-site copy
of ks4 must not be the least reliable device in the setup"*. That makes
this build a **release gate for the ks2 decommission**
([ks2/plan.md](../ks2/plan.md), deadline Sep 30, 2026).
It also takes over `transmission-bt` from nuc, so nuc keeps only what
needs its iGPU.
## Hardware
- Supermicro **A1SAi-2750F** mini-ITX, Intel Atom **C2750** (8 cores,
2.4 GHz Silvermont, 20 W SoC), AES-NI, no AVX
- RAM: **2× 4 GB DDR3-1600 ECC SO-DIMM fitted = 8 GB** (`Single-bit ECC`
confirmed), in DIMMA1/DIMMB1; **2 slots free**. Board takes 32 GB
officially, 64 GB with 16 GB modules.
⚠️ 8 GB is modest for a 3.6 TB pool — ARC lands around 4 GB. Fine for
streaming and replication (neither benefits much from cache), but the
first thing to raise if metadata-heavy operations feel slow.
- SATA: **2× SATA3 + 4× SATA2** (6 total)
- BIOS **2.2** (2019-11-22) as shipped by the RMA
- NIC: 4× GbE (Intel i354) + dedicated IPMI LAN
- Video: **ASPEED AST2400 BMC only — there is no iGPU.** Avoton is a
headless server SoC; `/dev/dri` is empty. That is why `jellyfin-server`
stays on nuc (§8).
### ⚠️ AVR54 — already handled
The C2750 is on the list of Atom C2000 parts affected by Intel's
**AVR54** erratum: the SoC's `LPC_CLKOUT0/1` signals degrade and stop,
after which the board never boots again — typically after ~18 months of
power-on, i.e. exactly an always-on duty cycle. **This board was RMA'd
by Supermicro for that issue and replaced**, so it carries the fix
(C0 stepping or the LPC pull-up rework). Recorded here so a dead C2000
board is not re-diagnosed from scratch later.
### Disk plan
| Port | Device | Role |
|---|---|---|
| SATA3-0 | Intel `SSDSC2CT120A3` (120 GB) | md mirror + `incus` pool |
| SATA3-1 | Toshiba `Q300.` (120 GB) | md mirror + `incus` pool |
| SATA2-0 | WD Red 4 TB (moved off the USB enclosure) | pool `tank` |
| SATA2-1/2/3 | free | a second 4 TB to mirror `tank`, later |
The SSDs get the SATA3 ports because they are the only devices that can
use them: both negotiate 6 Gb/s and do ~450500 MB/s, while the WD Red
tops out near 180 MB/s and cannot saturate SATA2's ~270 MB/s. A future
SLOG would also be fine on SATA2 — it is latency-bound on small sync
writes, not bandwidth-bound.
⚠️ **Disconnect the 4 TB before partitioning the SSDs.** It carries the
media library and nuc's replicas, nothing in the OS install needs it,
and it keeps the two 120 GB disks unambiguous in the installer's list.
Reconnect it before §5b.
Device letters shift with enumeration order and mean nothing — mdraid
assembles from superblock UUIDs, ZFS imports by GUID, fstab and GRUB use
UUIDs. **Identify disks by model and serial**
(`lsblk -o NAME,SIZE,MODEL,SERIAL`), never by letter: both SSDs are
120 GB, so size alone does not tell them apart.
SSD health measured 2026-08-30 (both read over the JMicron bridge on
nuc, `smartctl -d sat`), before deployment:
| | Intel 330 (25 nm MLC) | Toshiba Q300 (15 nm TLC) |
|---|---|---|
| host writes | 16.0 TiB | 7.5 TiB |
| endurance consumed | **0 %** (`Media_Wearout_Indicator` 100) | **7 %** (`Percentage Used Endurance Indicator`) |
| power-on hours | **unreadable** — attr 9 decodes to 914,563 h on this family | 3,877 |
| power cycles | 98 | 432 |
| defects | 0 reallocated / program-fail / erase-fail | 0 reported uncorrectable |
| SMART error log | **not supported** | supported, clean |
| device statistics log | absent | full ACS-2 set |
| short self-test | passed | passed |
| interface CRC errors | n/a | 16 (baseline — watch for growth) |
Both are healthy and far from wear-out; at OS-disk write rates endurance
is not the binding constraint for either. They were previously a
**matched pair** — identical layouts, `bpool` / `rpool` / `ubuntu:0`
labels from an Ubuntu ZFS-on-root mirror — which is why they go back
into a mirror here.
⚠️ **The Intel is effectively unmonitorable**: no error log, no device
statistics, no temperature, no usable hours counter. `smartd` can watch
the Toshiba properly and can only ask the Intel "are you still there".
Expect the Intel to be found dead rather than found degrading. That
asymmetry is the reason for the mirror.
### `tank` is a single vdev — accepted
The 4 TB holds the media library, transmission's downloads and the ks4
replicas, with no redundancy. A single vdev can *detect* corruption but
only self-heal metadata, not data — as seen in the usb4t incident.
**Decision 2026-08-30: accepted, nothing on `tank` is irreplaceable.**
Media and instances are re-fetchable from their sources; the ks4
replicas are leg 3 of ks4's 3-2-1 (local `sdb5` + restic/S3 remain —
[backup-strategy.md](../backup-strategy.md)). Losing `tank` costs time,
not data.
Three SATA ports stay free, so `zpool attach tank <existing> <new>`
turns it into a mirror whenever a spare 4 TB turns up. Not a
prerequisite for anything. Scrub weekly regardless — on a single vdev
the scrub is the only thing that *tells* you a file has rotted.
## 1. IPMI and BIOS first
The AST2400 stack is old and has known vulnerabilities.
- **Decision 2026-08-30: the BMC is never cabled.** The dedicated IPMI
port stays unplugged, so the AST2400's default credentials and its
known vulnerabilities are not reachable from anything. This is the
simplest correct answer for a box that sits on a flat home LAN — no
management VLAN needed, nothing to harden, nothing to patch.
- ⚠️ Consequence: **there is no out-of-band console.** A boot that fails
before sshd needs a physical monitor and keyboard. Worth knowing before
changing anything that affects booting (GRUB, the md arrays, fstab).
- BIOS: enable **restore-on-AC-loss**, enable C-states, and disable the
three unused i354 NICs (each costs about a watt).
### Switching this board to UEFI
There is **no "Boot Mode Select" entry** in this BIOS — that option
exists on later Supermicro generations, not here. What works
(verified 2026-08-30):
- **CSM → Disabled**
- **All OpROM policies → UEFI** (storage, video *and* network)
Setting the **video** OpROM to UEFI is safe despite the console being
the AST2400 BMC framebuffer — output survives, both on the BMC console
and over IPMI KVM.
⚠️ **Confirm the mode before you partition anything**, from the
installer (`Ctrl+Alt+F2`):
```sh
ls /sys/firmware/efi # directory exists = UEFI. Missing = legacy
```
This single check is what makes the difference between a working
install and an afternoon lost — see the trap in §3.
## 2. Prepare the SSDs
Both report `ATA Security is: Disabled, NOT FROZEN`, so a real secure
erase is available — do that rather than just repartitioning. It
restores the full spare-block pool on ten-year-old NAND.
```sh
# per disk, from a live system where the disk is NOT the running OS
hdparm --user-master u --security-set-pass Eins /dev/sdX
hdparm --user-master u --security-erase Eins /dev/sdX
hdparm -I /dev/sdX | grep -A2 Security # expect "not enabled"
```
Identical GPT layout on both, sized so ~27 GB (22 %) stays
**unallocated** as over-provisioning:
| Part | Size | Type | Device | Use |
|------|------|------|--------|-----|
| `sdX1` | 1 GB | EFI System Partition | — | `/boot/efi` (two independent ESPs) |
| `sdX2` | 2 GB | Linux RAID | `md0` | `/boot` ext4 |
| `sdX3` | 24 GB | Linux RAID | `md1` | `/` ext4 |
| `sdX4` | 2 GB | Linux RAID | `md2` | swap |
| `sdX5` | 64 GB | Solaris root (bf00) | — | **zpool `incus`** (mirror) |
| — | ~27 GB | **unallocated** | — | over-provisioning |
- ⚠️ **The ESP is deliberately not a RAID1 array.** ks4 has `md1 →
/boot/efi` because OVH's installer builds it with mdadm metadata
**1.0** (superblock at the *end*, so firmware still sees plain FAT).
`debian-installer` only creates metadata **1.2** arrays, whose
superblock sits at the start and makes the ESP unreadable to
firmware. So: one plain ESP per disk, only one mounted, the second
filled by hand (§3). Deviation from ks4 is intentional.
- A legacy-BIOS variant of this layout was tried first — 1 MB
`bios_grub` instead of the ESP, which makes the mirror simpler
(`grub-install` to both disks, nothing to keep in sync). It was
abandoned because this board's firmware has no way to prefer legacy
targets once CSM is off, and it kept falling through to the UEFI
shell. Recorded so it is not retried: **UEFI is the working path
here.**
- `/` at 24 GB matches the other hosts (nuc 46 GB, ks4 40 GB) — this box
has no desktop and no container roots on `/`.
- Mixing md partitions and a ZFS partition on the same disks is exactly
what ks4 does (`md1/2/3` + ZFS on `sda5`/`sdb5`).
### Why not full root-on-ZFS
Decision 2026-08-30. These SSDs previously ran Ubuntu 20.04's
experimental ZFS-root installer (hence the leftover `bpool` / `rpool`
labels), so the option was on the table. Rejected because:
- **Debian's installer cannot do it.** ZFS is CDDL, shipped only in
`contrib` as `zfs-dkms`; `debian-installer` can neither partition nor
boot from ZFS. Root-on-ZFS means the manual
[OpenZFS Debian Trixie HOWTO](https://openzfs.github.io/openzfs-docs/Getting%20Started/Debian/Debian%20Trixie%20Root%20on%20ZFS.html)
— ~60 steps from a live ISO. Per the repo convention the doc *is* the
rebuild procedure, and that is a bad thing to be executing during an
actual failure.
- **DKMS failure mode.** `zfs-dkms` rebuilds on every kernel upgrade.
If that build fails, root-on-ZFS means the box **does not boot**;
with an ext4 md root it boots normally and only the pools are
missing — recoverable over IPMI with a shell.
- GRUB's ZFS support lags OpenZFS, which is why every root-on-ZFS guide
needs a separate feature-limited `bpool`; `zpool upgrade bpool` is a
known way to make a machine unbootable.
- Ubuntu's version of this is a dead end anyway: the installer option
was nearly dropped in 22.04 and `zsys`, which made boot environments
useful, is abandoned.
What root-on-ZFS would buy — snapshot and roll back a bad upgrade — is
already covered where the state actually lives: container roots get
incus snapshots plus nightly replication (§9). The host is 24 GB of
packages reproducible from this file. Accepted cost: no pre-upgrade
rollback of the host itself (`etckeeper` covers `/etc` if wanted).
## 3. Install Debian 13 (trixie)
Netinst ISO (burned 2026-08-30, sha256 `65273bee…664e7`, verified
against `cdimage.debian.org/debian-cd/13.6.0/amd64/iso-cd/SHA256SUMS`).
- Manual partitioning per the table above: `sdX1` as ESP, `sdX2..4` as
RAID1 members (three arrays), `sdX5` left untouched.
- Tasks: **SSH server + standard utilities only**. No desktop — there is
no GPU and the console is a BMC framebuffer.
- Sources: `main contrib non-free-firmware` (`contrib` is required by
`zfs-dkms`; the installer does not offer it — add it after first boot).
Confirm d-i picked the right bootloader once installed:
`dpkg -l | grep grub-efi` — `grub-efi-amd64`, not `grub-pc`.
### ⚠️ Trap: installer boot mode decides the bootloader (hit 2026-08-30)
`debian-installer` chooses `grub-pc` or `grub-efi-amd64` from **how the
installer itself booted**, not from what the disks look like. Booting the
USB stick in legacy mode while the firmware prefers UEFI produces:
1. d-i installs `grub-pc`, targeting the MBR;
2. on a **GPT** disk that needs a 1 MB `bios_grub` partition — absent
here, so `grub-install` fails, easy to click past;
3. the firmware then tries the SSDs as UEFI targets, finds no `.efi`
binary, and drops to the **UEFI shell**.
Nothing is corrupt; the halves simply disagree. Symptoms and checks:
```sh
[ -d /sys/firmware/efi ] && echo UEFI || echo legacy # in the installer
lsblk -no PTTYPE,PARTTYPENAME /dev/sdX # gpt + "EFI System"?
dd if=/dev/sdX bs=440 count=1 2>/dev/null | od -c | head -3 # all \0 = no boot code
dpkg -l | grep -E '^ii.*grub-(pc|efi)' # which flavour got installed
```
The board's hybrid ISO offers both paths, so the F11 boot menu usually
lists the stick twice — picking the **`UEFI:`** entry avoids the whole
thing. Checking `/sys/firmware/efi` before partitioning is the one step
that prevents it.
### Second ESP — do this before trusting the mirror
The installer populates only the ESP it mounted. Until the second one is
written, losing that disk means the box does not boot, mirror or no
mirror.
⚠️ **Use `/dev/disk/by-id/`, never `/dev/sdX`.** Reconnecting the 4 TB
after the install shifts every letter — observed 2026-08-30: the HDD on
SATA2-0 takes `sda` even with the SSDs on SATA3, because the SATA2
controller enumerates first on this SoC. A bare `/dev/sdb1` written
during the install then points at a *different disk*, and here that
would mean reformatting the ESP the system actually boots from.
```sh
ESP2=/dev/disk/by-id/ata-TOSHIBA_Q300._36OB318OK1KU-part1 # the one NOT at /boot/efi
mkfs.vfat -F32 "$ESP2"
mkdir -p /boot/efi2 && mount "$ESP2" /boot/efi2
grub-install --target=x86_64-efi --efi-directory=/boot/efi2 \
--bootloader-id=debian-b --recheck
efibootmgr -v # expect: debian, debian-b
echo "UUID=$(blkid -s UUID -o value $ESP2) /boot/efi2 vfat umask=0077 0 1" >> /etc/fstab
mount -a && findmnt /boot/efi2
```
Re-run the `grub-install` after any GRUB or kernel change — the second
ESP is **not** kept in sync automatically. **Verify by pulling one disk
and booting**; the checklist item exists because an untested mirror is a
guess, and this is the component most likely to be silently wrong.
Do not fix the letter ordering by moving cables: the only arrangement
that makes the SSDs `sda`/`sdb` puts them on SATA2 and the HDD on SATA3,
which caps the only devices that can use 6 Gb/s and gives the bandwidth
to a disk that tops out near 180 MB/s.
## 4. Base system
```sh
apt update && apt full-upgrade -y
apt install -y \
linux-headers-amd64 zfs-dkms zfsutils-linux zfs-zed \
mdadm smartmontools nfs-kernel-server \
msmtp msmtp-mta bsd-mailx \
curl vim htop ripgrep sysstat dmidecode pciutils usbutils \
stress-ng fio
```
Static network — `/etc/network/interfaces` (ifupdown, matching nuc):
```
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
allow-hotplug enp0s20f0
iface enp0s20f0 inet static
address 192.168.0.4
netmask 255.255.255.0
gateway 192.168.0.2
dns-nameservers 1.1.1.1 9.9.9.9
```
(Gateway is **`192.168.0.2`** — `.1` is gone since 2026-08. The host
uses public resolvers, never blocky, to avoid a bootstrap loop.
Interface name is a guess until the board is up — check `ip -br link`.)
Restore `/root/.ssh/authorized_keys` (incl. `id_rsa_claude.pub`) and
`timedatectl set-timezone Europe/Paris`.
## 5. Pools
### 5a. `incus` — SSD mirror
```sh
zpool create -o ashift=12 \
-O compression=zstd -O atime=off -O xattr=sa -O acltype=posixacl \
incus mirror \
/dev/disk/by-id/ata-INTEL_SSDSC2CT120A3_CVMP250400ES120BGN-part5 \
/dev/disk/by-id/ata-TOSHIBA_Q300._36OB318OK1KU-part5
zpool set autotrim=on incus
```
Weekly scrubs come from the packaged systemd timers rather than cron:
```sh
systemctl enable --now zfs-scrub-weekly@tank.timer zfs-scrub-weekly@incus.timer
systemctl list-timers 'zfs-scrub*'
```
`autotrim` matters on ten-year-old NAND — it is what keeps the
unallocated 22 % actually available to the controller as spare.
### 5b. `tank` — move the 4 TB off USB onto SATA
The risky step. The pool holds `usb4t/backup/nuc` (nuc's replicas),
`usb4t/backup/ks4` (empty, awaiting the FTTH seed) and `usb4t/media`.
⚠️ **Between export and import, nuc has no replica target and Jellyfin
has no media.** Plan a maintenance window and disable nuc's 03:30 cron
first, so it fails loudly rather than half-running.
```sh
# --- on nuc, first ---
zpool scrub usb4t # start clean; wait for it
zpool status usb4t
zpool export usb4t
```
Move the disk to SATA2-0 (reconnect it now if you unplugged it for the
install), then:
```sh
# --- on nas ---
zpool import # confirm it is seen
zpool import usb4t tank # rename: it is not USB any more
zpool set cachefile=/etc/zfs/zpool.cache tank
zpool status tank
```
Properties survive from creation (`ashift=12`, `compression=zstd`,
`atime=off`, `xattr=sa`, `acltype=posixacl`). Re-point the mountpoints
and add the two new backup datasets:
```sh
zfs set mountpoint=/export/media tank/media # NFSv4 export root (§8)
zfs set mountpoint=none tank/backup
zfs list -o name,used,avail,mountpoint
```
Resulting layout:
```
incus mirror, 2× SSD — nas's own container roots
tank 4 TB, single vdev
├── tank/media → /export/media NFS ro → nuc; local device → transmission-bt
└── tank/backup
├── tank/backup/nuc → incus pool `nucbackup` (nuc pushes here)
└── tank/backup/ks4 → incus pool `ks4backup` (nas pulls from ks4)
nas's own replicas live on **nuc** (`data/backup/nas` → pool `nasbackup`),
not here — see §9.
```
Backup pools are named after the **source** host, matching
[ks4/incus-copy.md](../ks4/incus-copy.md). nas's *own* instances are not
backed up here — they cross-replicate to nuc (§9b), so neither host's
instances depend on that host surviving.
Once `tank` has run a week on SATA with **zero** pool suspensions and no
CRC errors, the `usb4t-dropouts` gate is cleared — record that in
[ks2/plan.md](../ks2/plan.md).
## 6. Incus
Same Zabbly stable repo as nuc and ks4:
```sh
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
```
macvlan like nuc, so instances get real LAN addresses — `transmission-bt`
keeps `192.168.0.7` when it moves:
```sh
cat <<EOF | incus admin init --preseed
config:
core.https_address: :8443
storage_pools:
- name: incus
driver: zfs
config:
source: incus
networks:
- name: macvlan
type: macvlan
config:
parent: enp0s20f0
profiles:
- name: default
devices:
eth0: {name: eth0, network: macvlan, type: nic}
root: {path: /, pool: incus, type: disk}
EOF
incus profile set default snapshots.schedule="0 3 * * *" snapshots.expiry=7d
```
Same macvlan quirk as nuc: **the host cannot talk to its own instances**,
and vice versa. Test container services from another LAN host or from
inside the container, never from `nas`.
Backup pools and the replica project:
```sh
incus storage create nucbackup zfs source=tank/backup/nuc
incus storage create ks4backup zfs source=tank/backup/ks4
incus project create backup -c features.images=false -c features.profiles=false
```
⚠️ **`tank/backup/nuc` already contains nuc's replicas** — they came
across with the pool. Re-register them so refreshes stay
ZFS-incremental instead of re-sending everything (the homeassistant VM
alone is a 50 GiB volume):
```sh
incus admin recover # point it at pool nucbackup; project backup
incus list --project backup
```
Same call nuc-install.md §5 uses after a rebuild. If `recover` is
skipped, the first push in §9a silently becomes a full re-send of every
nuc instance.
Order incus after the ZFS mounts, as on nuc —
`/etc/systemd/system/incus.service.d/after-zfs.conf`:
```ini
[Unit]
After=zfs-mount.service zfs.target
```
## 7. Move `transmission-bt` from nuc
Its WireGuard tunnel is **entirely inside the container** (`wg0`,
`10.8.0.21`, `wg-quick@wg0`, `BindsTo=` on the daemon), so the container
carries its own keys and **ks4 needs no change at all** — the peer stays
`10.8.0.21/32`. The kill-switch `/32` route points at the gateway
`192.168.0.2`, which is the same from here.
```sh
# on nuc — remote already added in §9a
incus stop transmission-bt
incus move transmission-bt nas: --storage incus
```
Then on nas, re-point the media device at the local dataset — this is a
plain `shift=true` device again, because the data is local ZFS:
```sh
incus config device remove transmission-bt media
incus config device add transmission-bt media disk \
source=/export/media path=/media shift=true
incus config set transmission-bt boot.autostart=true
incus start transmission-bt
```
Verify the tunnel and the kill switch before trusting it:
```sh
incus exec transmission-bt -- wg show
incus exec transmission-bt -- curl -s ifconfig.me # must print 193.70.35.17
incus exec transmission-bt -- ip route # must have NO default route
```
⚠️ **The watch-folder workflow moves with it** —
[transmission-bt.md](transmission-bt.md) says
`scp some.torrent root@192.168.0.3:/srv/media/.watchdir/`; it is now
`root@192.168.0.4:/export/media/.watchdir/`.
## 8. Media over NFS — `jellyfin-server` stays on nuc
`jellyfin-server` needs the Alder Lake-N iGPU for QSV/VAAPI; the C2750
has no render device at all, and software transcoding on Silvermont
manages 12 concurrent 1080p H.264 streams at best. So the container
stays on nuc and reaches the library over NFS, **read-only** —
`transmission-bt` is the only writer and it now lives here.
```sh
# --- nas: NFSv4 export, read-only, nuc only ---
cat >> /etc/exports <<'EOF'
/export 192.168.0.3(ro,fsid=0,crossmnt,no_subtree_check)
/export/media 192.168.0.3(ro,no_subtree_check,all_squash,anonuid=65534,anongid=65534)
EOF
exportfs -ra && exportfs -v
```
```sh
# --- nuc: mount at the SAME path, so jellyfin-server.md still applies ---
mkdir -p /srv/media
echo '192.168.0.4:/media /srv/media nfs4 ro,_netdev,soft,timeo=100,retrans=3 0 0' >> /etc/fstab
mount /srv/media && ls /srv/media
```
The container device changes only in losing the shift — per
[nuc/jellyfin-server.md](../nuc/jellyfin-server.md)'s own troubleshooting
note, *"on CIFS files are world-readable synthetic ownership, enough for
a read-only library"*; the same holds for NFS with `all_squash`:
```sh
incus stop jellyfin-server # shift cannot be hot-applied
incus config device set jellyfin-server media shift=false
incus config device set jellyfin-server media readonly=true
incus start jellyfin-server
incus exec jellyfin-server -- ls /media # must list the library
```
Requirements for that to work: media files must be **world-readable**
(`find /export/media -type f ! -perm -o=r`), and transmission must keep
creating them that way (it does — `umask`/`0775` per its doc).
⚠️ Boot ordering on nuc: incus is already ordered after
`zfs-mount.service`; add `remote-fs.target` to that drop-in, or
`jellyfin-server` starts against an empty mountpoint and shows an empty
library.
⚠️ `soft` mount is deliberate: a hung NAS should fail Jellyfin's reads,
not wedge nuc's processes in uninterruptible sleep the way the suspended
`usb4t` pool did.
## 9. Backup legs
The driver is unchanged —
[`incus-copy.sh`](https://git.lutran.fr/julien/scripts/src/branch/main/incus-copy.sh),
deployed to `/root/scripts` as everywhere else.
### 9a. nuc's instances -> nas (systemd timer, not cron)
⚠️ **nuc is an on-demand media box** (see [nuc/README.md](../nuc/README.md)):
since 2026-08-30 it only runs when watching Jellyfin or using the Spotify
kiosk, so it is usually powered off at 03:30. Cron cannot catch up a
missed window; a **systemd timer with `Persistent=true`** runs the job
shortly after the next boot. That is the whole reason this is a timer.
`/etc/systemd/system/incus-copy.service` on nuc:
```ini
[Unit]
Description=Replicate all nuc instances to nas (project backup, pool nucbackup)
After=network-online.target incus.service
Wants=network-online.target
Requires=incus.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c "/root/scripts/incus-copy.sh -d nas -m push -s nucbackup -p backup >> /var/log/incus-copy.log 2>&1"
```
`/etc/systemd/system/incus-copy.timer`:
```ini
[Timer]
OnCalendar=03:30
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
```
```sh
systemctl enable --now incus-copy.timer && systemctl list-timers incus-copy
```
⚠️ **`-p backup` is not optional.** Without it the replicas land in
`default` on nas and collide with nas's *live* instances — both hosts are
on the same macvlan LAN and the replicas carry the same static IPs.
### 9b. nas's own instances -> local pool `nasbackup`
**Decision 2026-08-30: local, not cross-replicated.** nuc is powered off
most of the time, so it is not a usable backup target — a nightly push to
it would fail noisily and, once mail works, alarm every morning. The
replicas instead go to `tank/backup/nas`, which is a **different pool**
from the instances themselves (`incus`, the SSD mirror), so it survives
losing that mirror. All three instances here are rebuildable from their
docs, so same-host is proportionate.
```sh
zfs create tank/backup/nas
zfs set mountpoint=legacy tank/backup/nas # see the trap below
incus storage create nasbackup zfs source=tank/backup/nas
```
```cron
# nas, /etc/cron.d/incus-copy
0 4 * * * root /root/scripts/incus-copy.sh -p backup -s nasbackup >> /var/log/incus-copy.log 2>&1
```
⚠️ **Trap: `incus storage create` hangs forever on a `mountpoint=none`
dataset.** `tank/backup` is set to `mountpoint=none`, so any child
created afterwards inherits it, and the pool create then blocks with no
error and no entry in `incus operation list` — it looks exactly like I/O
contention (a scrub was running, which sent me down that path for 20
minutes). Set the child to `legacy` to match its siblings first, and it
completes instantly.
### 9c. Cleanup on nuc after the pool move
Exporting `usb4t` leaves nuc with an incus storage pool whose backing
dataset is gone, plus replica records in the `backup` project pointing
at it. Remove them once §6's `incus admin recover` has re-registered the
same volumes on nas — **verify there first, then delete here**:
```sh
# on nas: confirm the replicas are registered
incus list --project backup -c ns -f csv
# on nuc: only then
incus delete --project backup --force <each-replica>
incus storage delete nucbackup
zpool status # only `data` should remain
```
### 9d. ks4 pull leg moves from nuc to nas
The leg [ks2/nas-seed.md](../ks2/nas-seed.md) prepared. The target pool
moves, so the **host** WireGuard tunnel moves too — nuc drops `wg-ks4`
entirely once this works (its only other tunnel user, `transmission-bt`,
is now here and carries its own).
- **ks4**: add nas as a peer on the `wireguard` container —
`wg set wg0 peer <nas-pubkey> allowed-ips 10.8.0.22/32 && wg-quick save wg0`.
The existing ufw rule (`allow in on incusbr0 from 192.168.1.18 to any
port 8443 proto tcp`) already covers it: traffic arrives masqueraded as
the WG container whichever peer sent it.
- **nas**: `/etc/wireguard/wg-ks4.conf` modelled on nuc's —
`Address = 10.8.0.22/32`, peer pubkey `TVs6d7…`,
`Endpoint = 193.70.35.17:51845`,
`AllowedIPs = 10.8.0.0/24, 192.168.1.1/32`, keepalive 25 — then
`systemctl enable --now wg-quick@wg-ks4`.
- `incus remote add ks4 https://192.168.1.1:8443 --accept-certificate --token '…'`
(cross-check the fingerprint against the token).
- **nuc, after verification**: `systemctl disable --now wg-quick@wg-ks4`,
remove `/etc/wireguard/wg-ks4.conf`, and drop the `10.8.0.20/32` peer
on ks4.
Seed manually in tmux — the first pass is **~1.75 TiB** over the home
downlink and only makes sense once FTTH is up:
```sh
/root/scripts/incus-copy.sh -r ks4 -s ks4backup 2>&1 | tee -a /var/log/incus-copy-ks4.log
```
Then test-restore one instance before ticking the gate in
[ks2/plan.md](../ks2/plan.md).
### Resulting schedule
| When | Host | What |
|---|---|---|
| 03:00 | nuc, nas | instance snapshots (profile) |
| 03:30 | nuc | push all instances → `nas:nucbackup` (**systemd timer**, `Persistent=true` — catches up after boot) |
| 04:00 | nas | local copy of nas instances → `nasbackup` (`tank/backup/nas`) |
| 05:00 | nas | pull `ks4:*` → `ks4backup` (after FTTH) |
| 05:30 | nuc | apt upgrade all containers |
| Mon ~00:12 | nas | `zfs-scrub-weekly@tank.timer` / `@incus.timer` (systemd, not cron) |
Staggered around ks4's own 01:00 / 05:00 jobs.
⚠️ Keep `snapshots.schedule` on every source. A refresh with no common
snapshot degrades to a **full re-send** — the failure mode that cost
933 G on ks4 ([ks4/local-backup-cron.md](../ks4/local-backup-cron.md)).
The homeassistant **VM** on nuc re-sends its whole volume without them.
Note `incus-copy.sh` takes a **global** `flock`: an overrunning ks4 pull
aborts that night's other run loudly rather than racing it.
## 10. Monitoring
Do not repeat nuc's 11-day blind spot
([usb4t-dropouts.md](../nuc/usb4t-dropouts.md) — zed was running, but the
host had no MTA).
**Status: live since 2026-08-30**, verified end to end (`smtpstatus=250`).
- **msmtp**, same shape as nuc: this box shares the dynamic home IP with
no PTR and no SPF alignment, so it must use **submission (587) with
auth**, not port 25 — rspamd rejects the port-25 path as spam.
`/etc/msmtprc` mode 600, host `mail.lutran.fr`, STARTTLS. Give SMTP
tests ≥ 30 s: the missing PTR delays the greeting.
The `zed@lutran.fr` account is the **same credential as nuc** — it is
SMTP-AUTH, not IP-bound, so `/etc/msmtprc` can simply be copied between
hosts (mode 600, root:root). The password lives in the password
manager; no host-specific setup is needed.
- **zed**: `/etc/zfs/zed.d/zed.rc` mode 600 with
`ZED_EMAIL_ADDR="julien@lutran.fr"`, `ZED_EMAIL_PROG="mail"`,
`ZED_NOTIFY_VERBOSE=1`, **`ZED_NOTIFY_DATA=1`**,
`ZED_NOTIFY_INTERVAL_SECS=3600`.
- **[`zpool-health.sh`](https://git.lutran.fr/julien/scripts/src/branch/main/zpool-health.sh)**
every 15 min via `/etc/cron.d/zpool-health` — zed does *not* report a
suspended pool (the vdev stays `ONLINE`, so `statechange-notify.sh`
never fires). That watchdog is the only thing that catches the exact
failure this box was built to prevent.
- **smartd** — `/etc/smartd.conf`. The Toshiba carries the useful
attributes; the Intel is a liveness check only:
```
# Toshiba Q300 — endurance + temperature are real here
/dev/disk/by-id/ata-TOSHIBA_Q300._36OB318OK1KU -a -o on -S on -s (S/../.././02|L/../../6/03) -W 4,50,55 -m julien@lutran.fr
# Intel 330 — no error log, no devstat, no temperature: liveness only
/dev/disk/by-id/ata-INTEL_SSDSC2CT120A3_CVMP250400ES120BGN -H -s (S/../.././03|L/../../6/04) -m julien@lutran.fr
# WD Red
/dev/disk/by-id/ata-WDC_WD40EFRX-68WT0N0_WD-WCC4E6NLPJJE -a -o on -S on -s (S/../.././04|L/../../6/05) -W 4,45,50 -m julien@lutran.fr
```
Verify the whole chain the day you build it, not the day you need it:
```sh
/root/scripts/zpool-health.sh -m julien@lutran.fr -t
tail -2 /var/log/msmtp.log # expect smtpstatus=250
smartctl -d sat -l devstat /dev/sdX | grep -i endurance
```
## 11. Power baseline
Take the measurement **before** the box goes into service, so later
readings mean something. Expect roughly 2535 W idle with three disks:
the 20 W SoC plus a BMC drawing several watts continuously, even at
soft-off.
```sh
# baseline: 10 min idle, everything settled
# CPU in steps — the interesting curve for an always-on box
for l in 25 50 75 100; do echo "=== ${l}% $(date +%s)"; stress-ng --cpu 0 --cpu-load $l --timeout 120s; done
# disk: random I/O is what moves an HDD's power, not throughput
fio --name=rr --directory=/export/media --size=20G --rw=randread --bs=4k \
--iodepth=32 --numjobs=4 --ioengine=libaio --direct=1 --runtime=300 --time_based
```
Measure at the wall (PDU or smart plug) — RAPL is unreliable on Avoton
and sees neither disks nor fans. Log epoch timestamps per step so the
trace can be recut against the meter's series afterwards.
## 12. Post-install checklist
- [x] IPMI **left unplugged by decision** (2026-08-30) — no BMC on the
LAN, therefore no out-of-band console either
- [ ] `cat /proc/mdstat` — all three arrays `[UU]`; GRUB written to **both**
ESPs (`efibootmgr -v` shows two entries),
and boot tested with one disk unplugged
- [ ] `zpool status` healthy for `incus` and `tank`; weekly scrubs
scheduled; both import cleanly after a reboot (`cachefile` set)
- [ ] **7 days with zero pool suspensions and zero CRC errors** — the
gate that closes [usb4t-dropouts.md](../nuc/usb4t-dropouts.md)
- [x] `smartd` monitoring all 3 disks; `zpool-health.sh -t` mail
delivered (`smtpstatus=250` in `/var/log/msmtp.log`) — done
2026-08-30
- [ ] `transmission-bt` on nas: egress is `193.70.35.17`, **no default
route**, downloads land in `/export/media/downloads`, watch folder
works from the new path
- [ ] `jellyfin-server` on nuc lists the library over NFS after a **cold
reboot of both hosts** (the boot-ordering trap)
- [ ] `incus admin recover` ran on nas **before** the first push, so the
inherited `tank/backup/nuc` replicas refresh incrementally instead
of re-sending (check the first run's duration, not just `rc=0`)
- [ ] nuc's 03:30 leg → `nas:nucbackup`, `rc=0`, all nuc instances
present (`incus list --project backup -c ns -f csv` on nas)
- [ ] nas's 04:00 local leg → `nasbackup`, `rc=0`, and **blocky,
privoxy and transmission-bt all appear by name** in
`incus list --project backup`
- [ ] stale `nucbackup` pool removed from nuc (§9c), `zpool status`
shows only `data`
- [ ] replicas are **stopped** with `boot.autostart=false` — they hold
the live containers' LAN addresses
- [ ] ks4 pull leg seeded + one instance test-restored → tick the gate
in [ks2/plan.md](../ks2/plan.md); then retire nuc's `wg-ks4`
- [ ] power baseline recorded above, with the meter reading
- [ ] this file updated with what was built (RAM fitted, NIC name, WD Red
serial)
@@ -1,6 +1,13 @@
# transmission-bt
BitTorrent client in an unprivileged Incus container on `nuc`, with an
> Moved from nuc to `nas` on 2026-08-30, together with the media
> dataset ([nas-install.md](nas-install.md) §7). Its WireGuard tunnel is
> entirely in-container, so ks4 needed no change — the peer is still
> `10.8.0.21`. The watch folder moved with it:
> `/export/media/.watchdir` on nas, not `/srv/media/.watchdir` on nuc.
BitTorrent client in an unprivileged Incus container on `nas`, with an
**always-on VPN**: all peer traffic exits via ks4's public IP through a
WireGuard tunnel to the `wireguard` container on ks4. Kill switch by
construction — `eth0` has **no default route**, so with the tunnel down
@@ -12,10 +19,10 @@ the container simply has no path to the internet.
whitelist (`192.168.0.*` only)
- Egress: WG peer `10.8.0.21``193.70.35.17:51845`, `AllowedIPs 0.0.0.0/0`
(verified: `curl ifconfig.me` from the container returns ks4's IP)
- Downloads: `/media/downloads` (= `usb4t/media`, same dataset Jellyfin
- Downloads: `/media/downloads` (= `tank/media`, same dataset Jellyfin
reads); in-progress files in `/media/.incomplete` so Jellyfin never
scans partials
- Watch folder: `scp` a `.torrent` into `/srv/media/.watchdir` on the host
- Watch folder: `scp` a `.torrent` into `/export/media/.watchdir` on the host
and it auto-downloads (see "Watch folder" below)
- `transmission-daemon` is `BindsTo=wg-quick@wg0.service` and binds
peer traffic to `10.8.0.21` — three independent layers against leaks
@@ -90,7 +97,7 @@ EOF'
incus exec "$CNAME" -- systemctl enable --now wg-quick@wg0
# media share (same dataset as jellyfin-server)
incus config device add "$CNAME" media disk source=/srv/media path=/media shift=true
incus config device add "$CNAME" media disk source=/export/media path=/media shift=true
incus exec "$CNAME" -- mkdir -p /media/downloads /media/.incomplete
incus exec "$CNAME" -- chown debian-transmission:debian-transmission \
/media/downloads /media/.incomplete
@@ -129,9 +136,9 @@ incus exec wireguard -- wg-quick save wg0
## Watch folder (auto-add torrents)
Drop a `.torrent` into `/srv/media/.watchdir` on the host and Transmission
Drop a `.torrent` into `/export/media/.watchdir` on the host and Transmission
auto-adds it and starts downloading — no web UI needed. The folder lives on
the shared `usb4t/media` dataset (`/media/.watchdir` inside the container).
the shared `tank/media` dataset (`/media/.watchdir` inside the container).
Edit the **active** config only while the daemon is stopped (it rewrites
`settings.json` on exit). The active file is
@@ -161,7 +168,7 @@ incus exec transmission-bt -- systemctl start transmission-daemon
Usage — the `.torrent` is consumed within a few seconds:
```sh
scp some.torrent root@192.168.0.3:/srv/media/.watchdir/
scp some.torrent root@192.168.0.4:/export/media/.watchdir/
```
- `watch-dir-force-generic: true` makes Transmission **poll** the folder
@@ -179,7 +186,7 @@ incus exec transmission-bt -- wg show wg0 latest-handshakes # non-zero timesta
incus exec transmission-bt -- curl -s https://ifconfig.me # must print 193.70.35.17
incus exec transmission-bt -- bash -c "ping -c1 -W2 8.8.8.8 || echo kill-switch OK" # with wg0 down
# web UI must be tested from a LAN machine — the macvlan quirk means the
# nuc host itself cannot reach 192.168.0.7
# nas host itself cannot reach 192.168.0.7 (macvlan, by design)
```
## Notes
+32 -18
View File
@@ -1,31 +1,45 @@
# Homelab: nuc
Incus host on the LAN.
Incus host on the LAN**on-demand**: since 2026-08-30 it only needs to
run when watching Jellyfin or using the Spotify Connect kiosk. Everything
always-on (blocky/DNS, privoxy, transmission-bt) moved to
[`nas`](../nas/README.md), which is why nuc can now be powered off.
⚠️ Powering nuc off has backup consequences — its 03:30 replication to
nas only runs while it is up. See [nas-install.md](../nas/nas-install.md) §9.
- Debian 13, Intel Alder Lake-N (iGPU `i915`, shared by both Jellyfin containers) —
bare-metal reinstall: [nuc-install.md](nuc-install.md)
- Instances are bridged onto the LAN (192.168.0.0/24)
- USB 4 TB WD Red: ZFS pool `usb4t``usb4t/backup``/backup`
(incus exports, `nuc/` + `ks4/` subdatasets, 1 TB quota) and
`usb4t/media``/srv/media` (media library, shared into containers
via `shift=true` disk devices; works because ZFS ≥ 2.2 supports
idmapped mounts)
- ⚠️ The USB enclosure drops off the bus periodically and suspends the
pool, which silently breaks the nightly replication — symptoms,
recovery and fixes: [usb4t-dropouts.md](usb4t-dropouts.md)
- Backups: all local instances replicated to the USB pool
(`/root/scripts/incus-copy.sh -p backup -s nucbackup`; replicas
stopped, autostart off) — see
[nuc-install.md](nuc-install.md); ks4 replicas pulled into
pool `ks4backup` — see [ks4/incus-copy.md](../ks4/incus-copy.md)
- Storage: ZFS pool `data` on the SSD (all instance disks). The USB
4 TB and its pool **left nuc on 2026-08-30** — the enclosure's JMicron
bridge was suspending the pool ([usb4t-dropouts.md](usb4t-dropouts.md));
the disk now sits on direct SATA in [`nas`](../nas/nas-install.md) as
pool `tank`.
- Media library: `/srv/media` is now an **NFSv4 mount from nas**
(`192.168.0.4:/media`, read-only). `jellyfin-server` stays here for
the iGPU and reads it with `shift=false`
([jellyfin-server.md](jellyfin-server.md)).
- Backups: nuc and nas **cross-replicate** — nuc pushes all its
instances to `nas:nucbackup` at 03:30, nas pushes its own to
`nuc:nasbackup` at 04:00, so neither host's instances depend on that
host surviving ([nas/nas-install.md](../nas/nas-install.md) §9).
The ks4 pull leg moved to nas as well
([ks2/nas-seed.md](../ks2/nas-seed.md)), so nuc no longer runs a
WireGuard tunnel.
- ⚠️ **Never reboot nuc with the display powered on** — the i915 probe
dies and takes every container with it (including LAN DNS). Symptoms
and fix: [jellyfin-client.md](jellyfin-client.md#-never-boot-nuc-with-the-display-active-2026-08-30)
## Instances
| Name | IP | Doc | Features |
|---|---|---|---|
| [jellyfin-server](jellyfin-server.md) | 192.168.0.5 | ✅ | unprivileged, autostart; iGPU render node (`gpu` device, render gid) for QSV/VAAPI transcoding; `/srv/media` disk device (`shift=true`); proxy device → host :8096 |
| [jellyfin-server](jellyfin-server.md) | 192.168.0.5 | ✅ | unprivileged, autostart; iGPU render node (`gpu` device, render gid) for QSV/VAAPI transcoding; `/srv/media` disk device (NFS from nas, `shift=false`, read-only); proxy device → host :8096 |
| [jellyfin-client](jellyfin-client.md) | 192.168.0.6 | ✅ | **privileged**, autostart; full iGPU (`gpu` device, gid 44) → HDMI kiosk (cage + Jellyfin Media Player); custom `raw.lxc` (bind `/dev/snd`, `/dev/input`, host `/run/udev`); Pioneer USB audio as ALSA default; FR keymap; go-librespot Spotify Connect ("Pioneer A-70") |
| [transmission-bt](transmission-bt.md) | 192.168.0.7 | ✅ | unprivileged, autostart; always-on WireGuard full tunnel → ks4 (egress = 193.70.35.17, kill switch: no default route); `/srv/media` disk device (`shift=true`), downloads to `/media/downloads`; web UI :9091 (LAN only) |
| blocky | 192.168.0.254 | — | unprivileged, autostart; DNS ad-blocker |
| privoxy | 192.168.0.11 | — | unprivileged, autostart; filtering HTTP proxy |
| homeassistant | (stopped) | — | **virtual machine**, 50 GiB root disk on pool `data` |
Moved to [`nas`](../nas/README.md) on 2026-08-30: `transmission-bt`
(next to the media dataset it writes to), plus `blocky` and `privoxy`
so LAN DNS and the proxy survive nuc being shut down.
+145 -1
View File
@@ -116,6 +116,15 @@ EOF'
# --- Kiosk user + seat management ----------------------------------------------
incus exec "$CNAME" -- bash -c 'id kiosk >/dev/null 2>&1 || useradd -m -G video,render,input,audio kiosk'
# ⚠️ Group names are NOT enough. The host (Debian) and the container
# (Ubuntu) allocate dynamic system gids independently, so the container's
# `input` group does not necessarily have the same gid as the group that
# owns /dev/input/* on the host. Bind the kiosk user to the *numeric*
# host gid, whatever it is called inside:
HOST_INPUT_GID="$(stat -c %g /dev/input/event0)"
incus exec "$CNAME" -- usermod -aG "$HOST_INPUT_GID" kiosk
incus exec "$CNAME" -- id kiosk # must list $HOST_INPUT_GID
# In a container seatd must NOT bind the seat to a VT (there is no usable VT;
# it would try to open the host's active tty and hang the compositor forever).
incus exec "$CNAME" -- mkdir -p /etc/systemd/system/seatd.service.d
@@ -245,10 +254,17 @@ it works regardless of which USB port the dongle lands on.
`/etc/udev/rules.d/99-jellyfin-kiosk-recover.rules`:
```
# Logitech Unifying receiver (re)plugged -> recover the jellyfin-client kiosk
# A Logitech receiver was (re)plugged -> recover the jellyfin-client kiosk.
# c52b = Unifying receiver (K400); c539 = Lightspeed receiver (G603).
ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="046d", ATTR{idProduct}=="c52b", RUN+="/usr/bin/systemctl --no-block start jellyfin-kiosk-recover.service"
ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="046d", ATTR{idProduct}=="c539", RUN+="/usr/bin/systemctl --no-block start jellyfin-kiosk-recover.service"
```
⚠️ The match is per product ID, so **a receiver not listed here will not
auto-recover** — the kiosk must be restarted by hand after plugging it in
(`incus exec jellyfin-client -- systemctl restart jellyfin-kiosk`). Add
the new id here when introducing different input hardware.
`/etc/systemd/system/jellyfin-kiosk-recover.service` — oneshot, so a burst of
udev events during one plug merges into a single restart (natural debounce):
@@ -285,6 +301,134 @@ udevadm control --reload-rules && systemctl daemon-reload
journalctl -t jellyfin-kiosk-recover -f
```
## ⚠️ Never boot nuc with the display active (2026-08-30)
**Symptom:** after a reboot, *no* Incus container starts. `incus list`
answers but `incus start <anything>` hangs forever, `systemctl status
incus` sits in `activating (start-post)`, and LAN DNS is down because
blocky never came up. Nothing in the incus logs explains it.
**Cause — nothing to do with incus.** If the TV/projector is connected
**and powered on** when nuc boots, firmware hands i915 an already-lit
pipe. The driver's state readback then trips a series of warnings and
the probe never completes:
```
drm_WARN_ON(!pll_active) intel_ddi.c:4019 intel_ddi_get_clock
drm_WARN_ON(p0 == 0 || p1 == 0 || p2 == 0) intel_dpll_mgr.c:2878
drm_WARN_ON(pixel_rate == 0) skl_watermark.c:1729
```
(all inside `intel_modeset_setup_hw_state``intel_display_driver_probe_nogem`)
The cascade:
1. i915 probe dies → **`/dev/dri` never appears** (no GPU at all)
2. `snd_hda_intel` waits forever for i915's audio component →
permanent **deferred probe** holding the PCI device lock on `0000:00:1f.3`
3. incusd reads that device's `sriov_numvfs` while enumerating
resources → blocks in **D state** → the daemon never signals ready,
so nothing autostarts and every `incus start` hangs
**Reproduced identically on 6.12.107 and 6.12.105** — it is the display
path, not a kernel regression. Do not waste time pinning kernels.
**Diagnosis, in order:**
```sh
ls /dev/dri/ # empty = i915 probe failed
cat /sys/kernel/debug/devices_deferred # snd_hda_intel entry = the deadlock
ps -eLo pid,tid,stat,wchan:26,comm | awk '$3 ~ /D/' # incusd in sriov_numvfs_show
dmesg -T | grep -E 'drm_WARN_ON|deferred probe pending'
```
**Fix:** disconnect HDMI (or power the display fully off — not standby),
reboot, then **hotplug the cable back in**. Connecting after boot goes
through normal connector detection instead of firmware state readback
and works fine.
In-place recovery is *not* possible: `modprobe -r i915` fails (module in
use by the wedged probe) and `modprobe i915` times out. A reboot is the
only way out.
**After hotplugging, restart the kiosk**`cage` started with zero
outputs and will not pick the display up on its own:
```sh
incus exec jellyfin-client -- systemctl restart jellyfin-kiosk
cat /sys/class/drm/card0-HDMI-A-2/status # expect: connected
```
Note the HDA controller this wedges is only used for **HDMI audio**,
which this setup does not use — audio goes to the Pioneer USB DAC via
`/etc/asound.conf`. It is pure collateral damage, but it takes the whole
host down with it.
## Input gid mismatch — latent, fix it anyway (2026-08-30)
> ⚠️ **This was not the cause of the 2026-08-30 outage.** That turned out
> to be a flat/switched-off K400 — a G603 on the same port and the same
> `event0` worked immediately. The mismatch below is real and worth
> correcting, but with `LIBSEAT_BACKEND=seatd` it is **seatd (running as
> root) that opens input devices** and passes the fd to cage, so the
> kiosk user's group membership is not on the critical path for input.
> Fix it for the direct-open fallback path, not as a debugging lead.
>
> **Before suspecting software, prove the hardware emits anything:**
> ```sh
> timeout 60 cat /dev/input/eventN | wc -c # press keys; 0 bytes = nothing reached the kernel
> ```
> That one check would have saved an hour.
>
> **Dead K400 batteries are invisible from the host.** This K400 exposes
> no `hidpp_battery_*` node under `/sys/class/power_supply/`, so charge
> cannot be read. Worse, the receiver still lists the keyboard as a paired
> peer (`0003:046D:4024.*` under the `C52B` receiver) whether or not it is
> awake, and `/dev/input/event0` plus a `Logitech K400` entry in
> `/proc/bus/input/devices` are present either way — so every software
> check looks perfectly healthy. **Zero bytes from the raw capture is the
> only signal.** Swapping in a different receiver on the same port is the
> quickest A/B confirmation.
**Symptom (if it ever does bite):** kiosk renders but input does nothing,
with no errors — `WLR_LIBINPUT_NO_DEVICES=1` keeps cage alive rather than
failing loudly.
**Cause:** `/dev/input/*` is `crw-rw---- root:<host input gid>`. The
install script adds `kiosk` to the group *named* `input` inside the
container, but Debian (host) and Ubuntu (container) allocate dynamic
system gids independently:
```
host /dev/input/event0 gid 996 -> "input"
container "input" group gid 995 <- kiosk was here
container gid 996 -> "systemd-timesync"
```
So the kiosk user was in the wrong group and could not open any input
device. The udev side was fine — check it first to rule it out:
`cat /run/udev/data/c13:64` should show `E:ID_INPUT=1` etc.
**Diagnose:**
```sh
stat -c '%n %a %u:%g' /dev/input/event0 # host gid
incus exec jellyfin-client -- id kiosk # does it include that gid?
# open() test — do NOT use `head`/`cat`, reading an event device blocks
# with no pending events and looks like a permission failure:
incus exec jellyfin-client -- su -s /bin/bash kiosk -c 'exec 3< /dev/input/event0 && echo OPEN_OK'
```
**Fix** (persists in the container's `/etc/group`):
```sh
HOST_INPUT_GID="$(stat -c %g /dev/input/event0)"
incus exec jellyfin-client -- usermod -aG "$HOST_INPUT_GID" kiosk
incus exec jellyfin-client -- systemctl restart jellyfin-kiosk
```
Re-check this after any host reinstall — the host's `input` gid is
dynamically allocated and can come back different.
## Troubleshooting
```sh
+41 -2
View File
@@ -5,8 +5,10 @@ Jellyfin media **server** in an unprivileged Incus container on `nuc`.
- Image: `images:ubuntu/24.04`, Jellyfin from the official repo (repo.jellyfin.org)
- IP: `192.168.0.5` (LAN bridge) — web UI/API on `http://192.168.0.5:8096`
- iGPU render node (`/dev/dri/renderD128`) passed for QSV/VAAPI hardware transcoding
- Media library: host `/srv/media` (ZFS dataset `usb4t/media`, USB 4 TB)
mounted at `/media` with `shift=true` (needs ZFS ≥ 2.2 for idmapped mounts)
- Media library: host `/srv/media` — since 2026-08-30 an **NFSv4 mount
from `nas`** (`192.168.0.4:/media`, dataset `tank/media`), mounted at
`/media` in the container with **`shift=false`** and `readonly=true`.
See [nas/nas-install.md](../nas/nas-install.md) §8.
- Port 8096 additionally proxied to the host address (`web` proxy device)
## Install script
@@ -77,6 +79,43 @@ incus restart "$CNAME"
echo "Done. Open http://<host-ip>:8096 to run the setup wizard."
```
## Media over NFS (2026-08-30)
The library moved to `nas` when the 4 TB left nuc's USB enclosure. The
container keeps the same path, so everything below still applies — only
the mount underneath `/srv/media` changed.
```sh
# nuc host: /etc/fstab
192.168.0.4:/media /srv/media nfs4 ro,_netdev,soft,timeo=100,retrans=3 0 0
```
`shift=true` **cannot** be used: idmapped mounts are not supported on
NFS (nor CIFS). Per the troubleshooting note below, dropping the shift is
enough for a read-only library — the export uses `all_squash` so files
carry synthetic world-readable ownership:
```sh
incus stop jellyfin-server # shift cannot be hot-applied
incus config device set jellyfin-server media shift=false
incus config device set jellyfin-server media readonly=true
incus start jellyfin-server
```
⚠️ Two traps:
- **Boot ordering.** Add `remote-fs.target` to nuc's
`/etc/systemd/system/incus.service.d/after-zfs.conf`, or the container
starts against an empty mountpoint and Jellyfin shows an empty library
(and may prune the library metadata).
- **`soft` is deliberate.** A hung nas should fail Jellyfin's reads, not
wedge nuc's processes in uninterruptible sleep the way the suspended
`usb4t` pool did ([usb4t-dropouts.md](usb4t-dropouts.md)).
`transmission-bt` is no longer on nuc — it moved to nas and writes to
the dataset locally ([nas/transmission-bt.md](../nas/transmission-bt.md)),
so nuc's mount is read-only and there is exactly one writer.
## First-run configuration
1. Run the setup wizard; add libraries pointing at `/media/...`.
+7
View File
@@ -265,6 +265,13 @@ Both logs rotate monthly (`/etc/logrotate.d/incus-*`).
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)
- [ ] ⚠️ **Reboot with the TV/projector disconnected or powered off.**
Booting with the display active kills the i915 probe, which wedges
`snd_hda_intel` in a deferred probe, which blocks incusd in
`sriov_numvfs_show` — **no container starts at all, including
blocky/DNS**. Hotplug the cable back after boot, then
`incus exec jellyfin-client -- systemctl restart jellyfin-kiosk`.
Full diagnosis: [jellyfin-client.md](jellyfin-client.md)
- [ ] 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