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:
- 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/mountcalls run during install. - 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-anywherecan generate it on first install and copy it back. - 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#fastwiredpoints at thenixosConfigurations.fastwiredoutput of the flake undernixos/.--generate-hardware-config ...runsnixos-generate-configon the target during install and writes the result to./nixos/hardware-configuration.nixon the laptop. Drop this flag on subsequent runs; once the file exists, the flake uses it directly.-i ~/.ssh/fastwiredpicks the SSH private key to authenticate with. Without it,nixos-anywherefalls 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.