Building a Minimal NixOS Homelab Host

A homelab operating system is not just the thing that happens to boot. It is the recovery plan, the security boundary, and the bit you will swear at later when the box is wedged behind furniture with no keyboard attached.

For a first NixOS host, the job is smaller than the platform people usually rush toward. It should boot, accept key-only SSH, expose a small firewall surface, run fail2ban for routine SSH noise, and rebuild itself from a repository. The service stack can wait until that loop works.

The companion repo here is a stripped-down public example, not a sanitised dump of a live machine. That is deliberate. A first NixOS guide should show the shape of a sound base host, not make readers sort through somebody else’s accumulated weirdness. Before installing it anywhere near hardware you care about, replace the placeholder hostname, admin username, SSH public key, system disk path, and NixOS release version.

What NixOS Changes

NixOS is a Linux distribution where the system is assembled from declarative configuration. Instead of building a server from package installs, hand edits under /etc, and shell history that reads like a small accident report, you express the desired operating system in Nix files and apply it with nixos-rebuild.

That does not make the machine magically reproducible just because the config looks tidy. Firmware, disks, network layout, secrets, and external services still matter. What NixOS does give a homelab operator is a better place to put intent: users, services, packages, firewall rules, SSH policy, and boot settings can live in a repository with reviewable history.

The NixOS project documents flakes as a way to pin inputs and expose named build targets, while the NixOS server documentation covers the distribution as a server platform rather than merely a desktop curiosity. Plenty of public NixOS homelab repos exist. That does not make a first host painless, but the platform is normal enough to treat seriously.

The trade-off is that you have to learn the model. A mutable Debian or Ubuntu server lets you make small changes quickly and remember the consequences later. NixOS asks for more discipline up front and pays it back when you need to rebuild, audit, roll back, or explain why a host behaves the way it does.

Keep The First Host Small

The companion skeleton is deliberately narrow:

suite-despair-nixos-homelab-base/
  .gitignore
  README.md
  flake.lock
  flake.nix
  hosts/
    example-host/
      configuration.nix
      disk-config.nix
      hardware-configuration.nix
  modules/
    base.nix
    ssh-hardening.nix
    fail2ban.nix
  docs/
    validation.md
    limitations.md

The host directory owns machine-specific choices. The modules contain reusable defaults that are worth applying before any application services exist. That split is not a grand architecture; it is enough structure to stop the first server turning into one large file with storage, users, services, firewall policy, and recovery assumptions all mixed together.

This article stops at the base host: one flake target, one disk layout, one admin user, SSH policy, firewall, fail2ban, and a rebuild check. Container runtime selection, application services, ZFS data pools, and monitoring are separate decisions with their own proof work.

Understand The Repo Shape Before You Touch Hardware

For a reader new to NixOS, the file layout matters as much as the individual options. This repo is not a bucket of snippets. It is a small operating-system definition split into files with different responsibilities.

FileWhat it does
README.mdExplains what the repo is, what it does not cover, which placeholders must be replaced, and how to run validation.
.gitignoreKeeps local Nix build outputs such as result out of Git.
flake.nixDefines the repo as a Nix flake, pins the inputs, and exposes one buildable NixOS host called example-host.
flake.lockRecords the exact input revisions Nix resolved. Commit it; do not normally hand-edit it.
hosts/example-host/configuration.nixThe main host file. It imports modules, names the host, declares the admin user, and sets the NixOS release baseline.
hosts/example-host/disk-config.nixThe destructive disk layout used by disko. This is where the system disk placeholder must be replaced.
hosts/example-host/hardware-configuration.nixA placeholder for hardware-specific settings. A real host should use its generated hardware configuration, not this stub.
modules/base.nixShared low-level defaults: flakes, timezone, locale, boot loader, firewall, and a small toolset.
modules/ssh-hardening.nixThe OpenSSH policy: enable SSH, open the firewall, disable password-style login, and block root SSH.
modules/fail2ban.nixThe fail2ban baseline for SSH noise. It is a guardrail, not the whole security model.
docs/validation.mdCommands for checking the flake before install and checking the host after first boot.
docs/limitations.mdThe deliberate boundaries: no containers, no ZFS pool, no secrets model, no monitoring, no production-hardening claim.

The Nix syntax used here is small enough to decode in one pass. Most files are NixOS modules shaped like { ... }: { ... }: a function that returns a set of NixOS options. Paths such as ../../modules/base.nix are just relative imports. The module system has depth, but you do not need it yet.

Define One Host Target

You do not need to become a flake theorist here. The practical idea is simple: the flake gives one named host definition that install and rebuild commands can both target.

The flake exposes a single NixOS configuration called example-host:

{
  description = "Minimal NixOS homelab base example";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
    disko.url = "github:nix-community/disko";
    disko.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs =
    {
      nixpkgs,
      disko,
      ...
    }:
    let
      system = "x86_64-linux";
    in
    {
      formatter.${system} = nixpkgs.legacyPackages.${system}.nixfmt;

      nixosConfigurations.example-host = nixpkgs.lib.nixosSystem {
        inherit system;
        modules = [
          disko.nixosModules.disko
          ./hosts/example-host/disk-config.nix
          ./hosts/example-host/configuration.nix
        ];
      };
    };
}

The important pieces are few. inputs names the external sources the repo depends on: nixpkgs for the NixOS package and module set, and disko for disk layout management. The follows line keeps disko on the same nixpkgs input as the rest of the system, which avoids dragging two different package universes into a beginner example for no useful reason.

The outputs section exposes a formatter and one NixOS system. system = "x86_64-linux" says this example is for a conventional 64-bit Intel or AMD Linux host. nixosConfigurations.example-host is the named build target, and the modules list says which files combine to form that host.

The practical gain is simple: the install command and later rebuild command can both point at the same host definition, .#example-host, instead of relying on terminal archaeology.

The longer build path is more explicit:

.#nixosConfigurations.example-host.config.system.build.toplevel

Read it from left to right: use the current flake, find the NixOS configuration called example-host, and build the final system closure for that host. The shorter .#example-host form is enough most of the time; the longer form is useful when you want to prove exactly which derivation builds.

If your Nix install has flakes disabled by default, the same checks can be run with explicit feature flags:

nix --extra-experimental-features "nix-command flakes" flake check
nix --extra-experimental-features "nix-command flakes" build .#nixosConfigurations.example-host.config.system.build.toplevel

The Nix reference documents nix flake generally and nix flake check specifically. Here the practical point is narrower: if the example host cannot evaluate and build, it has no business being handed a disk.

Use Replaceable Disk Configuration

The example uses disko for a simple UEFI system disk with an ext4 root filesystem:

{ ... }:

{
  disko.devices.disk.system = {
    type = "disk";
    device = "/dev/disk/by-id/REPLACE_WITH_SYSTEM_DISK";

    content = {
      type = "gpt";
      partitions = {
        ESP = {
          size = "1G";
          type = "EF00";
          content = {
            type = "filesystem";
            format = "vfat";
            mountpoint = "/boot";
            mountOptions = [ "umask=0077" ];
          };
        };

        root = {
          size = "100%";
          content = {
            type = "filesystem";
            format = "ext4";
            mountpoint = "/";
          };
        };
      };
    };
  };
}

Before using this, replace /dev/disk/by-id/REPLACE_WITH_SYSTEM_DISK with the correct persistent disk identifier for the target machine. On the target installer shell, list the available persistent names:

ls -l /dev/disk/by-id/

A real value will look more like /dev/disk/by-id/nvme-EXAMPLE_MODEL_EXAMPLE_SERIAL or /dev/disk/by-id/ata-EXAMPLE_MODEL_EXAMPLE_SERIAL than /dev/sda. Match the model and serial to the disk you intend to wipe, then use the full /dev/disk/by-id/... path in disk-config.nix. Declarative disk tools are useful because they remove ambiguity, but they will still erase the disk you name.

This is the system disk only. The base host needs to boot cleanly and be easy to recreate; data-pool architecture can wait.

Read from the top, the layout is straightforward: name the disk, create GPT, carve out an EFI partition for /boot, and give the rest to /. The example uses ext4 because this article is about the host baseline, not a storage pool.

Set The Host Identity And Access Path

The host configuration imports the hardware stub, the base module, SSH policy, and fail2ban:

{ ... }:

{
  imports = [
    ./hardware-configuration.nix
    ../../modules/base.nix
    ../../modules/ssh-hardening.nix
    ../../modules/fail2ban.nix
  ];

  networking.hostName = "example-host";
  users.mutableUsers = false;

  users.users.admin = {
    isNormalUser = true;
    description = "Homelab administrator";
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAA_REPLACE_WITH_YOUR_PUBLIC_KEY admin@example"
    ];
  };

  system.stateVersion = "25.11";
}

Replace example-host, admin, the SSH public key, and system.stateVersion to match the host you are actually building. For the SSH key, use the contents of your public key file, commonly ~/.ssh/id_ed25519.pub; do not paste the private key from ~/.ssh/id_ed25519.

If this is your first NixOS server, consider whether users.mutableUsers = false is appropriate before you have proved a recovery route such as local keyboard and monitor access, a serial console, IPMI-style remote management, or bootable installer access. Declarative users are useful. Locking yourself out in a beautifully reproducible way is still locking yourself out.

The matching hardware-configuration.nix in this public skeleton is only a placeholder. In a conventional NixOS install, this file is generated from the target machine with nixos-generate-config after the target filesystems are mounted. This nixos-anywhere example keeps it as a stub so the repo can evaluate cleanly without pretending to know your hardware. Before adapting the skeleton for a real host, generate or review hardware configuration on that machine and copy in only the settings you understand.

The important traps are fewer than they look. imports is how the host opts into the other modules. networking.hostName is the system hostname. users.users.admin declares the account you will actually log in as, and extraGroups = [ "wheel" ]; gives it the usual NixOS sudo path.

system.stateVersion is easy to misread as an upgrade knob. It is not. Treat it as a compatibility baseline you set when the host is created, then change only with release-note-level attention.

Put The Boring Defaults In One Place

The base module sets the mechanics expected on every small host:

{ pkgs, ... }:

{
  nix.settings.experimental-features = [
    "nix-command"
    "flakes"
  ];
  nix.settings.auto-optimise-store = true;

  time.timeZone = "Etc/UTC";
  i18n.defaultLocale = "en_GB.UTF-8";

  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  networking.firewall.enable = true;

  environment.systemPackages = with pkgs; [
    curl
    git
    htop
    vim
  ];
}

There is not much here, and that is the point. The first host needs a boot loader, a firewall, enough tools to inspect the system, and Nix configured so the repository remains the centre of gravity. UTC is a conservative default because logs sort cleanly and it avoids baking somebody else’s geography into the template.

Most of this module is boring on purpose. A first host needs defaults you can explain after midnight, not a museum of favourite options. The boot loader choices assume a UEFI machine using systemd-boot. The firewall is enabled before any extra service is introduced. The package list is short enough that every item should be defensible.

Make SSH Policy Explicit

SSH is the first real service on many small servers, so its policy belongs in the base configuration:

{ ... }:

{
  services.openssh = {
    enable = true;
    openFirewall = true;

    settings = {
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      PermitRootLogin = "no";
    };
  };
}

This enables OpenSSH, opens the firewall for it, disables password and keyboard-interactive authentication, and prevents root SSH login. The NixOS SSH documentation covers the service options, but the operational point is simpler: remote administration should not start life as a temporary exception that nobody quite removes later.

For an unattended homelab box, key-only SSH is a sensible default once you have confirmed a recovery route. If you need password login during initial setup, make that a deliberate temporary change and remove it in the next reviewed commit.

The two password-related settings are separate because OpenSSH has more than one interactive authentication path. Disabling both normal password authentication and keyboard-interactive authentication avoids leaving a password-like route open through PAM or server defaults. Root SSH is also disabled, so the expected path is simple: log in as the declared admin user with a key, then use sudo for administrative work.

Add fail2ban, But Do Not Worship It

fail2ban is a useful small-server guardrail, not a replacement for good SSH policy:

{ ... }:

{
  services.fail2ban = {
    enable = true;
    bantime = "1h";
    maxretry = 5;

    ignoreIP = [
      "192.0.2.0/24"
      "198.51.100.0/24"
      "203.0.113.0/24"
    ];
  };
}

Those ignoreIP values are documentation ranges. Replace them with a real management range only if you have one and actually want to trust it, or remove the allowlist entirely. The useful pattern is not the exact numbers; it is that the fail2ban stance is declared, reviewable, and present before the host is exposed to routine internet background radiation.

The NixOS fail2ban module keeps this short. Resist the urge to add elaborate ban actions before you have tested ordinary lockout and recovery behaviour. A small system that is understood beats a clever one that only makes sense while its author is still in the room.

What matters after boot is not the declaration alone but the result: the SSH jail exists, repeated failed logins are noticed, and the allowlist trusts only networks you actually mean to trust. The upstream jail.conf is useful once you go beyond the defaults. In a public example, documentation ranges make the pattern visible without publishing a private LAN.

Install, Boot, Rebuild

The high-level install flow is easier to understand if you separate the two machines involved. Your workstation or admin machine holds the repo and runs nixos-anywhere. The target machine is the small server you are installing; it boots into the NixOS installer, exposes temporary root SSH, and waits for the config to be applied.

In broad terms, the process is:

  1. Edit the repo on your workstation: hostname, admin user, public SSH key, disk path, and release version.
  2. Run the local checks so the flake evaluates before it touches a server.
  3. Boot the target machine from the NixOS installer image.
  4. Get the target onto the network and confirm you can SSH to the installer as root.
  5. Confirm the target disk path under /dev/disk/by-id/.
  6. From the workstation, run nixos-anywhere against the target’s installer IP.
  7. Let the machine reboot into the installed system, then log in as the declared admin user and validate the host.

Before running the install command, make sure the boring facts are already true:

  • the repo is on the machine you will run nixos-anywhere from
  • every placeholder in configuration.nix and disk-config.nix has been replaced
  • the target machine is booted into the NixOS installer
  • root SSH to the installer environment works
  • the disk path has been checked against /dev/disk/by-id/
  • you are comfortable wiping the named disk

With those in place, nixos-anywhere can apply the flake:

nix run github:nix-community/nixos-anywhere -- \
  --flake .#example-host \
  --target-host root@<installer-ip>

Read the nixos-anywhere and disko documentation before using that path on real hardware. The command follows the disk layout in the repository, so treat the pre-flight list as part of the command, not as optional ceremony.

After first boot, validate the host before adding anything else:

ssh admin@<host>
hostnamectl
systemctl --failed
systemctl status sshd.service --no-pager
systemctl status fail2ban.service --no-pager
sudo fail2ban-client status sshd
sudo nixos-rebuild switch --flake .#example-host

For those checks, “good” is deliberately plain: hostnamectl shows the hostname you declared, systemctl --failed shows no failed units, sshd.service and fail2ban.service are active, fail2ban-client status sshd can see the SSH jail, and nixos-rebuild switch --flake .#example-host completes against the same repo. The final rebuild is not ceremonial. It proves the running machine and the repository still agree after installation.

Stop Before The Stack

At this point the host is still unfinished, which is exactly where it should be. What you have is the base mental model: one repo, one host target, one system disk layout, one admin user, explicit SSH policy, fail2ban, and a validation loop.

That boundary matters. A homelab platform is easier to reason about when the base host, the service layer, the storage layer, monitoring, and backup policy are introduced and tested as separate decisions. NixOS makes that separation practical because the same repository can grow from a minimal host into a fuller operating model without hiding the early choices under a pile of enthusiastic config.

The next step is one declarative service with persistent state and a validation checklist. Start there only after the base host can boot, accept SSH, run fail2ban, and rebuild from source.