I've been running nginx on a box I call VDO for years now - it's the reverse proxy in front of a handful of self-hosted services: VDO.ninja, a Palworld server, Vaultwarden, Leantime, and a few others that have come and gone. Like most homelab nginx setups, it grew the way these things always grow: a sites-enabled/ file added here, a Certbot cron job there, a proxy_pass tweaked at 11pm to fix something that broke. It worked. It also wasn't something I could hand to a future version of myself, let alone anyone else, and reconstruct with any confidence.
So I decided to rebuild it on NixOS, where the whole nginx + TLS setup lives in one declarative configuration.nix instead of a pile of hand-edited files and whatever Certbot happened to do to them over the years. This is the story of that migration - what worked, what broke, and what I found hiding in the config that I didn't expect.
Starting point: auditing what's actually there
Before touching anything, I pulled nginx -T off VDO to get the full, resolved configuration - every included file, flattened. That turned out to be the right first move, because it surfaced two things I would have otherwise just carried forward blindly:
- A whole
stream {}block proxying Palworld's UDP port 8211 through nginx to the game server. - A default vhost that silently fell through to my Leantime instance whenever a request didn't match any
server_name- not something I'd ever consciously decided, just a side effect of vhost ordering.
Cross-referencing that against my router's port-forwarding table killed the first one immediately: Verizon Fios was port-forwarding Palworld's UDP 8211 straight to the game server's LAN IP, bypassing nginx entirely. The stream{} block had been dead code the whole time - nginx was never actually in that path. I also found a stray port-8080 forward pointing at VDO that didn't correspond to anything in the nginx config at all. A quick ss -tlnp on VDO confirmed nothing was even listening on it. Dead forward, dropped.
Small thing, but worth saying out loud: if you're about to rebuild something from scratch, audit what's actually running before you re-implement what you think is running. I would have faithfully ported over a UDP stream proxy that hadn't done anything in who knows how long.
While I was in there, I also killed the "default vhost falls through to Leantime" behavior and gave Leantime its own real server_name. The new default is a hard return 444 - connection just dies if nothing matches.
The plan: NixOS LXC, built alongside, cut over when ready
Rather than touch the live box, I built the replacement as a brand-new Proxmox LXC running NixOS, using nixos-generators to produce a proxmox-lxc-format template. Everything got built and tested side-by-side with the existing VDO nginx, with DNS still pointed at the old box the whole time. Certs for the new box came via Let's Encrypt DNS-01 challenges against Cloudflare (which already manages my DNS), specifically so the new box didn't need to be internet-facing yet to get real, working certificates. That let me curl --resolve against it and validate every vhost before DNS ever moved.
Where I got stuck (because I will absolutely forget this otherwise)
Nix's flake features aren't on by default. First run of nix run github:nix-community/nixos-generators failed with experimental Nix feature 'nix-command' is disabled. Flakes need both nix-command and flakes enabled - I'd only tried enabling one of them, and I'd also put the flag in the wrong place in the command (after the --, which routes it to the app being run, not to nix itself). Persisting it in ~/.config/nix/nix.conf made the problem go away for good.
A configuration.nix has to actually exist where you point. Obvious in hindsight, but I hit error: file 'nixos-config' was not found in the Nix search path because I hadn't actually put the file at the path I was telling nixos-generators to use yet - I'd mentally filed that as a later step and jumped ahead.
addSSL needs a certificate, even for a vhost whose whole job is dropping connections. My default catch-all vhost errored with services.nginx. virtualHosts._.sslCertificate' was accessed but has no value defined, because telling nginx to also listen on 443 means nginx needs something to present during the TLS handshake, "default" or not. Fix was pointing it at useACMEHost for one of my real certs - the same trick my old hand-written config had been quietly doing all along, I just hadn't carried it forward.
Option names aren't stable across nixpkgs versions, and the error messages are genuinely helpful about it. security.acme.defaults.credentialsFile doesn't exist on the nixpkgs revision I was pinned to - it's environmentFile now. The error output actually suggested the fix directly ("Did you mean ... environmentFile?"), which was a nice surprise after years of cryptic build tool errors elsewhere.
nixos-generators is on its way out. Mid-build I got a deprecation warning - as of NixOS 25.05, this functionality has been folded into nixos-rebuild build-image directly. It still works today and isn't going away suddenly, so I kept going rather than switch tools mid-troubleshoot, but it's worth knowing about if you're starting this today.
First boot has no PATH. Entering the fresh LXC via pct enter and running mkdir got me command not found - the shell exists, the binaries exist in the Nix store, but nothing had wired up PATH yet. export PATH="/run/current-system/sw/bin:$PATH" (or just . /etc/profile) sorted it out.
None of these were hard once I knew what they were. All of them would have been mildly maddening to hit cold.
Sizing the box
For resources, I ended up on 2 vCPUs, 4GB RAM, no swap (disk is on NVMe, so no speed argument for swap - but I bumped RAM up specifically because I dropped swap, since without it there's no cushion if a nixos-rebuild evaluation spikes), and 16GB of disk. That last number isn't about nginx's footprint, which is tiny - it's about giving the Nix store room to hold a few generations before garbage collection kicks in. I added nix.gc to the config to run weekly and prune anything older than 30 days, so that stays bounded going forward instead of creeping.
Where it landed
nixos-rebuild switch on the new box completed clean - no errors, just the expected (and harmless) warning about /boot not existing, which is just NixOS's tooling checking for a bootloader partition that an LXC has no concept of. From there, curl --resolve against every vhost on the new box returned the same content as the equivalent request against the live VDO. Cutover is just a DNS change away at this point.
What I'd tell past-me
- Pull the real, resolved config (
nginx -T, not just the files you remember editing) before you start porting anything. You will find dead config you forgot about. - Cross-reference against what's actually reachable from the outside - your router's port-forwarding table and
ss -tlnpon the box will tell you the truth faster than the app config will. - Build the replacement in parallel and validate with DNS-01 +
--resolvebefore ever touching DNS. There's no reason to take the old thing down to test the new thing. - NixOS's error messages, when they involve a renamed option, will usually just tell you the new name. Read the whole error before reaching for a search engine.