Earlier this year, I wrote about moving my homelab away from Kubernetes. The replacement was Docker Compose, Traefik and Komodo. It worked: Git remained the source of truth, Renovate opened update PRs and Komodo deployed them after merge. The setup caused almost no real incidents and remained fairly clean.
Over time, the weak spot became clearer: I was running a four-container Komodo control plane, including its own database, to manage six stacks on one server. Komodo also did not work particularly well for my secret-management setup (or I never spent the time configuring it well), and managing configuration files was more cumbersome than I expected.
Around mid-year, I had a call with the team at Clan about NixOS for an Ethereum project and decided to take their stack for a test drive. The biggest change since I had tried Nix roughly two years earlier was not Nix itself, but the development of LLMs. I could ask an LLM to write Nix and focus on reviewing the result. Nix is relatively easy to read but a pain to write. That experience made me want to dig deeper and see if it was finally time to give Nix another shot.
NixOS
The easiest way I’ve found to understand NixOS is that its configuration describes the whole machine, not just the applications on it. Packages, users, SSH, Docker, firewall rules, systemd units and directories are all declared in Nix modules. I split those modules by concern, and Nix merges them into one host configuration.
A flake pins the exact nixpkgs revision and exposes each host by name. CI can evaluate and build the same configuration before I merge it. On the host, a failed build leaves the current generation untouched; if a successful activation causes a regression, previous generations make rollback straightforward. I also retain ordinary SSH-based debugging when I need to inspect a machine directly.
There is an important boundary here: the Nix store and system configuration are immutable, but application data is not. Rolling back a generation can restore the previous kernel, packages and units. It cannot undo a PostgreSQL migration or writes made after a deployment.
GitOps with comin
NixOS gave me the declarative machine parts I needed, but I had grown too used to GitOps to give it up. That led me to comin, a small systemd service that polls the repository and rebuilds the host. It restores the GitOps workflow with very little overhead.
The flow is now:
- Open a PR.
- GitHub Actions builds every NixOS host configuration.
- Merge to
main. - comin pulls the commit, builds it and switches to the new generation.
Because comin is pull-based, GitHub does not need network access into my homelab. comin uses a repository-scoped, read-only GitHub token because the repository is private. The first deployment is a manual bootstrap because comin cannot deploy itself before it exists; every later application-host deployment is handled by comin. If a rebuild fails, the active generation stays in place.
I also run a watchdog that compares the deployed revision with origin/main. If they differ for more than 30 minutes, it sends an alert through ntfy. That catches stalled deployments, expired tokens and builds that keep the host on an older generation. My validator hosts remain manually deployed so that client restarts stay operator-controlled, but they use the same Nix modules and CI checks.
Secrets and Updates
The old Komodo configuration had secrets mixed into its stack configuration. Komodo had its own secret store, which I never configured, so NixOS was not the only way to fix this. However, sops-nix made the model much cleaner. I like SOPS because encrypted files can live in Git, while the decryption keys can use several different technologies.
Each host uses an age recipient derived from its SSH host key, while my PGP key remains the recovery key. This pattern is derived from what I use at work, but PGP can be replaced with another SOPS-supported key type. At activation, sops-nix decrypts only the secrets that host needs into /run/secrets, which is backed by memory. Containers consume those files through Compose env_file entries, so secret values never enter the Nix store.
Renovate completes the loop. Every weekend it checks Docker images, flake.lock and pinned GitHub Actions, then opens normal PRs for me to review. The cadence and update rules are fully customizable; I chose weekly to avoid being bombarded with PRs. A minimum release age also delays newly published versions, giving obviously bad or compromised releases more time to be noticed before I adopt them.
Why Docker inside NixOS
Putting Docker inside NixOS might seem odd, but I can explain! I could package each application with Nix and run it as a native service, but that would mean rewriting working Compose stacks. Most projects already publish container images and well-documented Compose examples, so I decided to keep Docker as the application layer.
The boundary is:
- NixOS configures the machine, installs Docker, stores the Compose configuration, exposes runtime secrets and owns the systemd lifecycle.
- Docker packages the application runtime and handles container networking.
Each stack has a small Nix module that wraps docker compose up and down in a systemd unit. The module also declares its writable directories, required secret files, startup health check and failure notification. Traefik runs in its own NixOS-managed Compose stack, separate from the application stacks, with ingress configuration expressed as labels.
It is not conceptually pure, but it gives me the boundary I want: declarative host management without repackaging every application.
Backups and uptime
All persistent application state lives under /opt. Before each nightly Restic snapshot, service-specific database dumps are written to /opt/db-dumps. Restic sends the snapshot to S3-compatible RustFS storage on my NAS. The retention policy keeps 14 daily snapshots and three monthly snapshots.
For monitoring, I try to answer three questions: Is the host up? Is the application up? Is the monitoring up?
To answer these, I use:
- Prometheus and Grafana to collect and display host and container metrics.
- Gatus on another machine to probe each service from the outside.
- healthchecks.io as a dead-man switch, expecting regular heartbeats from each host and backup job.
- ntfy as the notification channel for failed units, stalled deployments and missed heartbeats.
These layers cover different failure modes. A deployment-time health check verifies that containers start. Gatus catches later availability failures from outside the host. healthchecks.io notices when a host or backup stops reporting, even if the local monitoring stack is also down. Prometheus and Grafana provide the detail needed to explain degradation.
Summary
Since trying it out, I’ve migrated my entire homelab to NixOS. I now have one machine running Ethereum-related services, one for homelab applications and one remote machine running publicly accessible services such as this website. They share the same modules and conventions, so defining a new host or service is much easier.
Overall, this setup fits my needs well. I do not need Kubernetes’s scheduler, high-availability model or scalability, and I no longer need the overhead of a separate Komodo control plane. NixOS keeps the machine declarative, comin provides GitOps for the application hosts, and Docker Compose keeps application deployment familiar. For a small number of intentionally chosen machines rather than a cluster of replaceable ones, it is the best of both worlds.