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:
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
:
# 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:
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:
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.
# 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.
# 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.
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:
#!/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:
- how should the user run this script?
- 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.
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:
- Knowing what to search for online (I tried "wrap shell script Nix")
- Finding some reference to some function, maybe an example
- Trying Noogle (only works for builtins and lib functions)
- Trying the NixOs manual
- Searching for code snippets in the NixOS repo
- Searching for Snippets across Github using Code Search
- Plead for help on the (unofficial) NixOS discord