I've been thinking again about using NixOS to build bootable disk images I can run on a remote qemu/KVM host. I worked out how to build bootable qcow2 disk images a few years back, which still seems to work, but I wanted something that worked with flakes which I've since adopted.

I initially found a way using nixos-generators which worked pretty well until I wanted to change the diskSize. This wasn't a big issue, but I realised I didn't really need nixos-generators either.

This is a pretty standard looking configuration.nix module that describes an example system to build.

{ config, lib, pkgs, ... }: {
  boot.kernelPackages = pkgs.linuxPackages_5_15;

  users.users = {
    tarn = {
      isNormalUser = true;
      extraGroups = [ "wheel" ];
      password = "";
    };
  };

  environment.systemPackages = with pkgs; [
    python310
  ];

  system.stateVersion = "23.05";
}

The configuration for building qcow2 disk images image can be separated into it's own module called qcow.nix. The attribute system.build.qcow2 is set to use the NixOS make-disk-image.nix magic.

{ config, lib, pkgs, modulesPath, ... }: {
  imports = [
    "${toString modulesPath}/profiles/qemu-guest.nix"
  ];

  fileSystems."/" = {
    device = "/dev/disk/by-label/nixos";
    autoResize = true;
    fsType = "ext4";
  };

  boot.kernelParams = ["console=ttyS0"];
  boot.loader.grub.device = lib.mkDefault "/dev/vda";

  system.build.qcow2 = import "${modulesPath}/../lib/make-disk-image.nix" {
    inherit lib config pkgs;
    diskSize = 10240;
    format = "qcow2";
    partitionTableType = "hybrid";
  };
}

To tie it all together we can create a flake.nix file which describes an input and uses the modules above to build an output.

{
  description = "Example Virtual Machine Configuration";
  inputs =  {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
  };
  outputs = { self, nixpkgs }: {
    nixosConfigurations = {
      # configuration for builidng qcow2 images
      build-qcow2 = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          ./qcow.nix
        ];
      };
    };
  };
}

To run a flake all the configuration needs to be added to a git repository.

$ git init
$ git add .

To build the qcow2 images nix build is used. Here we use our build_qcow2 configuration and the system.build.qcow2 attribute from earlier which is now somehow available.

$ nix build .#nixosConfigurations.build-qcow2.config.system.build.qcow2

This builds a qcow2 disk image in the nix store and creates a result symlink to the the containing directory in the store. To try out the disk image, we'll need a writable copy that can be mounted as a writable disk by qemu.

$ cp result/nixos.qcow2 root.qcow2
$ chmod 644 root.qcow2

The image can now be used to boot a qemu virtual machine

$ qemu-kvm -name nixos \
           -m 4G \
           -smp 2  \
           -drive cache=writeback,file=root.qcow2,id=drive1,if=none,index=1,werror=report \
           -device virtio-blk-pci,bootindex=1,drive=drive1 \
           -nographic
...
<<< Welcome to NixOS 23.05.20230924.261abe8 (x86_64) - ttyS0 >>>

Run 'nixos-help' for the NixOS manual.

nixos login: tarn
Password:

[tarn@nixos:~]$ uname -a
Linux nixos 5.15.133 #1-NixOS SMP Sat Sep 23 09:10:03 UTC 2023 x86_64 GNU/Linux

[tarn@nixos:~]$ python --version
Python 3.10.12

To kill the VM shut it down with the poweroff command or use ctrl + a followed by x.