Skip to content

Basic VM

In this section, we will lay the foundation for the provisioning of a complete, if minimal, NixOS system, built as a Qemu qcow2 image.

Note

You should read this page to understand the mechanisms, but you can skip the work itself and start with a preconfigured flake by running the following command:

nix flake init --template github:nix4noobs/starters#basicvm-22.11

Generating the image using the NixOS Generators project

We will use NixOS generators to generate the actual image, to that end, we add this project (itself a flake) as an input to our flake.

From reading the NixOS generators readme, we can gather that something like the following should generate a qcow image, given the NixOS configuration in configuration.nix:

Generating a qcow2 image with NixOS generators
# Inside flake outputs, we will wrap this
# package entry in a call to `forAllSystems` later
packages.x86_64-linux = {
    vm = {
        system = "x86_64-linux";
        modules = [
            ./confguration.nix;
        ]
        format = "qcow";
    }
}

Note that this is a package entry, so to build the VM image, we will end up calling nix build .#vm.

Note

Where will the image be?: Recall also that after each successful call to nix build, a result symlink will appear in the directory, linking into the store where the build output will reside. After building the VM, you will see that inside result, there will be a nixos.qcow2 file.

Configuring NixOS

The central NixOS configuration file is by convention called configuration.nix, though you could name it however you want.

There is really no difference between configuring NixOS for bare-metal deployment vs a VM, except perhaps that a VM config tends to be tailored for a single task and therefore shorter.

Note

The configuration itself is a attribute set and the NixOS manual's configuration chapter has a lot more information on what you can do.

State Version

The state version variable should be set once during creation, and then never changed afterwards. The state variable is used when determining defaults for various configuration modules. E.g. when configuring OpenSSH, you will set some attributes, but rely on defaults for everything else. Defaults may later change, in response to new best practices, for example. But if changing defaults impacted existing NixOS configurations, reproducability would be broken. Hence the stateVersion variable.

The initial (& final) value of stateVersion should be the release of NixOS that your configuration is initially targetting. For instance, if my configuration is using the 22.11 release branch of NixOS, then I will set stateVersion to the same release, also:

Setting NixOS state version
system.stateVersion = "22.11";

Configuring Nix

Remember that we initially configured Nix to enable the new CLI commands and to enable flakes. We want Nix configured in the same way in the NixOS VM, and the following snippet does that:

Configuring Nix in NixOS
nix = {
  # enable new CLI and flake support
  package = pkgs.nixFlakes;
  extraOptions = ''
    experimental-features = nix-command flakes
  '';
};

Users

The manual provides more examples of how to configure user accounts. But for the VM, we permit passwordless login of root and create a single user account, nixusr, who is member of the wheel group and who then can use sudo to run administrative commands.

Configure users in NixOS
# allow logging into `root` without a password
users.users.root.initialHashedPassword = "";

# install user `nixusr`
users.users.nixusr = {
  isNormalUser = true;
  home = "/home/nixusr";
  description = "nixusr user";
  extraGroups = [ "wheel" ];
  uid = 1000;
  # `mkpasswd -m sha-512`
  hashedPassword = "$6$bIj/yHEKrsB4GIg9$SW2OHgWTvoC5AVlENwhWkBY7tF6SSG8z6cT/bSEuyw2Jy7U2qui1isCQjeDd.ti94FI..DyKExk/FCR0FpyEO/";
  openssh.authorizedKeys.keys = [
    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfiSeJDvonf1w5dNk5V+KcGvKODQva5PUxAO0UZYBvXbZwxuBnFQ0VGgvONRF/Sct+phdI4GFFKliDqZc9KtNiyM9SNjOrNQQfLJgWNHPmWNABx3gfFvQygNoTsS9GxulMitdGUtrXuK5l4yLAU1INC97v3/qIqjGSu9pPqnNWyWMa1d2VWa8QkA2zDSC0J1+ytt/ZqwtAyxP86lVjTb4aDpdRY3ucZH8xvk9sIR2gJsFXm9Tz58PJh/FEvJ/X9FTBkm8qq6/KDN2wNbJ/Bs7/x9rg4UmEhKpN3bStRVOOHPotUOxZ2I/uIUlMn9CIDhqVVTU6XruFVdUwzdUzbvyAKrcbcV8LdVdeOBRkTZgz9s7plHMl/Q2I1KGhecEqiGLwL7v3BJibf/S/saCSmziLU6FYrR8w8FtRStKKTaz7sE/50eVWBkQX+wFFsLw8HLdjJnHXBUZDHgYzXoVAAVFbzZxeA8E5924YF8bgLBkqn2FBrFnHiBgArkHWuv2I6V+gw9hMjPEQJje5h2E2l/NzL0sq/dlh7CXoJVf/9K6GM3fCjWfcJOmdu50sBzqFmIEZIgbGJnEkdRnCglk3VbBqNyMdhKKVBL5dRRmEBF2FbfoWCAihGW8sodZWbDMMZalzW1cgofA4LUrFQRktuR+G0cT73bBw0VbnZjuVF5Bq4w== samsung 2021-02-16"
  ];
};

OpenSSH

Finally we configure OpenSSH - note that we disable root login - generally a great idea, especially so when root has no password! We also disable password authentication, meaning that would-be users connecting to the machine need to have their SSH key(s) registered with a user account on the machine.

We already registered a SSH public key for the nixusr, see the Users section above.

Configuring OpenSSH for NixOS
# configure OpenSSH service
services.openssh = {
  enable = true;
  settings = {
    PermitRootLogin = "no";
    PasswordAuthentication = false;
    KbdInteractiveAuthentication = false;
  };
};

Specifying installed packages

Finally, we probably want some software installed when booting into the image. In this case, I'm just installing a copy of fortune, an old CLI tool which prints a different "fortune cookie" each time it is run.

Software installed this way is available system-wide, this closely resembles the effect of installing software via apt in Debian/Ubuntu or dnf in Fedora.

Specifying base system software
environment.systemPackages = [
    pkgs.fortune
];

Using the VM

As mentioned above, building the VM by running nix build .#vm will, as for any call to nix build, create a result symlink, pointing to the built artifact inside the Nix store. In this case, result points to a directory containing nixos.qcow2, our VM image.

However, booting the VM from qemu would not work, as it requires write permission, and in a multi-user Nix install, all operations on the Nix store are mediated by the Nix daemon while the files themselves are owned by root.

An easy way to remedy this is copying the image out of the store, but that's also wasteful of disk space. Another approach is using Qemu's ability to create overlay images, images which are based on another image, but for which changes are written into the overlay image.

The command to create the overlay image can be put into a script:

Script to make QEMU overlay images
#!/usr/bin/env bash

die () {
    echo >&2 "$@"
    exit 1
}

if [ "$#" -ne 1 ]; then
 echo "USAGE ${0} <overlay img destination>"
 die "1 argument required, $# provided"
fi

DST="$1"

qemu-img create \
    -F qcow2 \
    -b $(readlink -f ./result/nixos.qcow2) \
    -f qcow2 \
    "${DST}"

Two issues arise when introducing a shell script like this:

  1. how should the user run this script?
  2. how can we ensure the script works on their machine?

The first concern is mainly born from our use of flakes. It would be nice not to require the user to clone our flake repository to get the script, so we'd like to expose it through Nix.

The second concern is especially poignant with shell scripts. This script is minimal, but even it relies on the readlink program from coreutils and qemu-img. Sadly, macOS and Linux have different implementations of coreutils programs which manifests itself in different flags, features supported and output printed, all which contribute to the brittle nature of shell scripts. But with Nix, we can provide the utilities the script expects and pin their versions, making shell scripts much more reliable.

Wrap make overlay and expose as flake app
apps = forAllSystems ({ pkgs, ... } @ args: let
  # package shell script for execution in nix env using nix deps
  make-overlay-script = pkgs.runCommandLocal "make-overlay" {
  script = ./scripts/make-overlay;
  nativeBuildInputs = [ pkgs.makeWrapper ];
  } ''
  makeWrapper $BASH $out/bin/make-overlay.sh \
      --add-flags $script \
      --prefix PATH : ${pkgs.lib.makeBinPath (with pkgs; [ bash qemu coreutils ])}
  '';
in {
  make-overlay = {
      type = "app";
      program = "${make-overlay-script}/bin/make-overlay.sh";
  };
});

The runCommandLocal function is a variant of runCommand, which is a helper function for quickly building a derivation (read: build a package) which wraps a script.

We would like to not inline the script in the nix flake file, so instead we write a script using makeWrapper, which we use to install Nix packages bash, qemu-img and readlink and to put them first in the PATH variable, overshadowing similar binaries from the host OS.

Note

how did you come up with this?: I'd love to give an easy answer, but finding solutions like these is often something like:

  1. Knowing what to search for online (I tried "wrap shell script Nix")
  2. Finding some reference to some function, maybe an example
  3. Trying Noogle (only works for builtins and lib functions)
  4. Trying the NixOs manual
  5. Searching for code snippets in the NixOS repo
  6. Searching for Snippets across Github using Code Search
  7. Plead for help on the (unofficial) NixOS discord