Nix Fleet, part 4. The series so far: the one-flake architecture, remote builders, and fleet-wide secrets. This part is where it all gets pointed at bare metal — a 5-bay ARM NAS, installed declaratively and reproducibly.
The Kobol Helios64 is a 5-bay NAS built on a Rockchip RK3399 (aarch64, 4–8 GB RAM). It's a lovely, slightly cantankerous little ARM board, and it's the storage backbone of my homelab. The goal for this host was the same as every other machine in the fleet: I should be able to reinstall it from the flake and get an identical result, disks and all. No clicking through an installer, no hand-partitioning, no "what did I configure last time."
That's what disko and nixos-anywhere make possible.
The two tools
- disko turns disk layout into declarative Nix. Instead of running
fdiskandmkfsby hand, you describe the partitions, filesystems, and pools you want, and disko makes the disks match. Partitioning becomes config you can review, version, and re-apply. - nixos-anywhere installs a NixOS flake onto a remote machine over SSH — it can kexec into an installer, run disko to lay out the disks, and install the full system, all from my workstation.
Together they collapse "install a NAS" into one command:
nix run github:numtide/nixos-anywhere -- --flake .#helios64 <ssh-host>
That partitions the drives per the disko spec, installs the helios64 host config from my flake, and reboots into a running NixOS. The disk layout is no longer a one-time manual act I'd have to remember — it's part of the host definition.
The ARM-specific bits
An ARM SBC NAS has a few wrinkles a generic x86 box doesn't, and the host config names them explicitly:
# hosts/nixos/helios64.nix
{
networking.hostName = "helios64";
# The RK3399 needs its device tree to boot correctly
hardware.deviceTree.enable = true;
hardware.deviceTree.name = "rockchip/rk3399-kobol-helios64.dtb";
services.openssh.enable = true;
system.stateVersion = "24.11";
}
The device tree is the load-bearing line — without rk3399-kobol-helios64.dtb, the board doesn't come up right. The boot chain itself is U-Boot/Tow-Boot on SD/eMMC with the NixOS root on SATA. And because this is aarch64-linux, the system image builds on my x86 box via the emulation I set up in Part 2 — I never need a native ARM build host just for the NAS.
A note on honesty: the committed host config is intentionally minimal right now (it even evaluates as a container in CI to bypass boot/filesystem asserts). Storage and services are phased work. So treat this post as the design and install method I'm executing against, not a claim that every service below is already live. The series documents the build as it happens.
Storage: ZFS, and why
The storage decision got its own ADR (I keep architecture decision records in the repo — docs/adr/0001-choose-zfs-for-helios64.md, accepted 2025-08-27). The short version:
Decision: ZFS. End-to-end checksums, snapshots, send/receive, and self-healing are worth a lot for data I actually care about, and NixOS's ZFS integration is mature. The cost is memory pressure on a low-RAM ARM board and tighter kernel/module version coupling. The alternative considered — mdraid + ext4/btrfs — is simpler and lighter but has weaker integrity guarantees. For a NAS, integrity wins.
The pool config reflects the board's constraints:
boot.supportedFilesystems = [ "zfs" ];
services.zfs = {
autoScrub.enable = true;
trim.enable = true;
};
# On a 4 GB board, cap the ARC so ZFS doesn't starve everything else:
# boot.kernelParams = [ "zfs.zfs_arc_max=1073741824" ]; # 1 GiB
Sensible defaults for the pool (tank): ashift=12, compression=zstd, autotrim=on, atime=off for bulk data, and a topology chosen by disk count — mirror for 2 disks, RAIDZ1 for 3–5, RAIDZ2 when I want to survive two failures. Datasets split by purpose: tank/data/shares for SMB, tank/data/media for streaming, tank/backups for restic targets (with credentials supplied by agenix — that's the payoff of Part 3).
The gotchas this board taught me
Real hardware has opinions, and the runbook records them so I don't relearn them at 2am:
- The 2.5GbE port can be flaky on some Helios64 units. The mitigation is unglamorous: prefer the 1GbE port, lock the link speed at the switch, and capture
dmesgfor 24h before trusting it. There's an ADR for that decision too (0002-networking-1g-vs-2_5g.md). - ZFS ARC will eat a 4 GB board alive if you let it. Cap
zfs_arc_maxto 1–2 GiB and re-evaluate after watching real workload for a week. - RAIDZ1 + power events is a risk. A NAS without tested backups isn't a backup. The drive-replacement playbook (
zpool offline→ swap →zpool replace→ watch the resilver) lives in the design doc, and restore tests are scheduled quarterly.
Why the ceremony is the point
It would be faster, once, to flash an SD card and click through a setup. But "once" is the trap. The value of the disko + nixos-anywhere + flake approach is the second time — when a disk dies, or I want to rebuild on fresh drives, or I'm reproducing the NAS to test an upgrade. Then the disk layout, the ZFS topology, the device tree, the services, and the secrets are all declared, and the rebuild is a command, not an archaeology project.
That's the whole thesis of this series, taken to its hardest case. Part 1 claimed one flake could configure every machine — laptop, server, and NAS — reproducibly. The NAS is the machine where reproducibility is least convenient and most valuable, and it's the one that proves the model. Same flake, same discipline, all the way down to the spinning rust.
The host config and ADRs referenced here live in parallaxisjones/dotfiles.
— Parker Jones, parkerjones.dev