September 2026 update: since April, two of my three machines have moved to NixOS. I’ve kept the original text and added what changed. The details are under “The big job”, further down.
Why self-host?
I’ve never been comfortable with the idea of handing everything over to Google or to some cloud. My data, my services, my configs, I’d rather they stayed at home. Beyond the control, building a homelab is mostly an excuse to learn. Networking, Docker, Linux in depth, firewalls. That kind of thing you only really get by banging your head against it. And then there’s the plain pleasure of tinkering with something until it works exactly the way you want.
The hardware
Nothing fancy. A Dell Optiplex 3050 picked up cheap (i3-7100T, 16 GB of RAM). It’s quiet, it sips power, and it runs 24/7 without complaining. Perfect for a home server. In the config repo, this machine is called optiflex.
A second one has joined since, an HP MicroServer Gen8 doing NAS duty, gen8 for short. Four SAS bays hooked up to a 9211-8i HBA in IT mode, three drives in there set up as ZFS RAIDZ1. The pool is called tank, created in early July 2026. The system runs off a small Kingston SSD tucked into the optical bay, and the machine has been at 16 GB of RAM since the July upgrade. Most of the storage has moved over there. The Optiplex mounts the media library over the local network with NFS.
The network architecture
The heart of the setup is Headscale, an open source implementation of Tailscale’s control plane. It gives me a mesh VPN across all my devices (desktop, laptop, phone, tablet) without exposing anything to the internet. I picked Headscale over Tailscale because I wanted to keep control of the control plane, with no dependency on a third-party service.
Headscale runs in a container on the Optiplex. Which means the tailnet’s control plane goes down along with that machine. That detail matters later on.
The server acts as the exit node for the whole network. DNS is handled by Technitium, which gives me full control over name resolution and filtering. For the firewall I use nftables with per-subnet restrictions on SSH access. The sukiyaki.fr site runs on native IPv6, configured through systemd-networkd with a static IPv6 address and firewall rules on the BBox.
# systemd-networkd, static IPv6
# (real address replaced with the 2001:db8::/32 documentation prefix)
[Network]
Address=2001:db8:1234:5678::1/64
Gateway=fe80::1
DNS=2001:db8:1234:5678::1
The services
Around twenty Docker containers run permanently. Here are the main ones.
Authentik: SSO / Identity Provider
Authentik is my central identity provider. Every service that supports it authenticates through OIDC. That includes Headscale itself. When I connect a new device to the VPN, the auth goes through Authentik. One account, one password, permissions managed in one place. It’s the kind of thing that looks like overkill for a homelab, but once it’s in place everything is so much cleaner.
Forgejo: self-hosted Git
Forgejo rather than Gitea or GitLab, because it’s light, community-run, and it does exactly what I need. All my private repos live there, and this site’s CI/CD runs on Forgejo Actions, compatible with the GitHub Actions format, so no new syntax to learn.
Jellyfin: Media server
Jellyfin for media streaming, paired with Samba for local access to the NAS. It replaces any need for Plex or Netflix for the personal library. Reachable from any device on the VPN.
Firefly III: personal finances
Firefly III to track my finances, with automatic sync through GoCardless to pull the transactions from my bank (BoursoBank). It’s not perfect (the sync lags sometimes), but it gives me a clear view of where my money goes without handing my banking data to a third-party app.
Ollama: remote AI inference
The setup I’m proudest of. Ollama runs on my CachyOS desktop with an RX 7900 XTX, reachable remotely over Headscale. The machine is off by default. When I need inference I wake it with Wake-on-LAN through UpSnap, use whatever model I need, and it shuts itself back down. All the power of a current GPU without it running 24/7.
The rest
- Technitium for local DNS, filtering and custom resolution
- UpSnap, Wake-on-LAN for the machines on the network
- Nextcloud (files, calendars and contacts), which moved to the NAS
- Vaultwarden, the password vault
- Nginx Proxy Manager as reverse proxy, with TLS termination on 80/443
- Dockge for driving the compose stacks from a web interface
- Beszel for light monitoring, one agent per server and the hub on the NAS
- Homarr, the homepage that pulls it all together
- Navidrome and slskd, the music corner
- A Postfix relay. No machine sends mail itself, everything goes through it
Security
Rule one is that nothing is exposed to the internet that doesn’t need to be. Headscale means every service is only reachable over the VPN. The disk is encrypted with LUKS. Boot is secured with rEFInd, Secure Boot and custom keys, not the default Microsoft ones. nftables restricts SSH access to a few subnets only. And Authentik centralises authentication with OIDC wherever possible.
The big job: going declarative with NixOS
Why
The problem with a machine you administer by hand is that after a while nobody really knows what’s on it. A firewall rule added one evening. A file in /etc tweaked to unblock something. A service enabled and forgotten. Six months later the machine runs, but the docs lie.
NixOS answers that fairly radically. The system configuration is a file, and the system is nothing but the result of that file. If it isn’t written in there, it stops existing at the next rebuild. The history of the infrastructure becomes a git log, and when an update breaks something you reboot into the previous generation.
Hence a nix-dotfiles repo: 39 commits between July 5 and August 17, 2026, three machines in it.
One clarification up front, because it comes back constantly below. NixOS doesn’t replace Docker here. It describes the system layer (disks, network, users, firewall, systemd units, backups). The compose stacks stay data, not Nix config.
One flake, three machines
- gen8, the NAS, on the stable channel
- nixpad, the ThinkPad T480s running Libreboot, on unstable. It’s a lab machine, it’s allowed to break
- optiflex, the homelab Optiplex, stable too, because it runs identity, the proxy and the mail, and I want no surprises on it
What’s common is factored into shared modules all three import: the Nix settings and the base CLI kit, the locale (English interface, French formats, Europe/Paris), and the Tailscale client already pointed at my Headscale.
# hosts/gen8/default.nix
imports = [
./hardware.nix
../../modules/nixos/base.nix
../../modules/nixos/locale.nix
../../modules/nixos/tailscale.nix
];
The disk layout goes through disko, so partitioning is declarative too, and the laptop’s user environment through home-manager.
Day to day it comes down to two commands:
# apply the config
sudo nixos-rebuild switch --flake .#gen8
# go back to the previous generation if it broke
sudo nixos-rebuild switch --rollback
A homemade install ISO
The flake also builds its own install image. It ships sshd with my keys from boot, NetworkManager so I can install over wifi, disko, and a copy of the flake. One ISO, any of the machines, including the one you’re reinstalling precisely because you broke it.
A detail that cost me an evening. Upstream’s minimal ISO doesn’t enable flakes. Until you add the option, disko and nixos-install --flake both fail on it.
gen8: the NAS, in production
It was the first machine moved to NixOS, in early July, and it was the right testing ground, because nothing critical depended on it yet.
Everything that makes a NAS is declared in there. The ZFS pool and its monthly scrub. The SMART self-tests, deliberately offset so they never land at the same time as the scrub. The sanoid snapshots on the dataset holding the Nextcloud data, not on the media library, which is re-downloadable content and doesn’t deserve to have its blocks kept around. Nextcloud’s Postgres database is dumped to the pool every night at 03:30, just before the 04:00 snapshot, because it lives on the system SSD that ZFS doesn’t protect. And Samba shares the whole thing, while an NFS export reserved for the Optiplex serves the Docker binds for Jellyfin and friends.
All of it sends mail: SMART, ZFS errors, scrub results. I left verbose mode on deliberately. The end-of-scrub mail every month doubles as a heartbeat. With a system that never says anything, you can’t tell whether it’s fine or whether it’s gone mute.
nixpad: the laptop
The T480s runs Libreboot, so no UEFI. Legacy BIOS, with the firmware’s GRUB chaining the disk’s GRUB. disko handles the GPT, the 1 MiB EF02 partition that forces, and LUKS. Fun constraint: the passphrase has to be typeable on US QWERTY, because the GRUB prompt loads no custom keymap.
The fingerprint reader was the biggest piece of work. It’s a Validity sensor that libfprint doesn’t support upstream. You have to go through a community flake, pinned to its own stable release because that’s the only combination its author tests. Two backends: a first one for bootstrapping, which pairs the sensor, uploads the firmware to it and produces the calibration data, then a second one that picks that data up and finally lets you use a standard fprintd, so PAM that works without hacks.
Libreboot also comes with an unexpected bonus. Vendor BIOSes have locked the undervolt MSR since Plundervolt, it doesn’t. In exchange, the thinkpad_acpi module no longer loads by itself and has to be forced to get fan control and the battery charge thresholds back.
optiflex: the machine that hasn’t moved yet
And this is where the story gets less tidy.
The optiflex config is written. It dates from August 11, a faithful port of the Arch machine surveyed line by line: network, users with pinned uids and gids so restored files land on their feet, mounts, Samba, SMART, outgoing mail, Docker daemon settings, backup units. The public IPv6, which used to be a hand-written systemd service, fits in three declarative lines. It evaluates. It has never booted. The machine still runs Arch.
Two reasons for that.
The first is that this machine is the homelab. Headscale runs on it, so my remote access goes down with it. So does Authentik, so does the reverse proxy holding 80/443, the DNS, the mail relay, Forgejo. You don’t reinstall that on a Sunday evening just to see what happens.
The second is the config / data split. NixOS rebuilds the system layer with one command, but it doesn’t recreate /opt/stacks with its compose files and its .envs, or the named Docker volumes (there are more than eighty of them), or the reverse proxy’s certificates, or Tailscale’s state, or the Headscale ACL. All of that is a restore. And a restore needs preparing.
So it’s staged, in this order:
- Backups first. Since mid-August, the Optiplex pushes its irreplaceable state to the NAS every night at 03:15: Vaultwarden, Headscale, the proxy’s config and certificates, Forgejo, the compose file and the
.envof every stack. The key on the NAS side is locked inside anrrsync. It can write to that one directory and strictly nothing else. Versioning is gen8’s ZFS snapshots, and restores are just normal files. If the job fails I get a mail, sent withcurland notsendmail. A mail that says “Docker is broken” can’t go out through a relay running in Docker. - Installing on a second disk. NixOS goes on the M.2, with its own EFI partition. The Arch SSD stays intact next to it. The firmware boot order decides which of the two systems comes up, so rolling back costs one reboot.
- Bringing the stacks back up, in order: DNS, then the reverse proxy, then Headscale, then the mail relay, then Authentik, then the rest. With the traps that come with it. Two external Docker networks to create by hand before starting anything, and a third one definitely not, because the compose file owns that one.
Two machines out of three, then. And the one that counts is still left.
What I learned
This homelab has taught me more than any course ever did. Networking in depth (IPv6, VPN, firewall, DNS) is the kind of thing you only really understand by configuring it yourself. Same for Docker. Running twenty-odd containers under real conditions, with real networking between them, is a different world from the “hello-world” tutorial. I also learned to document my configs, because in six months you won’t remember why you added that particular nftables rule. And above all, I’ve debugged real problems: ROCm refusing to see the GPU, Docker networking on Arch not behaving like it does on Ubuntu, the kind of thing Google doesn’t solve in two clicks.
The move to NixOS added its own layer of lessons.
The most useful one has nothing to do with Nix. It’s the survey. To port the Arch machine I had to read it line by line, and that’s where you find everything that had been changed live and never written down anywhere. Writing a declarative config is mostly an excuse to rediscover your own machine.
Two traps cost me dearly:
- Docker’s published ports don’t go through the firewall. Docker does its DNAT in
nat/PREROUTING, the packet then goes toFORWARDthrough Docker’s own chains and never touchesINPUT. So an ordinary firewall rule has no effect on it. You have to filter inDOCKER-USER, and match the original destination port through conntrack, because by that point the DNAT has already rewritten the address. - A snapshot that didn’t exist. The nixpkgs sanoid module runs under
DynamicUserand delegates the ZFS permissions to itself in anExecStartPreprefixed with a-. If the delegation fails, the error is swallowed: the service exits 0 without having created a single snapshot. Clean run, exit 0, zero backups. A backup that reports success without doing anything is worse than no backup at all. Since then I check the artifact (the snapshot list), never the return code.
And a third one, nastier, on the Nix side itself: a flake’s github: references resolve through the GitHub API, which rate-limits my IP. When that happens, nix says nothing and silently falls back to a cached revision. My inputs stayed stuck on a May revision despite repeated nix flake updates. Everything has been on git+https since.
What’s next
The priority is the optiflex cutover. The rest waits behind it.
On the monitoring side there’s now a Beszel agent on both servers, with the hub on the NAS. That at least gives an overview. A proper Prometheus + Grafana is still on the list.
The other direction is making gen8 the always-on node and letting the Optiplex sleep by default, woken on demand with Wake-on-LAN, exactly like the desktop with Ollama. Wake-on-LAN is already declared in its NixOS config, precisely so it survives every rebuild. That first means finishing the move of the services that have to stay reachable at all times: the dashboard you use to wake the machine can’t depend on the machine it wakes.
And one day, maybe, the MacBook through nix-darwin. The desktop stays out of the repo: not everything needs to be declarative.