Installation, briefly

Grab the latest minimal ISO from nixos.org/download, boot it, partition the disk, mount the root at /mnt, and run:

nixos-generate-config --root /mnt
nano /mnt/etc/nixos/configuration.nix
nixos-install
reboot

nixos-generate-config writes two files: a hardware-configuration.nix derived from probing the running hardware (do not edit by hand), and a configuration.nix stub that's intended to be the system's source of truth.

Anatomy of configuration.nix

A working server configuration is a single Nix expression. Below is a minimal one with a user, SSH, and the firewall opened on 22/80/443:

{ config, pkgs, ... }:

{
  imports = [ ./hardware-configuration.nix ];

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

  # Hostname & networking
  networking.hostName = "edge-01";
  networking.networkmanager.enable = false;
  networking.useDHCP = false;
  networking.interfaces.ens3.useDHCP = true;

  # Timezone & locale
  time.timeZone = "America/Toronto";
  i18n.defaultLocale = "en_CA.UTF-8";

  # Users
  users.mutableUsers = false;
  users.users.amir = {
    isNormalUser = true;
    extraGroups  = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAA... user@laptop"
    ];
  };

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

  # Firewall
  networking.firewall.allowedTCPPorts = [ 22 80 443 ];

  # System packages available to every user
  environment.systemPackages = with pkgs; [
    vim git tmux htop curl rsync
  ];

  # Pin the state version this config was written against.
  # Do not bump this casually — it pins service migration defaults.
  system.stateVersion = "25.11";
}

Two things to notice. First, users.mutableUsers = false — with this set, only users defined in the config exist on the system, and any useradd done at the shell is reverted on the next switch. The configuration becomes the only place users come from. Second, system.stateVersion is sticky on purpose: it tells NixOS which generation's service defaults to use, so upgrading nixpkgs doesn't silently change Postgres data-directory layouts or similar.

Adding a service: nginx with ACME

NixOS ships modules for most common services. The whole "install nginx, configure a vhost, get a Let's Encrypt cert, restart on rotation" workflow collapses to:

services.nginx = {
  enable = true;
  recommendedProxySettings = true;
  recommendedTlsSettings   = true;
  recommendedGzipSettings  = true;
  recommendedOptimisation  = true;

  virtualHosts."example.com" = {
    enableACME = true;
    forceSSL   = true;
    locations."/" = {
      proxyPass = "http://127.0.0.1:8080";
      proxyWebsockets = true;
    };
  };
};

security.acme = {
  acceptTerms = true;
  defaults.email = "ops@example.com";
};

ACME is wired automatically: NixOS provisions an ACME account, requests a cert from Let's Encrypt over HTTP-01, drops it into /var/lib/acme/example.com/, points nginx at it, and reloads nginx on renewal. No cron jobs to write, no certbot deploy hooks.

Switch, test, boot

After editing /etc/nixos/configuration.nix, build and activate the new generation:

sudo nixos-rebuild switch     # build and activate now
sudo nixos-rebuild test       # build and activate, but don't add to boot menu
sudo nixos-rebuild boot       # build and add to boot menu, activate on next boot
sudo nixos-rebuild dry-run    # show what would change, do nothing
sudo nixos-rebuild build      # build the system in ./result, do nothing

test is the safety command. If the new generation can't activate (a typo in a service definition, a broken service that fails to start), nothing has been written to the boot menu, and a reboot returns the machine to the previous generation. Once you're satisfied, switch promotes it.

Rollbacks

Every nixos-rebuild switch creates a new system generation. The bootloader shows them all:

sudo nix-env --list-generations --profile /nix/var/nix/profiles/system
sudo nixos-rebuild switch --rollback              # roll back to previous generation
sudo /nix/var/nix/profiles/system-42-link/bin/switch-to-configuration switch
                                                  # pin to a specific generation

The bootloader itself lists generations as menu entries; a broken rebuild can be rolled back without booting into a recovery shell — pick the previous generation at the boot prompt, boot, then nixos-rebuild switch --rollback.

Keep at least a week of generations

Default GC settings can prune old generations aggressively. Set nix.gc.options = "--delete-older-than 30d"; in configuration.nix if you want a full month of bootable history on every machine.

Flakes for pinning

Default NixOS pulls packages from the channel the installer subscribed to (rolling-ish, branch-tracking). That's fine for a workstation, brittle for production. Flakes pin the exact nixpkgs commit a configuration was built against, and check it into version control alongside the configuration.

Enable flakes once:

nix = {
  settings.experimental-features = [ "nix-command" "flakes" ];
};

Then create /etc/nixos/flake.nix:

{
  description = "Server fleet";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";

  outputs = { self, nixpkgs }: {
    nixosConfigurations.edge-01 = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [ ./configuration.nix ];
    };
  };
}

Rebuild with the flake:

sudo nixos-rebuild switch --flake /etc/nixos#edge-01

The first run writes a flake.lock pinning nixpkgs to an exact git revision. Subsequent builds use exactly that revision — bit-for-bit reproducible across machines and time — until nix flake update bumps the lock.

Remote deploys

Two reasonable approaches for fleets larger than one box:

  • nixos-anywhere — install NixOS onto a target machine over SSH, including initial partitioning via disko. Good for bringing up a fresh VPS in one command from a laptop with a flake checkout.
  • colmena or deploy-rs — declarative multi-host deploy, build locally, copy closures over SSH, activate atomically with auto-rollback on health-check failure.

Both rely on the same Nix store closure model: the entire system, every dependency, is content-addressed, so deploying to a remote host is "copy these store paths there and flip a symlink."

What NixOS is not good for

A few honest caveats:

  • The package ecosystem is smaller than Debian's; obscure software sometimes needs an overlay or a flake to build.
  • FHS-assuming binaries (vendor blobs, some commercial software) need buildFHSUserEnv or steam-run to work — not impossible, but extra steps.
  • The Nix language itself is the steepest part of the curve. Module system, attribute sets, lazy evaluation — it's not Python.
  • Disk usage grows: every generation kept means its closure stays in the store. Garbage-collect aggressively (nix-collect-garbage -d) on small disks.

If "I want my server to be exactly the same in a year as it is today, and I want to know what changed when it isn't" is a goal, NixOS is the path of least resistance to that property. For everything else, Debian is still the right answer.