← All articles
SOFTWARE Homelab Immutable Infrastructure with NixOS 2026-02-14 · 9 min read · nixos · nix · immutable-infrastructure

Homelab Immutable Infrastructure with NixOS

Software 2026-02-14 · 9 min read nixos nix immutable-infrastructure declarative reproducible linux

Most homelab servers start the same way: install Ubuntu or Debian, SSH in, run a bunch of apt commands, edit config files, install Docker, tweak some sysctl settings, and hope you remember what you did when you need to rebuild. After a year of accumulating configuration drift across half a dozen machines, you have a fleet of snowflakes that nobody -- including you -- can reproduce.

NixOS takes a fundamentally different approach. Your entire system -- packages, services, users, firewall rules, kernel parameters, everything -- is declared in a configuration file. The system you get is the system the configuration describes. Nothing more, nothing less. If you can version control a config file, you can version control your entire server.

NixOS snowflake logo

This guide covers running NixOS in a homelab: why it's worth the learning curve, how to structure your configurations, and how to deploy reproducibly across multiple machines.

Why NixOS for Homelabs

The pitch for NixOS in a homelab comes down to three things:

Reproducibility. Given the same configuration, you get the same system every time. If your server catches fire, you rebuild it by running one command. No ansible playbook to maintain, no shell script to hope still works, no "I think I installed X manually that one time."

Atomic upgrades and rollbacks. Every system configuration change creates a new "generation." You can boot into any previous generation from the boot menu. Broke something? Reboot into the last working generation. This is system-level undo that works even if your boot process is broken.

Declarative service management. NixOS has built-in modules for hundreds of common services -- Nginx, PostgreSQL, Prometheus, Docker, WireGuard, you name it. Instead of installing a package and editing config files, you declare what you want and NixOS handles the rest.

The tradeoff is a steeper learning curve. Nix has its own language, its own package manager, and its own way of thinking about systems. The first week is confusing. After that, you won't want to go back.

Installing NixOS

Download the NixOS minimal ISO from nixos.org. For a homelab server, the minimal ISO is preferred -- you don't need the graphical installer.

Boot the ISO on your server (USB drive, IPMI virtual media, or PXE) and follow these steps:

# Partition your disk (example: UEFI system with a single root partition)
parted /dev/sda -- mklabel gpt
parted /dev/sda -- mkpart ESP fat32 1MiB 512MiB
parted /dev/sda -- set 1 esp on
parted /dev/sda -- mkpart primary 512MiB 100%

# Format
mkfs.fat -F 32 /dev/sda1
mkfs.ext4 /dev/sda2

# Mount
mount /dev/sda2 /mnt
mkdir -p /mnt/boot
mount /dev/sda1 /mnt/boot

# Generate initial config
nixos-generate-config --root /mnt

This creates two files in /mnt/etc/nixos/:

Edit /mnt/etc/nixos/configuration.nix for a basic server:

{ config, pkgs, ... }:
{
  imports = [
    ./hardware-configuration.nix
  ];

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

  # Hostname
  networking.hostName = "homelab-01";

  # Enable SSH
  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
    };
  };

  # Create your user
  users.users.deploy = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAA... your-key-here"
    ];
  };

  # Allow sudo without password for deploy user
  security.sudo.wheelNeedsPassword = false;

  # Basic packages
  environment.systemPackages = with pkgs; [
    vim
    git
    htop
    tmux
    curl
    wget
  ];

  # Firewall
  networking.firewall = {
    enable = true;
    allowedTCPPorts = [ 22 ];
  };

  # Timezone
  time.timeZone = "America/Los_Angeles";

  # System state version (don't change after initial install)
  system.stateVersion = "24.11";
}

Install:

nixos-install
reboot

That's it. You now have a running NixOS server with SSH access, a locked-down firewall, and no root password.

Understanding Nix Flakes

Flakes are Nix's answer to reproducible builds and dependency management. A flake is a directory with a flake.nix file that declares its inputs (dependencies) and outputs (what it provides). For NixOS configurations, flakes ensure that every build uses the exact same version of nixpkgs and any other dependencies.

Create a git repository for your homelab configurations:

mkdir ~/homelab-nix && cd ~/homelab-nix
git init

Create flake.nix:

{
  description = "Homelab NixOS configurations";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
  };

  outputs = { self, nixpkgs, ... }: {
    nixosConfigurations = {
      # Define each machine
      homelab-01 = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/homelab-01/configuration.nix
          ./hosts/homelab-01/hardware-configuration.nix
          ./modules/common.nix
        ];
      };

      homelab-02 = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/homelab-02/configuration.nix
          ./hosts/homelab-02/hardware-configuration.nix
          ./modules/common.nix
        ];
      };
    };
  };
}

The inputs section pins your nixpkgs version. Every machine built from this flake uses the same package versions. To update, run nix flake update, which updates flake.lock.

Structuring Configurations

A well-organized NixOS homelab repository separates shared configuration from host-specific configuration:

homelab-nix/
├── flake.nix
├── flake.lock
├── hosts/
│   ├── homelab-01/
│   │   ├── configuration.nix
│   │   └── hardware-configuration.nix
│   ├── homelab-02/
│   │   ├── configuration.nix
│   │   └── hardware-configuration.nix
│   └── nas/
│       ├── configuration.nix
│       └── hardware-configuration.nix
├── modules/
│   ├── common.nix          # Shared base config
│   ├── monitoring.nix      # Prometheus/node_exporter
│   ├── docker.nix          # Docker runtime
│   └── wireguard.nix       # VPN configuration
└── secrets/
    └── ...                 # Encrypted secrets (agenix/sops)

Common Module

modules/common.nix contains settings every machine should have:

{ config, pkgs, ... }:
{
  # Automatic garbage collection
  nix.gc = {
    automatic = true;
    dates = "weekly";
    options = "--delete-older-than 30d";
  };

  # Enable flakes
  nix.settings.experimental-features = [ "nix-command" "flakes" ];

  # Common packages
  environment.systemPackages = with pkgs; [
    vim
    git
    htop
    tmux
    curl
    wget
    iotop
    lsof
    dnsutils
    tcpdump
  ];

  # SSH hardening
  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
    };
  };

  # Automatic security updates
  system.autoUpgrade = {
    enable = true;
    flake = "github:youruser/homelab-nix";
    flags = [ "--update-input" "nixpkgs" ];
    dates = "04:00";
    allowReboot = true;
    rebootWindow = {
      lower = "04:00";
      upper = "06:00";
    };
  };

  # Time sync
  services.timesyncd.enable = true;
  time.timeZone = "America/Los_Angeles";

  # Firewall defaults
  networking.firewall.enable = true;
}

Declarative Services

This is where NixOS really shines. Instead of installing packages and editing config files, you declare the service state you want.

NixOS declarative configuration showing flake-based server management with rollbacks

Nginx reverse proxy:

{ config, pkgs, ... }:
{
  services.nginx = {
    enable = true;
    recommendedGzipSettings = true;
    recommendedOptimisation = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;

    virtualHosts."grafana.homelab.local" = {
      locations."/" = {
        proxyPass = "http://127.0.0.1:3000";
        proxyWebsockets = true;
      };
    };

    virtualHosts."prometheus.homelab.local" = {
      locations."/" = {
        proxyPass = "http://127.0.0.1:9090";
      };
    };
  };

  networking.firewall.allowedTCPPorts = [ 80 443 ];
}

PostgreSQL:

{ config, pkgs, ... }:
{
  services.postgresql = {
    enable = true;
    package = pkgs.postgresql_16;
    ensureDatabases = [ "grafana" "nextcloud" ];
    ensureUsers = [
      {
        name = "grafana";
        ensureDBOwnership = true;
      }
      {
        name = "nextcloud";
        ensureDBOwnership = true;
      }
    ];
    settings = {
      max_connections = 100;
      shared_buffers = "256MB";
      work_mem = "16MB";
    };
  };
}

Prometheus with node_exporter:

{ config, pkgs, ... }:
{
  services.prometheus = {
    enable = true;
    retentionTime = "90d";
    globalConfig = {
      scrape_interval = "15s";
    };
    scrapeConfigs = [
      {
        job_name = "node";
        static_configs = [{
          targets = [
            "homelab-01:9100"
            "homelab-02:9100"
            "nas:9100"
          ];
        }];
      }
    ];
  };

  # Also run node_exporter on this host
  services.prometheus.exporters.node = {
    enable = true;
    enabledCollectors = [ "systemd" "processes" ];
    port = 9100;
  };

  networking.firewall.allowedTCPPorts = [ 9090 9100 ];
}

WireGuard VPN:

{ config, pkgs, ... }:
{
  networking.wireguard.interfaces.wg0 = {
    ips = [ "10.100.0.1/24" ];
    listenPort = 51820;
    privateKeyFile = "/run/secrets/wireguard-private-key";

    peers = [
      {
        # Laptop
        publicKey = "abc123...";
        allowedIPs = [ "10.100.0.2/32" ];
      }
      {
        # Phone
        publicKey = "def456...";
        allowedIPs = [ "10.100.0.3/32" ];
      }
    ];
  };

  networking.firewall.allowedUDPPorts = [ 51820 ];
}

Each of these is a complete, working service definition. No manual installation, no config file editing, no forgetting a step. Add the module to your host's configuration, rebuild, and the service is running.

Building and Deploying

Local Rebuild

On the machine itself:

sudo nixos-rebuild switch --flake /path/to/homelab-nix#homelab-01

switch activates the new configuration immediately. Alternatives:

Remote Deployment with deploy-rs

For deploying to multiple machines from a central point, use deploy-rs. Add it to your flake:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    deploy-rs.url = "github:serokell/deploy-rs";
  };

  outputs = { self, nixpkgs, deploy-rs, ... }: {
    nixosConfigurations = {
      # ... your hosts ...
    };

    deploy.nodes = {
      homelab-01 = {
        hostname = "10.0.20.10";
        profiles.system = {
          user = "root";
          sshUser = "deploy";
          path = deploy-rs.lib.x86_64-linux.activate.nixos
            self.nixosConfigurations.homelab-01;
        };
      };
      homelab-02 = {
        hostname = "10.0.20.11";
        profiles.system = {
          user = "root";
          sshUser = "deploy";
          path = deploy-rs.lib.x86_64-linux.activate.nixos
            self.nixosConfigurations.homelab-02;
        };
      };
    };
  };
}

Deploy to a single machine:

deploy .#homelab-01

Deploy to all machines:

deploy .

deploy-rs includes automatic rollback: if the deployed system doesn't confirm health within a timeout, it reverts to the previous generation. This means a bad deploy won't leave a remote machine in a broken state.

Rollbacks

Every nixos-rebuild switch creates a new generation. List them:

sudo nix-env --list-generations --profile /nix/var/nix/profiles/system

Roll back to the previous generation:

sudo nixos-rebuild switch --rollback

Roll back to a specific generation:

sudo nix-env --switch-generation 42 --profile /nix/var/nix/profiles/system
sudo /nix/var/nix/profiles/system/bin/switch-to-configuration switch

You can also select any generation from the boot menu (hold Shift during boot on UEFI systems). This is your escape hatch when a configuration change prevents SSH access or breaks the network.

Managing Secrets

NixOS configurations are stored in plain text in a git repository. Secrets (passwords, API keys, private keys) need special handling. Two popular options:

agenix

Uses age encryption. Secrets are encrypted in the repository and decrypted at activation time on the target machine:

# Add agenix to your flake inputs
# Create a secrets directory
mkdir secrets

# Create a secrets.nix mapping
# secrets/secrets.nix
let
  homelab01Key = "ssh-ed25519 AAAA... root@homelab-01";
  homelab02Key = "ssh-ed25519 AAAA... root@homelab-02";
  adminKey = "ssh-ed25519 AAAA... admin@workstation";
in {
  "wireguard-key.age".publicKeys = [ homelab01Key adminKey ];
  "db-password.age".publicKeys = [ homelab01Key homelab02Key adminKey ];
}

Encrypt a secret:

agenix -e secrets/wireguard-key.age

Reference it in your configuration:

age.secrets.wireguard-key.file = ../secrets/wireguard-key.age;

networking.wireguard.interfaces.wg0 = {
  privateKeyFile = config.age.secrets.wireguard-key.path;
  # ...
};

sops-nix

Similar concept but uses SOPS (Secrets OPerationS), which supports multiple encryption backends (age, GPG, AWS KMS):

sops.secrets."database/password" = {
  sopsFile = ../secrets/database.yaml;
};

Both approaches work well. agenix is simpler and has fewer dependencies. sops-nix is more flexible if you're already using SOPS elsewhere.

Practical Tips

Start with one machine. Don't try to convert your entire homelab to NixOS at once. Pick your least critical server, install NixOS, and learn the workflow. Once you're comfortable, migrate others.

Use the NixOS options search. The NixOS options search is your best friend. Every configurable option in NixOS is documented there. When you want to do something, search for it before writing custom config.

Keep hardware-configuration.nix per-host. This file is generated by the installer and contains hardware-specific settings (disk partitions, kernel modules). Don't share it between machines.

Pin your nixpkgs version. The flake.lock file pins exact versions. Commit it to git. Run nix flake update deliberately when you want to upgrade packages, not accidentally.

Set up binary caching. Building packages from source takes a long time. NixOS's binary cache (cache.nixos.org) provides pre-built packages for most things. For custom packages, consider running your own cache with attic or cachix.

Garbage collect regularly. Old generations accumulate and consume disk space. The nix.gc settings in the common module handle this automatically, but you can also run it manually:

sudo nix-collect-garbage -d  # Delete all old generations

The Mental Shift

The biggest challenge with NixOS isn't the technical complexity -- it's the mental shift from imperative to declarative system management. On a traditional distro, you do things: install a package, edit a file, restart a service. On NixOS, you declare what you want the system to look like, and the system converges to that state.

This means you stop SSHing into servers to fix things. Instead, you edit your configuration, commit it, and deploy. The configuration is the source of truth. The running system is a product of the configuration. If the system doesn't match what you expect, the answer is always in the configuration.

For a homelab, this is liberating. You can rebuild any server from scratch in minutes. You can experiment with new services by adding a module, and roll back if it doesn't work. You can manage your entire infrastructure from a single git repository, with full history of every change you've ever made.

The learning curve is real -- expect to spend a few weekends getting comfortable with the Nix language and the NixOS module system. But once you're past that, you'll have the most maintainable homelab infrastructure possible.