Installing a Custom NixOS Image on a Raspberry Pi
January 23, 2024 | 15 min. read
In my homelab, I have four Raspberry Pis running on NixOS. For the first Pi I installed NixOS on, I went through the traditional boot process:
- Find the official NixOS
.iso
image, and copy it onto a USB drive - Find a spare monitor and keyboard, and plug the Pi into them
- Plug in the USB and boot the RasPi off of the NixOS install
.iso
- Install the OS manually by clicking around a GUI, then edit the
configuration.nix
file
This definitely works, but it's a huge pain. I have to dig around through my
messy cable drawer and assemble an ad-hoc Pi Installation Station, editing the
configuration.nix
on a fresh install without my usual text editor and tools is
annoying, etc. I also use Flakes for all of my desktop/laptop NixOS configs, so
unifying all my Pi hosts with the flake outputs would be ideal.
Luckily, it's pretty easy to create custom NixOS images to boot a RasPi from. It doesn't even require a USB drive, or any manual intervention other than inserting an SD card into the Pi. Here's how it's done:
Write the Configuration
The first step, as you might guess, is to write the desired NixOS configuration
for the Pi that you want to install NixOS on. Throughout this guide, I'll be
using Nix Flakes. The general approach will be the same if you're not using
Flakes, but the arguments for a few shell commands will be a little different
(and left as an exercise for the reader). Our example flake.nix
will look like
this:
# flake.nix
{
description = "my flake setup";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs, ... }@inputs: rec {
# here goes the other flake outputs, if you have any
nixosConfigurations."pi" = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
modules = [
./configuration.nix
];
};
};
}
Then, you can write your configuration.nix
:
# configuration.nix
{ pkgs, ... }:
{
imports = [
./hardware-configuration.nix
];
# Use the extlinux boot loader. (NixOS wants to enable GRUB by default)
boot.loader.grub.enable = false;
# Enables the generation of /boot/extlinux/extlinux.conf
boot.loader.generic-extlinux-compatible.enable = true;
# networking config. important for ssh!
networking = {
hostName = "pi";
interfaces.end0 = {
ipv4.addresses = [{
address = "192.168.1.42";
prefixLength = 24;
}];
};
defaultGateway = {
address = "192.168.1.1"; # or whichever IP your router is
interface = "end0";
};
nameservers = [
"192.168.1.1" # or whichever DNS server you want to use
];
};
# the user account on the machine
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user.
hashedPassword = "blablabla" # generate with `mkpasswd`
};
# Enable the OpenSSH daemon.
services.openssh.enable = true;
# I use neovim as my text editor, replace with whatever you like
environment.systemPackages = with pkgs; [
neovim
wget
];
# allows the use of flakes
nix.package = pkgs.nixFlakes;
nix.extraOptions = ''
keep-outputs = true
keep-derivations = true
experimental-features = nix-command flakes
'';
# this allows you to run `nixos-rebuild --target-host admin@this-machine` from
# a different host. not used in this tutorial, but handy later.
nix.settings.trusted-users = [ "admin" ];
# ergonomics, just in case I need to ssh into
programs.zsh.enable = true;
environment.variables = {
SHELL = "zsh";
EDITOR = "neovim";
};
}
This is a big example, but a few key points should be made. SSH is enabled by
default to allow us to get a remote shell post-installation. Double check that
the IP address for the end0
interface is outside of your network's DHCP range
(if applicable), and that the gateway and nameserver IPs are valid for your
router and DNS setup.
Also, we're creating a user named admin
on the machine, and giving them access
to sudo
by adding them to the wheel
group. Change that if you don't want
sudo
access or I don't recommend
that, and doing so will prevent you from doing remote deployments (which is
covered in the last section of this tutorial). . Be sure, though,
to set the hashedPassword
attribute to the output of mkpasswd -m <hashing algorithm of choice> <your password here>
. This circumvents having to run
passwd
and manually set the password on the Raspberry Pi, so we'll be able to
just ssh
into the machine as admin
from the get-go.
Here's the hardware-configuration.nix
that I used. Unfortunately, from what I
can tell, there's no way to run nixos-generate-config
based on the hardware of
a different device, so I had to mostly copy+paste this from the Raspberry Pi 4
that already had NixOS installed on it. I'm also pretty sure this will work on
other models of Pi, but not 100% sure. Caveat emptor.
# hardware-configuration.nix
# Do not modify this file! It was generated by ‘nixos-generate-config’
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [ "xhci_pci" "usbhid" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ ];
boot.extraModulePackages = [ ];
fileSystems."/" =
{ device = "/dev/disk/by-label/NIXOS_SD"; # this is important!
fsType = "ext4";
options = [ "noatime" ];
};
swapDevices = [ ];
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
# (the default) this is the recommended approach. When using systemd-networkd it's
# still possible to use this option, but it's recommended to use it in conjunction
# with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
networking.useDHCP = lib.mkDefault true;
# networking.interfaces.end0.useDHCP = lib.mkDefault true;
# networking.interfaces.wlan0.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
}
Building the SD Image
For the custom image, we'll be building an SD image rather than an .iso
. This
allows us to just use the SD card that the Raspberry Pi uses as storage to
install the operating system. With an .iso
, we'd also need a USB drive to burn
the .iso
onto and then use as the boot media.
To build the SD image, we'll use the nixos-generators
tool. Also be sure to stage
all of your new files in Git if you haven't already, so that the Flake will be
able to find them. Now, we can run:
$ nix run nixpkgs#nixos-generators -- -f sd-aarch64 --flake .#pi --system aarch64-linux -o ./pi.sd
That'll chug for a while, but it should eventually spit out a symlink named
pi.sd
that links to a directory in /nix/store
containing the SD image. We'll
have to copy it out of the Nix store and decompress it before we can use it.
$ cd pi.sd/sd-image
$ ls
nixos-sd-image-24.05.20231124.5a09cb4-aarch64-linux.img.zst
$ cp nixos-sd-image-24.05.20231124.5a09cb4-aarch64-linux.img.zst ~/ && cd ~
$ unzstd -d nixos-sd-image-24.05.20231124.5a09cb4-aarch64-linux.img.zst -o nixos-sd-image.img
You should now see a file named nixos-sd-image.img
that's at least a few
gigabytes in size. That'll be our custom image that we'll install onto the SD
card.
Installing the SD Image
Grab your SD card, and insert it into whatever machine you've build the SD image
on. We'll need to find the name of the block device for the SD card, so we know
which device to copy the image data onto. You can do this with sudo fdisk -l
,
and the outputs should look like the following:
$ sudo fdisk -l
Device Boot Start End Sectors Size Id Type
/dev/mmcblk0p1 * 2048 2099199 2097152 1G c W95 FAT32 (LBA)
/dev/mmcblk0p2 2099200 31116287 29017088 13.9G 83 Linux
In that example, the name of the SD card block device is /dev/mmcblk0
. The
p1
and p2
are just partitions on the device, which we don't need to care
about. With that info, we can then run:
$ sudo dd if=~/nixos-sd-image.img of=/dev/mmcblk0 bs=1M status=progress
That's also going to chug along for a while, but you should see the data slowly
copy onto the device. After it's done, pop the SD card into the Raspberry Pi and
plug in the Ethernet and power cable. Don't worry about grabbing a keyboard and
monitor - this should work automagically! The red LED should be solid, and the
green LED will blink for a while. To double check that it boots and connects to
the network, run ping
on the IP that we put in the configuration.nix
:
$ ping 192.168.1.42
PING 192.168.1.42 (192.168.1.42) 56(84) bytes of data.
64 bytes from 192.168.1.42: icmp_seq=1 ttl=64 time=0.257 ms
64 bytes from 192.168.1.42: icmp_seq=2 ttl=64 time=0.386 ms
64 bytes from 192.168.1.42: icmp_seq=3 ttl=64 time=0.222 ms
^C
--- 192.168.1.42 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2087ms
rtt min/avg/max/mdev = 0.222/0.288/0.386/0.070 ms
We can also try to SSH into the machine with the password we created for the
admin
user:
$ ssh admin@192.168.1.42
The authenticity of host '192.168.1.42 (192.168.1.42)' can't be established.
ED25519 key fingerprint is SHA256:ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '192.168.1.42' (ED25519) to the list of known hosts.
(admin@192.168.1.42) Password:
Last login: Tue Jan 23 19:54:47 2024 from 192.168.1.120
[admin@pi:~] $
And we're good to go! The SD image isn't like an ISO install image; the operating system has already been fully installed as the SD card's filesystem and can be treated like any other server now.
Bonus: Installing New Configs Remotely
Another problem that arises when you have more than a few NixOS hosts is configuration drift. Flakes do help with this a little, since you can have every NixOS hosts' configuration as a different output of the same flake and use the flake as a kind of configuration monorepo. Even so, this usually means a workflow something like this:
- You want to make a change to one of your servers from the coziness of your desktop/laptop workstation.
- Update the Nix file associated with the server, and push the changes to your Git remote.
- Time to SSH into the server, pull the new changes, and run
nixos-rebuild
. - If there's any issues, you change the configuration from the server and rebuild until it's working.
This definitely works, but it can really bite you if you forget to push any changes to your Git remote after making any changes on the server. That's when configuration drift (and a lot of nasty merge conflicts) occur.
Thankfully, there's a few solutions that allow us to never leave the comfort of
our workstation when it's time to deploy a NixOS configuration. Unfortunately,
most of them are either unofficial, unmaintained, or both.
NixOps is a NixOS project, but it's de facto
abandoned.
Morph is another popular tool for deployments,
but it's not affiliated with Nix/NixOS officially and it doesn't look like it
supports Flakes. Thankfully,
nixos-rebuild
has a pretty simple flag, --target-host
, that allows us to
build a NixOS configuration on one machine and deploy it on another machine over
SSH.
To use the --target-host
flag, you'll need to double check that you have the
nix.settings.trusted-users
field in your configuration.nix
set to your user
(see the above example). You'll also have to use the --use-remote-sudo
flag.
This is needed because everything will be built on the local machine, and then
copied over into the /nix/store
of the target host. Of course, you can avoid
this whole song and dance by just using the root
user on the target host and
enabling root
access over SSH, but that's not recommended for security
reasons.
The magic command is then as follows:
$ nixos-rebuild switch --flake .#pi --target-host admin@192.168.1.42 --use-remote-sudo
In my experience, this works... but not flawlessy. If, like on most systems, you
require users to type in a password to use sudo
, you'll get hit with the
following error:
$ nixos-rebuild switch --flake .#pi --target-host admin@192.168.1.42 --use-remote-sudo
building the system configuration...
(admin@192.168.1.42) Password:
copying 0 paths...
sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required
Essentially, the sudo
running on 192.168.1.42
can't ask us for our password,
since it's running remotely rather than in the same TTY instance. To appease the
computer gods, we must invoke the magic env var NIX_SSHOPTS
:
$ NIX_SSHOPTS="-o RequestTTY=force" nixos-rebuild switch \
--flake .#pi \
--target-host admin@192.168.1.42 \
--use-remote-sudo
Here's where things get very odd, and I'm not sure if I'm running into a bug or I've just misconfigured something and tooling is tripping over it. Here's a walkthrough of what happens when I run that command:
$ NIX_SSHOPTS="-o RequestTTY=force" nixos-rebuild switch \
--flake .#pi \
--target-host admin@192.168.1.42 \
--use-remote-sudo
building the system configuration...
(admin@192.168.1.42) Password:
We build the NixOS configuration for pi
(I've already done so, hence the
lack of usual build logs). We then get prompted for the admin
password, so we
can SSH in and copy the build output to the target host. I type in my password
as usual.
...
copying 0 paths...
[sudo] password for admin:
I've already installed this NixOS configuration, so we don't need to copy any
new /nix/store
paths over. Regardless, I'm prompted for the admin
password
by sudo
as if I did (not the surprising part).
...
Shared connection to heracles.lab.janissary.xyz closed.
The program then hangs, until I press Ctrl-C.
...
[sudo] password for admin:
The program comes back to life, and I get prompted by sudo
for my password, again???
...
activating the configuration...
setting up /etc...
reloading user units for admin...
setting up tmpfiles
reloading the following units: -.mount
Shared connection to 192.168.1.42 closed.
...and the target host is able to install the new config, and restart just fine???
This is super duper weird, but it definitely works. I made small but detectable changes to the NixOS config and went through the flow again several times to be sure, and the end result was This felt very much like falling through that one illusory spike pit in Super Metroid . You think you've done something horribly wrong, but it's actually totally fine. .
Conclusion
In a previous blog post, I mentioned trying to install NixOS on a Raspberry Pi via the official ISO and running into weird issues. Thankfully, I was able to dig through some GitHub issues and documentation to find all the info I've laid out above. It worked like a charm for me when Nixifying all of my machines, and I hope it does for you too.
If you happen to have any thoughts/feedback/trouble with this tutorial, feel free to send me an email at the address in my About page.