Posted on ::

I used to run my servers the “classic” way: a CoreOS-style minimal base plus Ansible playbooks to configure services on top. It worked, but it had the usual problem — the git repo described intentions, while the actual state of the server was whatever the last playbook run left behind, plus whatever I SSH’d in and hacked at 2 AM and forgot about.

Then I joined the Nix cult. This post is a semi-guide to how my nix-servers repo is set up and what it replaced. Not comprehensive — just the interesting parts.

What’s a flake, in human language?

A flake is just a git repo with a flake.nix file that declares:

  1. inputs — pinned dependencies (like a package-lock.json, but for entire operating systems, libraries, and even other git repos),
  2. outputs — things it produces: packages, server configs, dev shells.

That’s it. The magic is in the pinning: flake.lock freezes every input to an exact commit, so “works on my machine” becomes “works identically on every machine, forever”. Clone the repo in 5 years, build, get bit-identical result.

My setup

The whole server — a Hetzner dedicated box with 2× NVMe in ZFS mirror — is one flake:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-parts.url = "github:hercules-ci/flake-parts";
    disko.url = "github:nix-community/disko";        # declarative disk partitioning
    deploy-rs.url = "github:serokell/deploy-rs";      # deployment tool

    # my apps, pulled in as flake inputs — infra just consumes them
    blog.url = "github:jozefRudy/blog";
    bizweb = {
      url = "github:jozefRudy/bizweb";
      flake = false; # plain static site, not a flake
    };
  };
  # ...
}

Note the last two inputs: my own apps are inputs to the server flake. The blog you’re reading is a git dependency of the server that hosts it. Updating production = nix flake update blog && just deploy.

The entire OS is a module list

The host definition reads like a shopping list. Each module is a plain .nix file in the repo:

flake.nixosConfigurations.fsn1 = inputs.nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = with config.flake.nixosModules; [
    disko-zfs-mirror          # disk layout
    hardware-hetzner-dedicated
    base                      # sshd, firewall, nix gc
    caddy                     # reverse proxy
    alerting                  # telegram alerts
    app_bizweb
    app_blog
    { networking.hostName = "fsn1"; }
  ];
};

This is the key difference from my old core-is + Ansible duo: the git repo is the source of truth for the entire server, hardware-wise and service-wise. And it starts from zero — with CoreOS we needed Butane/Ignition as a separate tool just to describe the initial machine, whereas Nix describes even the hardware itself: disk partitioning, bootloader, filesystems, all the way down. No second tool, no gap between “provisioned” and “configured”. Nothing exists on the machine that isn’t in the repo. If a new person (or future me) looks at the repo, they see everything — disk layout, firewall rules, services, cron, users. There’s no “oh, someone manually installed fail2ban in 2023 and nobody documented it”.

Compare with the old way:

# ansible/tasks/mystery.yml — is this still applied? who knows
- name: install stuff
  apt:
    name: ["fail2ban", "htop", "something-else"]
  # pray the playbook is idempotent and nobody touched the box since

With Ansible, playbooks mutate a live system. With NixOS, the config is the system — just deploy builds the full OS closure and switches to it atomically. The new generation either activates fully or not at all; if a deploy fails halfway (or the machine won’t boot the new config), it falls back to the last known-good generation — no half-broken state, no risk of locking yourself out of a remote box. Rollback = boot the previous generation from the GRUB menu.

The entire server is type-checked

This one is fundamental, and only becomes obvious in practice. NixOS’s module system defines every option with a type — services.nginx.enable is a boolean, networking.firewall.allowedTCPPorts is a list of ports — and evaluation fails before anything deploys if you pass the wrong thing, misspell an option, or produce a config that doesn’t merge. nix flake check type-checks the whole machine, offline, no server involved.

JSON/YAML-declared tools like Ansible can’t do this. There’s no schema over the whole system, so a typo’d variable or a wrong type surfaces at runtime, mid-playbook, against a live box. And it’s not a stylistic difference: Nix configs are written in a real purely functional language — with types, functions, and abstraction — while Ansible is YAML with templating bolted on. You can’t retrofit a type system onto that.

ZFS and zero-copy backups

Disks are declared with disko: two NVMe drives, GPT, ESP on both (either disk can boot), rest goes to a ZFS mirror called rpool. Fresh bare-metal install from my Mac is one command:

nix run .#install-fsn1   # wraps nixos-anywhere: partitions disks, installs everything

Why ZFS? Snapshots are zero-copy — taking a snapshot costs nothing, it’s just a pointer. So “backups” become trivially declarable in Nix:

services.sanoid = {
  enable = true;
  datasets."rpool/postgres" = {
    hourly = 24;
    daily = 7;
    autosnap = true;
    autoprune = true;
  };
};

A few lines and every dataset gets automatic snapshotting with retention policy — no cron jobs, no pg_dump scripts rotting in /root. Offsite replicas are the same story: syncoid/zrepl to a Hetzner Storage Box, declared in the repo like everything else.

Alerting: stupidly simple, and tested

My favorite part. Alerting on a self-managed server is usually a hack — a script installed somewhere, with no record of how it got there or how it’s wired up. Here it’s a module in the repo: a small Python script that tails the systemd journal and sends batched error digests to Telegram:

flake.nixosModules.alerting = {pkgs, ...}: {
  systemd.services.journal-tg-watch = {
    description = "journal error watcher -> telegram";
    wantedBy = ["multi-user.target"];
    serviceConfig = {
      ExecStart = pkgs.lib.getExe self.packages.${system}.journal-tg-watch;
      Restart = "always";
    };
  };
};

That’s the whole service. But here’s the kicker — it has an actual test, running as a flake check. The test stubs journalctl and curl, feeds fake errors through the script, and asserts the exact Telegram API call that would be made:

checks.alerting = pkgs.runCommand "journal-tg-watch-test" {} ''
  WINDOW=1 MIN=2 CURL=${stubCurl} JOURNALCTL=${stubJournalctl} \
    timeout 5 ${pkgs.lib.getExe script} || true
  diff -u ${expected} "$CALLS"
  touch $out
'';

just test → alerting logic verified, on every change, without a server. Try doing that with a bash script someone SCP’d to the box.

Apps: flakes welcome, docker not banned

The easy path is an app shipped as its own flake: app repo owns the build (packages.x86_64-linux.default), infra repo owns the runtime — caddy vhost, systemd unit, postgres database, redis instance. My caddy blog module is boring:

services.caddy.virtualHosts.${config.domains.blog}.extraConfig = ''
  root * ${inputs.blog.packages.x86_64-linux.default}
  file_server
  encode zstd gzip
'';

But nothing stops you from dockerizing a service anyway — NixOS has virtualisation.oci-containers, so the odd app that insists on a container is still declared in the same repo, next to everything else. Nix doesn’t force purity; it just makes purity convenient.

Where it goes next: colmena

Right now it’s one host, deployed with deploy-rs. The design scales naturally: define more hosts as attrset entries, and fleet management is one tool away — colmena can take the same flake and deploy to N machines in parallel with tagged groups (colmena apply --on @workers). That’s also the path to cloud: the same module list describes an EC2/Hetzner Cloud instance, so the “fleet” is just more entries in the flake. But YAGNI — I’ll switch when I actually have a fleet.

Bonus: Nix on macOS, goodbye brew

The same trick works on the desktop. I’m writing this on a Mac where Nix + flakes replaced Homebrew — just home-manager from a single flake.nix in my dotfiles repo. Every CLI tool pinned in flake.lock, exact versions, one command to apply:

home-manager switch --flake dotfiles#jozefrudy@darwin

Home-manager makes setup easy on macOS. Fonts — no downloading .ttf files or struggling with fonts:

home.packages = with pkgs; [
  fira-code
  source-code-pro
  nerd-fonts.fira-code
];
fonts.fontconfig.enable = true;

And background daemons — home-manager emits launchd agents declaratively. A small side-project API of mine runs as a persistent service, no plist XML ever touched by hand:

launchd.agents.myapp = {
  enable = true;
  config = {
    Label = "com.myapp.serve";
    ProgramArguments = [
      "${config.home.profileDirectory}/bin/myapp"
      "serve" "--port" "17381"
    ];
    RunAtLoad = true;
    KeepAlive = true;
    StandardOutPath = "~/Library/Application Support/myapp/myapp.log";
  };
};

Fish shell config, plugins, abbreviations, env vars — same story, all in the repo. Brew is fine; this is just strictly better. It also helps that nixpkgs is the largest package repository of any Linux distribution — over 118,000 packages (per Repology), more than Arch or Debian. Obscure tool you need is almost certainly already packaged.

Verdict

Is Nix worth the learning curve? The language is slightly weird, but LLMs flatten the curve — and I’d argue fully declarative config is actually the ideal medium for LLM agents: no hidden state to reason about, everything visible in text, changes verifiable by evaluating the config. It just makes sense for them. And the end state — one git repo that is my infrastructure, testable, reproducible, deployable to bare metal in one command — is something my Ansible setup never gave me.


Written with LLM assistance; config snippets are real and really deployed.