Homelab Immutable Infrastructure with NixOS
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.
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/:
hardware-configuration.nix-- auto-detected hardware settings (keep as-is)configuration.nix-- your system configuration (edit this)
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.
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:
boot-- builds but doesn't activate until next reboottest-- activates but doesn't add to boot menu (good for testing)build-- builds only, doesn't activate
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.