Bootstrapping NixOS with a Template Generator

8 min read

Every NixOS machine I manage starts the same way. A configuration.nix that imports the right modules. A home.nix that wires up the user’s shell, editor, and dev tooling. An entry in flake.nix that ties it all together. A deploy script that knows whether to rebuild locally or push to a remote host. A user registry entry with SSH keys and metadata.

None of this is hard. All of it is tedious, and tedious means error-prone. You copy an existing host directory, find-and-replace the hostname, forget to update the architecture, wonder why your aarch64 server is trying to pull x86_64 packages, and lose twenty minutes to a mistake that shouldn’t have been possible. I got tired of this, so I wrote a tool.

The setup-template

The dotfiles repo now ships a Rust CLI called setup-template. It scaffolds new host and user configurations for the flake — interactively or from a config file. You run it, answer some questions, and it produces the files you would have written by hand, minus the transcription errors.

nix run .#setup-template -- new

The wizard prompts for two things: who you are and what the machine is.

For the user, it asks for a username, full name, email, preferred shell, whether you want developer tooling, and any SSH public keys. For the host, it asks for a hostname, platform (Linux or Darwin), architecture, role (desktop or server), nixpkgs channel, deploy mode, and which optional modules to enable — secrets, neovim, and language toolchains.

When it finishes, it writes three files and prints a flake snippet to stdout:

  • configs/users.nix — your user added to the shared registry
  • hosts/<name>/configuration.nix — the system config for your new machine
  • hosts/<name>/home.nix — the home-manager config with your selected modules
  • A ready-to-paste nixosConfigurations or darwinConfigurations block

What this looks like for a site host

This site — perlpimp.net — is a Zola static site packaged as a Nix flake. The flake builds the HTML, compiles a CV to PDF via typst, and exports a NixOS module that sets up nginx with ACME, virtual hosts, domain redirects, and optional Plausible analytics. The module interface is small:

services.perlpimpnet = {
  enable = true;
  domain = "perlpimp.net";
  extraDomains = [ "www.perlpimp.net" ];
  analytics.plausible.enable = true;
};

To host this, you need a NixOS server. That server needs a system configuration, a user, deploy tooling, and probably secrets management for anything beyond the static site itself. This is exactly what the template generator produces.

You would run the wizard, select Linux, your architecture, the server role, and enable secrets. The generator writes the host configuration with systemd-boot, a user account, and a deploy script that either rebuilds from a local checkout or pulls from GitHub over SSH. You paste the flake snippet, add the perlpimpnet flake as an input, import its NixOS module into your host config, and you have a deployable server.

The manual version of this process involves copying files from another host, editing half a dozen values across three files, and hoping you got the deploy script arguments right. The template version is a two-minute wizard followed by adding your site-specific imports. One of these scales. The other is how I used to do it.

Non-interactive mode

The wizard is fine for one-off setups, but if you are provisioning multiple hosts — say a web server, a build server, and a desktop — you write a JSON config and run the generator in batch mode:

nix run .#setup-template -- generate --config setup.json

The config file is a versioned schema with arrays of users and hosts. Each host references its primary user by username, selects its modules, and declares its deploy mode. The generator validates the whole thing — primary users must exist in the users list, language selections must be from the supported set, Darwin hosts can’t be servers — and either produces all the files or tells you what’s wrong. No partial output, no half-generated state.

{
  "version": 1,
  "repo_ref": "github:ijohanne/dotfiles-ng",
  "users": [{
    "username": "ij",
    "name": "Ian Johannesen",
    "email": "ij@perlpimp.net",
    "shell": "fish",
    "developer": true,
    "ssh_keys": ["ssh-ed25519 AAAA..."]
  }],
  "hosts": [{
    "name": "web01",
    "platform": "linux",
    "arch": "x86_64",
    "role": "server",
    "nixpkgs": "unstable",
    "deploy_mode": "remote",
    "primary_user": "ij",
    "modules": {
      "secrets": true,
      "neovim": true,
      "languages": ["nix"]
    }
  }]
}

This is the same config file you would check into version control if you wanted a record of how each machine was initialised. The generator is deterministic — same input, same output — so the config file doubles as documentation.

A concrete example: from zero to serving perlpimp.net

Say you have a VPS from any provider that supports kexec — Hetzner, netcup, OVH, most of the usual suspects. The box is running whatever stock Linux the provider gave you. You want it running NixOS, serving this site, with secrets management and a deploy script, and you want it done in under an hour.

Here is the whole process.

Step 1: scaffold the host

cd ~/git/dotfiles
nix run .#setup-template -- new

The wizard runs:

── User ──────────────────────────────────
Username: ij
Full name: Ian Johannesen
Email: ij@perlpimp.net
Shell [fish/zsh/bash]: fish
Developer mode? [Y/n]: y
SSH public key (empty to finish): ssh-ed25519 AAAA... ij@macbook
SSH public key (empty to finish):

── Host ──────────────────────────────────
Hostname: web01
Platform [linux/darwin]: linux
Architecture [x86_64/aarch64]: x86_64
Role [desktop/server]: server
Nixpkgs channel [unstable/stable]: unstable
Deploy mode [local/remote]: remote
Enable secrets? [Y/n]: y
Enable neovim? [Y/n]: y
Languages [nix, rust, lua, markdown, flutter]: nix

It writes configs/users.nix, hosts/web01/configuration.nix, hosts/web01/home.nix, and prints the flake snippet.

Step 2: add disko and the site module

The generator gives you a working system config, but the VPS needs disk partitioning and your site needs its NixOS module. You create a hosts/web01/disko.nix for the VPS disk layout — a small BIOS boot partition and the rest as ext4:

{
  disko.devices = {
    disk.main = {
      type = "disk";
      device = "/dev/vda";
      content = {
        type = "gpt";
        partitions = {
          boot = {
            size = "1M";
            type = "EF02";
          };
          root = {
            size = "100%";
            content = {
              type = "filesystem";
              format = "ext4";
              mountpoint = "/";
            };
          };
        };
      };
    };
  };
}

Then you edit hosts/web01/configuration.nix to import disko and the perlpimpnet module:

{ inputs, config, pkgs, lib, user, ... }:

let
  deploy = import ../../configs/deploy { inherit pkgs; };
in
{
  imports = [
    ../../configs/server.nix
    ./hardware-configuration.nix
    ./disko.nix
    inputs.disko.nixosModules.disko
    inputs.perlpimpnet.nixosModules.default
  ];

  networking.hostName = "web01";

  services.perlpimpnet = {
    enable = true;
    domain = "perlpimp.net";
    extraDomains = [ "www.perlpimp.net" ];
    analytics.plausible.enable = true;
  };

  environment.systemPackages = [
    (deploy.mkDeployScript {
      name = "deploy-web01";
      host = "web01";
    })
  ];

  sops = {
    defaultSopsFile = ../../secrets/web01.yaml;
    age = {
      sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
      keyFile = "/var/lib/sops-nix/key.txt";
      generateKey = true;
    };
  };

  system.stateVersion = "25.11";
}

You add perlpimpnet and disko as flake inputs, paste the generated flake snippet, and commit.

Step 3: deploy with nixos-anywhere

The VPS is running stock Debian or Ubuntu. You don’t need to install NixOS manually — nixos-anywhere handles it. It SSH’s into the running Linux, kexec’s into a NixOS installer environment, partitions the disk using your disko config, and installs your flake configuration. One command:

nix run github:nix-community/nixos-anywhere -- --flake .#web01 root@<vps-ip>

This partitions /dev/vda according to disko.nix, installs the full NixOS configuration including nginx, ACME, and the perlpimpnet module, and reboots. When it comes back up, the site is live. The whole thing takes a few minutes, most of which is the build and transfer.

Step 4: secrets and ongoing deploys

After the first boot, grab the host’s age key for sops-nix:

ssh-to-age-remote root@<vps-ip>

Add it to .sops.yaml, create secrets/web01.yaml, and you have encrypted secrets that only this host can decrypt.

From here on, deployments are a push and an SSH command:

git push
ssh web01 deploy-web01

The deploy script on the host checks for a local checkout, falls back to GitHub if there isn’t one, and runs nixos-rebuild switch --flake. That is the entire workflow — template, customise, nixos-anywhere, deploy. No ISO to boot, no manual partitioning, no ansible playbooks, no forgetting which host has which configuration.

What happens after generation

The generator gets you to a buildable configuration. It does not do everything. After the files land, you still need to:

  1. Merge the generated users.nix into any existing user registry
  2. Paste the flake snippet into flake.nix
  3. Add your site-specific module imports — in this case, the perlpimpnet NixOS module
  4. If you enabled secrets, set up sops-nix: get the host’s age key, add it to .sops.yaml, create the secrets file
  5. Build and deploy

The tool prints these steps when it finishes. It does not try to be clever about automating things that require human judgment — like whether your secrets should use age or GPG, or which network interface your server uses. It handles the boilerplate. You handle the decisions.

Why Rust

The generator is built with clap for argument parsing, dialoguer for the interactive prompts, and serde for config serialisation. It has unit tests for rendering determinism and schema validation. It builds with rustPlatform.buildRustPackage in the flake and participates in nix flake check.

I could have written this as a bash script. I have written this as a bash script, more than once, for other projects. Bash is fine for gluing together commands, but the moment you need structured input validation, schema versioning, or deterministic output, you are fighting the language instead of using it. A Rust binary that either produces correct output or fails with a clear error is worth the marginal extra effort over a shell script that silently does the wrong thing when you typo an argument.

The broader point

NixOS already solves the “works on my machine” problem for system configuration. But the act of creating that configuration — the scaffolding step — is still a manual, copy-paste-and-edit workflow in most setups. The reproducibility of the system doesn’t help if the process of defining the system is ad hoc.

A template generator is not a novel idea. Every web framework has create-app. Every language has init. What’s less common is applying this to infrastructure definitions, where the cost of a wrong default is not a broken dev server but a misconfigured production host.

The goal is not to remove the need to understand what the configuration does. It is to remove the need to remember it. The generator encodes the conventions of the flake — where files go, how deploy scripts are wired, which modules exist — so that adding a new host is a matter of answering questions about the host, not about the flake’s internal structure. The knowledge lives in the tool, not in your head.