There are many reasons to have a Raspberry Pi running as part as your infrastructure. If you are using NixOS on most of your devices, you might want to run NixOS on your Raspberry Pi aswell, e.g. for a small home server or whatever you are using your Pi for.

This not only unifies your deployment across devices and makes the Pi one less "manual" piece of the puzzle, but also greatly reduces the installation time on the Raspberry, as compiling and installing packages after flashing the SD-card might take a long time on the Pi's hardware.

Guess what, you can use Nix to generate a configured image for you and just flash the configured system on the card! Just generate the image based on this template, flash it and start the Pi with your fully configured system.

I will be using a Raspberry 4 for this, but the same setup could be applied to other models. Nixpkgs already has a installer image generator for the Raspberry Pi 4, but it will just give you a base to install NixOS, not a system configured to your own liking.

Start by creating a file that will contain settings that apply to all devices of that model. I created this very basic template for the Raspberry Pi 4 and saved it as rpi4-image.nix:

# rpi4-image.nix
{ config, lib, pkgs, ... }: {
  nixpkgs.localSystem.system = "aarch64-linux";
  imports = [

    # Sd image settings
    <nixpkgs/nixos/modules/installer/cd-dvd/sd-image.nix>

    # Your configuration
    ./configuration.nix
  ];

  boot = {
    kernelPackages = pkgs.linuxPackages_rpi4;
    loader.grub.enable = false;
    loader.generic-extlinux-compatible.enable = true;
    consoleLogLevel = lib.mkDefault 7;

    # The serial ports listed here are:
    # - ttyS0: for Tegra (Jetson TX1)
    # - ttyAMA0: for QEMU's -machine virt
    kernelParams =
      [ "console=ttyS0,115200n8" "console=ttyAMA0,115200n8" "console=tty0" ];

    initrd.availableKernelModules = [
      # Allows early (earlier) modesetting for the Raspberry Pi
      "vc4"
      "bcm2835_dma"
      "i2c_bcm2835"
      # Allows early (earlier) modesetting for Allwinner SoCs
      "sun4i_drm"
      "sun8i_drm_hdmi"
      "sun8i_mixer"
    ];

  };

  sdImage = {
    populateFirmwareCommands = let
      configTxt = pkgs.writeText "config.txt" ''
        [pi3]
        kernel=u-boot-rpi3.bin
        [pi4]
        kernel=u-boot-rpi4.bin
        enable_gic=1
        armstub=armstub8-gic.bin
        # Otherwise the resolution will be weird in most cases, compared to
        # what the pi3 firmware does by default.
        disable_overscan=1
        [all]
        # Boot in 64-bit mode.
        arm_64bit=1
        # U-Boot needs this to work, regardless of whether UART is actually used or not.
        # Look in arch/arm/mach-bcm283x/Kconfig in the U-Boot tree to see if this is still
        # a requirement in the future.
        enable_uart=1
        # Prevent the firmware from smashing the framebuffer setup done by the mainline kernel
        # when attempting to show low-voltage or overtemperature warnings.
        avoid_warnings=1
      '';
    in ''
      (cd ${pkgs.raspberrypifw}/share/raspberrypi/boot && cp bootcode.bin fixup*.dat start*.elf $NIX_BUILD_TOP/firmware/)
      # Add the config
      cp ${configTxt} firmware/config.txt
      # Add pi3 specific files
      cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin firmware/u-boot-rpi3.bin
      # Add pi4 specific files
      cp ${pkgs.ubootRaspberryPi4_64bit}/u-boot.bin firmware/u-boot-rpi4.bin
      cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin firmware/armstub8-gic.bin
      cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-4-b.dtb firmware/
    '';
    populateRootCommands = ''
      mkdir -p ./files/boot
      ${config.boot.loader.generic-extlinux-compatible.populateCmd} -c ${config.system.build.toplevel} -d ./files/boot
    '';
  };
}

If you want to use a different device, you might want to lookt at hardware already supported by the installer and adapt the file above where needed. Some devices need kernel parameters to be set or specific modules blacklisted to work properly. The above works as-is for the Raspberry Pi 4 4GB version and will expand the root partition on first boot to the full size of the SD card.

Apart from some settings, it contains two imports: <nixpkgs/nixos/modules/installer/cd-dvd/sd-image.nix> and ./configuraton.nix

The first just simplifies the configuration by adding some sane defaults for sd-images (you probably already guessed so) the second will be where we put our actual configuration.

Create a file called configuration.nix and place it in the same folder. Here is a very basic example that just enables SSH. Edit this file as you please, it will be where you put your actual NixOS configuration and probably the only part you need to touch for the Raspberry Pi 4.

# configuration.nix
{ lib, pkgs, ... }: {
  systemd.network.enable = true;

  # Enable SSH with root login
  services.openssh = {
    enable = true;
    permitRootLogin = "yes";
  };

  users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAA...." ];
}

When finished, generate the image with the command below. This might take a few minutes on the first run, but will be a lot faster afterwards as it uses the cache to rebuild only where needed.

To be able to build the image for the ARM architecture on your desktop, you will need to enable the emulation for aarch64-linux. Just drop this in your system's /etc/nixos/configuration.nix and nixos-rebuild switch to it.

boot.binfmt.emulatedSystems = [ "aarch64-linux" ];

With it ready, start the build.

nix-build '<nixpkgs/nixos>' -A config.system.build.sdImage -I nixos-config=./rpi4-image.nix

The last line of the output should be a path to the nix-store, e.g. /nix/store/...-aarch64-linux.img. Despite it's suffix .img this is a directory which in turn contains a subdirectory called /sd-image/. In there you will find your freshly-baked image file, compressed with zst.

You can disable compression for the build process to speed up things, but if you used the examples above, this will write the image correctly to the card, assumed here to be /dev/sdX. Change the path to match your card and don't overwrite your harddrive with the image!

The following will obviously need root privileges:

zstd -vdcfT6 /nix/store/...-aarch64-linux.img/sd-image/...-aarch64-linux.img.zst  | dd of=/dev/sdX status=progress bs=64K

For good measure run sync when it's finished and plug the card in to the Pi. Upon powering, it should boot into your fully configured NixOS system and allow you to login with the SSH key you specified above.

Go have some 🥧!

Note: If you don't want to copy-paste everything, just use the GitHub template repository I created. It contains the exact same setup, plus a ./build.sh to save you from remembering the build command.