fastwired@reader:~/fastwired/ $ cat posts/gitops-nix/all-your-vm-are-belong-to-nixos.md

All your VM are belong to NixOS

Cloud providers ship Ubuntu, Debian, or some flavor of those. None of them ship NixOS as a stock image, so to run NixOS on a fresh cloud VM you have to put it there yourself. This post walks through how, using nixos-anywhere.

nixos-anywhere takes a running Linux box you can SSH into, hands it a NixOS configuration, and replaces the OS in place: kexec into a NixOS installer, partition the disk, write the new system. No console, no rescue ISO, no clicking around in the provider's web UI.

What's Nix again?

Nix is a package manager, with a small, purely functional configuration language at its core. "Pure functional" means the same thing it does in any other language: when Nix evaluates a configuration, the result depends only on the inputs you handed in, never on hidden state from your shell, your filesystem, or the network. The most common root input is nixpkgs, a giant catalogue of build recipes for everything from bash to postgresql. From whatever inputs you give it, Nix resolves the full dependency graph for what you asked for, builds each piece in isolation, hashes the result based on every input that went into it, and drops it into the Nix store under that hash. The system then uses those store entries through symlinks: your PATH, your boot config, and your service definitions all point into the store.

That's the loop: inputs in, dependency graph resolved, each piece built and hashed into the store, system points at the store. Same inputs always produce the same store paths, which is where the rollback and reproducibility properties from the last post come from.

A quick word on the language before we start reading any of it. The mental model I find most useful: Nix is what JSON and TOML would look like if they had a baby with functions. Attribute sets are written { key = value; } (bare keys without quotes, semicolons after each entry rather than commas), lists are [ a b c ] (whitespace-separated, no commas either), and strings and numbers look the way you'd expect. On top of that you get function literals like { pkgs, lib, ... }: { ... } (everything left of the : is the argument, everything right is the body) and references to other expressions by name. That'll cover almost everything you read in this post.

Installing Nix locally

To run nixos-anywhere and setup the server, we need Nix on the local machine to drive the install.

I'm on Linux, so I'll use the official Linux installer. macOS users, the command is slightly different; grab the macOS one-liner from the Nix download page. Windows users, go install a proper OS and come back. (WSL2 also works, if you must.)

For Linux with systemd, the recommended multi-user install is one command:

sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon

Open a fresh shell once it finishes, and check:

nix --version

If you see a version number, you're set.

Wait, what's a flake?

A Nix project is just a directory with one or more .nix files in it: Nix expressions declaring what the project depends on and what to build. A flake is a Nix project with a standard shape: a flake.nix at the root that declares two things, inputs and outputs.

Inputs are the other Nix code the project depends on. Most flakes pull in nixpkgs, and a flake building servers will usually also pull in things like nixos-anywhere and comin. Each input is a pointer to a Git repo or tarball, and a flake.lock file sitting next to flake.nix pins every one of them to an exact commit. Same idea as package-lock.json or Cargo.lock in other ecosystems: build the flake today, build it again in a year, and you get the same result, because the inputs haven't moved.

Outputs are what the flake produces. A flake can produce lots of different kinds of things (packages, dev shells, apps, NixOS modules), but the one we care about here is nixosConfigurations: one entry per machine, each describing a full NixOS system. nixos-anywhere reads a nixosConfigurations.<hostname> output to know what to install on a server. Comin will later read the same output to know what to apply on every push.

So the mental model for the rest of this series is: one server flake as entry point with nixpkgs and a few helpers as inputs, and one nixosConfigurations.<hostname> output per server.

Flakes have technically been "experimental" for years, but in practice they're how almost everyone writes Nix today. The upstream installer doesn't turn them on by default, so we opt in by hand:

mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

Quick check:

nix flake --help

If you get help text instead of an error, you're set.

A top-level dev flake

In the infra repo I like to keep a top-level flake.nix that describes the dev environment, separate from the server flake under nixos/ that comes later. Right now it only needs two tools on PATH: nixos-anywhere for the install we're about to do, and nixos-rebuild for poking at configurations from the laptop.

{
    description = "Infra dev environment";

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

    outputs = { self, nixpkgs }:
        let
            system = "x86_64-linux";
            pkgs = nixpkgs.legacyPackages.${system};
        in {
            devShells.${system}.default = pkgs.mkShell {
                packages = [
                    pkgs.nixos-anywhere
                    pkgs.nixos-rebuild
                ];
            };
        };
}

Swap x86_64-linux for aarch64-darwin if you're on Apple Silicon, or wire up flake-utils if you want both.

To enter the shell, cd into the repo and run:

nix develop

nix develop evaluates the flake, builds the devShell, and drops you into a subshell where the listed packages are on PATH. Nothing is installed globally; close the shell and nixos-anywhere is gone from your environment again. The packages themselves stay cached in the Nix store, so the next nix develop is instant.

nix develop
which nixos-anywhere
/nix/store/…-nixos-anywhere-1.x.x/bin/nixos-anywhere

That's our workbench. From inside this shell we'll point nixos-anywhere at the first server.

What nixos-anywhere needs

For nixos-anywhere to install onto a target, it needs a NixOS configuration that covers three things:

  1. A disk layout - how to partition, format, and mount the target's disk. This is the job of disko, a NixOS module that turns a declarative Nix description into the parted/mkfs/mount calls run during install.
  2. A hardware configuration - the kernel modules, microcode toggles, and boot bits specific to whatever VM the provider hands us. We can't write this ourselves because the provider hides the underlying hardware behind a generic image, but nixos-anywhere can generate it on first install and copy it back.
  3. An actual system configuration - hostname, users, services, and at minimum an SSH key so we can log back in once the install finishes.

All three live in a second flake under nixos/. Keeping it separate from the dev flake means the tools driving the install aren't tangled up with the OS definition being shipped.

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

    outputs = { nixpkgs, disko, ... }: {
        nixosConfigurations.fastwired = nixpkgs.lib.nixosSystem {
            system = "x86_64-linux";
            modules = [
                disko.nixosModules.disko
                ./disk-config.nix
                ./hardware-configuration.nix
                ./configuration.nix
            ];
        };
    };
}

Two inputs: nixpkgs and disko. The disko.inputs.nixpkgs.follows = "nixpkgs" line tells Nix to use our nixpkgs for disko's nixpkgs too, so everything resolves against a single revision. You'll see that pattern repeated for every input that itself depends on nixpkgs.

The output is one nixosConfigurations.fastwired, built from four modules: disko's NixOS module to make disko's options available, then three local files for the disk layout, hardware bits, and system config. The attribute name (fastwired here, after the box I'm setting up) is how we'll address this configuration on the command line; nixos-anywhere will reference it as .#fastwired later. Use whatever name fits your machine.

The disk layout

disk-config.nix describes partitions and filesystems in Nix. For a typical cloud VM, the nixos-anywhere examples repo ships a layout that works as-is — grab disk-config.nix and drop it next to the flake.

The one thing worth glancing at is the disk path:

disk.disk1 = {
    device = lib.mkDefault "/dev/sda";
    ...
};

Most cloud VMs expose their root disk as /dev/sda or /dev/vda; bare-metal with NVMe usually shows up as /dev/nvme0n1. SSH into the target and run lsblk if you're not sure, and override the value if it isn't /dev/sda.

Hardware configuration

./hardware-configuration.nix doesn't exist yet, and that's fine. nixos-anywhere will create it on the first install run when we pass --generate-hardware-config nixos-generate-config ./hardware-configuration.nix, and from then on it's just another file in the repo. Commit it alongside everything else, and subsequent rebuilds of the same machine will read it back instead of regenerating.

The system config

That leaves configuration.nix, the actual machine description. We'll grow it through the series; for now it just needs cloud-VM-friendly defaults (hardware detection, bootloader) plus enough OpenSSH and firewall to reach the box once NixOS takes over.

A quick note on what I'm assuming you have in front of you for this part: a cloud VM already up and running, with an SSH key pair on your laptop whose public half is authorized for the VM's root user. In my case it's a Hetzner Cloud CX23 instance and the key pair is named fastwired (so ~/.ssh/fastwired and ~/.ssh/fastwired.pub on the laptop). Swap in your own provider, instance type, and key path wherever those names appear.

{ modulesPath, ... }: {
    imports = [
        (modulesPath + "/installer/scan/not-detected.nix")
        (modulesPath + "/profiles/qemu-guest.nix")
    ];

    boot.loader.grub = {
        efiSupport = true;
        efiInstallAsRemovable = true;
    };

    services.openssh.enable = true;

    users.users.root.openssh.authorizedKeys.keys = [
        "...your ssh public key here..."
    ];

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

    system.stateVersion = "25.11";
}

The two modulesPath imports are cloud-VM hygiene. Both files ship inside nixpkgs itself, under nixos/modules/; modulesPath is a standard module argument that resolves to that directory, so the imports turn into absolute paths into whatever nixpkgs revision your flake is pinned to. installer/scan/not-detected.nix adds safe defaults for hardware NixOS's auto-detection sometimes misses; profiles/qemu-guest.nix enables the virtio drivers and qemu-guest-agent integration that almost every cloud VM needs to actually see its disks and network. Optional on bare metal; effectively required on the KVM-backed VPS most cloud providers hand you.

boot.loader.grub.efiInstallAsRemovable = true tells GRUB to write itself to the EFI fallback path (/EFI/BOOT/BOOTX64.EFI) instead of registering a new entry with the firmware. nixos-anywhere typically can't write firmware NVRAM during a kexec install, so without this you'd end up with an installed system that won't boot.

For authorizedKeys.keys, paste the entire contents of your .pub file (in my case ~/.ssh/fastwired.pub) — the public key, not the private one. That's what lets the laptop reach the box over SSH once NixOS takes over.

The networking.firewall block enables NixOS's built-in stateful firewall and explicitly allows incoming TCP on port 22. Everything else stays closed by default; we'll punch more holes as we add services later in the series. (Strictly speaking services.openssh.enable already opens 22 on its own through services.openssh.openFirewall, but being explicit about what's allowed makes the file easier to read later.)

system.stateVersion pins the NixOS release the machine was first installed under; set it to whatever version your nixpkgs input tracks and leave it alone after that.

Sanity-checking the flake

Before pointing nixos-anywhere at a real machine, it's worth confirming the files at least parse. Cheapest check is to run Nix's parser over each one:

nix-instantiate --parse nixos/flake.nix nixos/configuration.nix
nixos/disk-config.nix

If everything parses, Nix prints the AST for each file. If something's off — missing semicolon, unbalanced braces, unknown keyword — you get a line and column to start from. This won't catch semantic errors (referencing a NixOS option that doesn't exist, for example), but it catches typos that would otherwise blow up halfway through an install.

For a deeper check that actually evaluates the whole configuration, drop a placeholder where hardware-configuration.nix will eventually live (nixos-anywhere will overwrite it on the first install run anyway) and ask Nix to validate the flake:

echo '{}' > nixos/hardware-configuration.nix
nix flake check ./nixos

A clean exit there means the flake evaluates end-to-end — every module loads, every option is one NixOS knows about, every reference resolves. After that, the install is just turning the crank.

Running the install

We have a flake under nixos/, a disk layout, a slot for the hardware config, and an SSH key authorized on the target. Time to run it.

One step on the target first: boot the VPS into the provider's rescue mode. That's the environment nixos-anywhere will kexec from.

From inside nix develop (so nixos-anywhere is on PATH), and at the top of the repo rather than inside nixos/ — the ./nixos paths in the command are relative to wherever you run it — with the target's IP in hand:

nixos-anywhere \
--flake ./nixos#fastwired \
--generate-hardware-config nixos-generate-config ./nixos/hardware-configuration.nix \
-i ~/.ssh/fastwired \
root@<target-ip>

What the flags do:

  • --flake ./nixos#fastwired points at the nixosConfigurations.fastwired output of the flake under nixos/.
  • --generate-hardware-config ... runs nixos-generate-config on the target during install and writes the result to ./nixos/hardware-configuration.nix on the laptop. Drop this flag on subsequent runs; once the file exists, the flake uses it directly.
  • -i ~/.ssh/fastwired picks the SSH private key to authenticate with. Without it, nixos-anywhere falls back to whatever your ssh-agent has loaded.
  • root@<target-ip> is the target VM.

The first run takes a few minutes: nixos-anywhere SSHes in, downloads a kexec image, switches the running OS into a NixOS installer, partitions the disk per disko, builds the system closure, and writes it. When it finishes you'll see ### DONE! ###, the target will reboot into NixOS, and the SSH host key will have changed (clear it from ~/.ssh/known_hosts before reconnecting).

After it comes back up, ssh -i ~/.ssh/fastwired root@<target-ip> should land you in a fresh NixOS system, and ./nixos/hardware-configuration.nix should be sitting in the repo waiting to be committed.

Now we have a basic nixos vps.
Next we'll take care of the secrets with sops and age.