Introduction

I’ve been running my home server on an old Raspberry Pi 3 B+ for some time now. Unfortunately, due to its limited RAM size of 1 GiB, I’ve been running into a number of problems that could only be solved by purchasing a more powerful machine.

Since I was already quite familiar with SBCs made by the Raspberry Pi Foundation, I naturally gravitated toward their newest offering: the Raspberry Pi 5 in its 16 GiB RAM variant. I figured upgrading my setup within the same vendor ecosystem would be quite easy.

My current home server runs on NixOS. While the NixOS project provides ready-to-use SD card installer scripts for older Raspberry Pi models, the situation is more complicated for the Raspberry Pi 5.

Furthermore, NixOS doesn’t seem to provide a kernel package for the Raspberry Pi 5, nor does it provide a U-Boot package. For the Raspberry Pi 3, both the kernel and U-Boot were provided out-of-the-box. This means custom packages will have to be defined for the installer to use.

Fortunately, the Raspberry Pi firmware package is common for all Raspberry Pi’s, so at least that is taken care of.

In summary, the following elements will need to be provided:

  1. Raspberry Pi 5 Linux kernel package
  2. Raspberry Pi 5 U-Boot package
  3. Raspberry Pi 5 SD card installer script

Raspberry Pi OS

Let’s start by inspecting the operating system provided by the Raspberry Pi Foundation, namely: Raspberry Pi OS, a Debian-derived Linux distribution. A ready-to-use image can be downloaded directly from the Foundation’s site:

% wget https://downloads.raspberrypi.com/raspios_arm64/images/raspios_arm64-2025-12-04/2025-12-04-raspios-trixie-arm64.img.xz -O raspios.img.xz
% unxz raspios.img.xz
% file raspios.img
raspios.img: DOS/MBR boot sector; partition 1 : ID=0xc, start-CHS (0x80,0,1), end-CHS (0x3ff,3,32), startsector 16384, 1048576 sectors; partition 2 : ID=0x83, start-CHS (0x3ff,3,32), end-CHS (0x3ff,3,32), startsector 1064960, 11534336 sectors

It looks like a standard disk image that we can copy to our target SD card. Let’s do that:

% dd if=raspios.img of=/dev/sdb oflag=sync bs=1M status=progress

Now for the interesting part: let’s check whether it will actually boot. We need to attach a serial console somewhere to the SBC. If this device works like the older Raspberry Pi models, then we can use the UART exposed on the GPIO header.

Let’s connect the FT232 UART adapter like so:

         GND <-> GND
GPIO14 (TxD) <-> FT232 RxD
GPIO15 (RxD) <-> FT232 TxD

Raspberry Pi 5 UART Wiring

Next, we can use picocom to actually interact with our serial console:

% picocom -b 115200 /dev/ttyUSB0

Unfortunately, it’s silent. But that was to be expected, as we still need to enable the UART console. We can do that by enabling UART in config.txt, and adjusting command line arguments passed to Linux in cmdline.txt.

Let’s start with config.txt. We need to use the enable_uart option, and provide a DTB overlay for the bootloader to use:

> enable_uart=1
> dtoverlay=uart0

Then, we need to switch the default console Linux uses to ttyAMA0, which is the default name for PL011 TTYs:

< console=serial0,115200 console=tty1 root=PARTUUID=efab4ffe-02 rootfstype=ext4 fsck.repair=yes rootwait resize quiet splash plymouth.ignore-serial-consoles
---
> console=ttyAMA0,115200 console=tty1 root=PARTUUID=26187812-02 rootfstype=ext4 fsck.repair=yes rootwait quiet splash plymouth.ignore-serial-consoles

With those changes in place, we can see the output from Linux on the serial console and verify that we can successfully reach getty:

Debian GNU/Linux 13 raspberrypi ttyAMA0

My IP address is 127.0.1.1 ::ffff:127.0.1.1

raspberrypi login: 

With that, we’ve verified that the HW is indeed functional, and we can move to actually creating an installer script using Nix.

Creating an initial installer

Let’s start by creating something basic: a boilerplate, but one that we can actually build with nix-build.

NixOS provides the sdImage derivation to create SD card images, so we’ll use that. We also have to set up cross-compilation, since the machine that we’re building the installer on is amd64, while the Raspberry Pi 5 is aarch64. The nixpkgs.crossSystem.system option will help us with that.

Finally, NixOS sets the boot.loader.grub.enable option to true by default. We won’t be using GRUB, so let’s disable it. Our rpi5-installer.nix looks as follows:

{ lib, config, pkgs, ... }:

{
  nixpkgs.crossSystem.system = "aarch64-linux";
  imports = [
    <nixpkgs/nixos/modules/installer/sd-card/sd-image.nix>
  ];

  boot.loader.grub.enable = false;

  sdImage = {
    compressImage = false;

    populateFirmwareCommands = ''
    '';

    populateRootCommands = ''
    '';
  };
}

The sdImage derivation compresses the image with zstd by default. We don’t want to deal with decompressing the image after every build, so let’s disable that as well.

The installer is still missing many required components, like Linux and U-Boot, and it doesn’t provide the config.txt file required by the bootloader, but let’s try building it right now anyway:

% nix-build '<nixpkgs/nixos>' -A config.system.build.sdImage -I nixos-config=./rpi5-installer.nix

This will take some time, as we need to cross-compile every package. Finally, we’re informed that the installer image has been created:

firmware_part.img: 1 files, 0/15321 clusters
61440+0 records in
61440+0 records out
31457280 bytes (31 MB, 30 MiB) copied, 0.11289 s, 279 MB/s
/nix/store/bppsx3vxq4bxrf6j997ymbvbqn62br09-nixos-image-sd-card-25.11.4882.fa83fd837f30-aarch64-linux.img-aarch64-unknown-linux-gnu

The SD card image is also available locally at ./result/sd-image/nixos-image-sd-card-25.11.4882.fa83fd837f30-aarch64-linux.img.

Let’s inspect its contents. First, we’l use losetup to set up a loop device, so that we can actually mount the individual partitions:

% losetup --partscan --find --show ./result/sd-image/nixos-image-sd-card-25.11.4882.fa83fd837f30-aarch64-linux.img
/dev/loop0

Next, let’s check the created block devices:

% ls /dev/loop0*
/dev/loop0  /dev/loop0p1  /dev/loop0p2

Finally, let’s mount and inspect the partitions:

% mkdir -p ./firmware ./rootfs
% mount /dev/loop0p1 ./firmware
% mount /dev/loop0p2 ./rootfs
% ls ./firmware
% ls ./rootfs
lost+found  nix  nix-path-registration

Right now, the firmware partition is empty. This was expected, since we didn’t provide the populateFirmwareCommands option with any commands. The rootfs partition, however, is already filled with packages from the Nix store:

ls ./rootfs/nix/store/ | wc -l
567

Let’s unmount the partitions and detach the loop device before proceeding:

% umount ./firmware ./rootfs
% losetup -d /dev/loop0

Populating the firmware

As mentioned in the introduction, the Raspberry Pi Firmware package provides firmware and device tree binary blobs. As of date, the raspberrypifw package is in version 1.20250430. Ultimately, the raspberrypifw package fetches the raspberrypi/firmware repository from GitHub. We can see that the 1.20250430 tag already contains BCM2712 device tree blobs, meaning - we can use the package as-is:

populateFirmwareCommands = ''
  pushd ${pkgs.raspberrypifw}/share/raspberrypi/boot
    # firmware blobs
    cp bootcode.bin fixup*.dat start*.dat $NIX_BUILD_TOP/firmware/

    # device tree blobs
    cp bcm2712*.dtb $NIX_BUILD_TOP/firmware/
  popd
'';

Now, let’s inspect the firmware partition again:

% losetup --partscan --find --show ./result/sd-image/nixos-image-sd-card-25.11.4882.fa83fd837f30-aarch64-linux.img
% mount /dev/loop0p1 ./firmware
% ls firmware
bcm2712d0-rpi-5-b.dtb  bcm2712-rpi-cm5-cm4io.dtb   bootcode.bin  fixup4x.dat   fixup_x.dat
bcm2712-d-rpi-5-b.dtb  bcm2712-rpi-cm5-cm5io.dtb   fixup4cd.dat  fixup_cd.dat
bcm2712-rpi-500.dtb    bcm2712-rpi-cm5l-cm4io.dtb  fixup4.dat    fixup.dat
bcm2712-rpi-5-b.dtb    bcm2712-rpi-cm5l-cm5io.dtb  fixup4db.dat  fixup_db.dat

Neat! But there are still two elements missing, namely: config.txt and cmdline.txt. For config.txt, we’ll have to use the writeText package to create a text file on the fly:

config = pkgs.writeText "config.txt" ''
  disable_overscan=1
  enable_uart=1
''

Then, we can just copy it in populateFirmwareCommands:

cp ${config} $NIX_BUILD_TOP/firmware/config.txt

At this point, the full rpi5-installer.nix would look as follows:

{ lib, config, pkgs, ... }:

{
  nixpkgs.crossSystem.system = "aarch64-linux";
  imports = [
    <nixpkgs/nixos/modules/installer/sd-card/sd-image.nix>
  ];

  boot.loader.grub.enable = false;

  sdImage = {
    compressImage = false;

    populateFirmwareCommands = let
      config = pkgs.writeText "config.txt" ''
        disable_overscan=1
        enable_uart=1
      '';
    in
      ''
        pushd ${pkgs.raspberrypifw}/share/raspberrypi/boot
          # firmware blobs
          cp bootcode.bin fixup*.dat start*.dat $NIX_BUILD_TOP/firmware/

          # device tree blobs
          cp bcm2712*.dtb $NIX_BUILD_TOP/firmware/
        popd

        # copy config.txt
        cp ${config} $NIX_BUILD_TOP/firmware/config.txt
      '';

    populateRootCommands = ''
    '';
  };
}

Let’s verify that we have indeed populated the partition with config.txt:

% cat ./firmware/config.txt
disable_overscan=1
enable_uart=1

Cool! As for cmdline.txt, we won’t have to provide this file. While for Raspberry Pi OS the bootloader stored on EEPROM does all the heavy lifting and boots Linux directly, we’ll rely on U-Boot to do that. Meaning, instead of having the original flow of:

  1. Startup code jumping to EEPROM bootloader
  2. EEPROM bootloader starting Linux

We’ll do:

  1. Startup code jumping to EEPROM bootloader
  2. EEPROM bootloader launching U-Boot, with U-Boot acting as a 2nd stage bootloader
  3. U-Boot starting Linux

U-Boot

To build U-Boot, we can use the buildUBoot package already provided by NixOS. We’ll rely on a generic ARM64 Raspberry Pi configuration for U-Boot, as it doesn’t ship with a Raspberry Pi 5 specific configuration file:

ubootRaspberryPi5_64bit = pkgs.buildUBoot {
  defconfig = "rpi_arm64_defconfig";
  extraMeta.platforms = [ "aarch64-linux" ];
  filesToInstall = [ "u-boot.bin" ];
};

Next, we need to copy the output binary specified by the filesToInstall option. The copying command will be part of the populateFirmwareCommands option of the sdImage derivation:

cp ${ubootRaspberryPi5_64bit}/u-boot.bin firmware/u-boot-rpi5.bin

Finally, we’ll have to instruct the EEPROM bootloader to launch U-Boot and load a device tree blob. We’ll do that by defining the kernel and device_tree options to the config.txt file:

kernel=u-boot-rpi5.bin
device_tree=bcm2712-rpi-5-b.dtb

Unfortunately, on Raspberry Pi 5, the UART exposed on the GPIO header is controller by the RP1 chip, which means we’d have PCIe up and running to see any output. PCIe won’t be available to us so early on the boot process, so we’ll have to switch to using the debug UART with the 3-pin JST-SH 1.0mm connector. First, let’s solder the JST-SH male connector to a 2.54 mm goldpin male connector:

JST-SH 1.0mm connector unsoldered JST-SH 1.0mm connector soldered

Next, we can connect it to the debug UART female connector. The debug UART is located between the two micro-HDMI connected to Raspberry Pi 5:

JST-SH 1.0mm connector connected to Raspberry Pi 5

Let’s build the SD card image, flash it and see if U-Boot works:

U-Boot 2025.10 (Oct 06 2025 - 19:13:09 +0000)

DRAM:  1020 MiB (total 16 GiB)
RPI 5 Model B (0xe04171)
Core:  25 devices, 11 uclasses, devicetree: board
MMC:   mmc@fff000: 0, mmc@1100000: 1
Loading Environment from FAT... Unable to read "uboot.env" from mmc0:1... 
In:    serial,usbkbd
Out:   serial,vidconsole
Err:   serial,vidconsole
Net:   No ethernet found.

starting USB...
No USB controllers found
       scanning usb for storage devices... 0 Storage Device(s) found
Hit any key to stop autoboot:  0 
U-Boot>
U-Boot> echo "Hello world!"
Hello world!

Seems to be working just fine! The rpi5-installer.nix at this step looks as follows:

{ lib, config, pkgs, ... }:

let
  ubootRaspberryPi5_64bit = pkgs.buildUBoot {
    defconfig = "rpi_arm64_defconfig";
    extraMeta.platforms = [ "aarch64-linux" ];
    filesToInstall = [ "u-boot.bin" ];
  };
in
{
  nixpkgs.crossSystem.system = "aarch64-linux";
  imports = [
    <nixpkgs/nixos/modules/installer/sd-card/sd-image.nix>
  ];

  boot.loader.grub.enable = false;

  sdImage = {
    compressImage = false;

    populateFirmwareCommands = let
      config = pkgs.writeText "config.txt" ''
        disable_overscan=1
        enable_uart=1
        kernel=u-boot-rpi5.bin
        device_tree=bcm2712-rpi-5-b.dtb
      '';
    in
      ''
        pushd ${pkgs.raspberrypifw}/share/raspberrypi/boot
          # firmware blobs
          cp bootcode.bin fixup*.dat start*.dat $NIX_BUILD_TOP/firmware/

          # device tree blobs
          cp bcm2712*.dtb $NIX_BUILD_TOP/firmware/
        popd

        # copy config.txt
        cp ${config} $NIX_BUILD_TOP/firmware/config.txt

        # copy uboot
        cp ${ubootRaspberryPi5_64bit}/u-boot.bin firmware/u-boot-rpi5.bin
      '';

    populateRootCommands = ''
    '';
  };
}

With that done, we can move forward to booting Linux.

Linux

Unlike U-Boot, there is some support for the Linux kernel for Raspberry Pi 5, just not in the main nixpkgs repository. Raspberry Pi 5 profile is hosted by the NixOS Hardware project. While it doesn’t provide a U-Boot package, it does provide a kernel for us to use.

Let’s start by adding the NixOS Hardware channel:

% nix-channel --add https://github.com/NixOS/nixos-hardware/archive/master.tar.gz nixos-hardware
% nix-channel --update

Next, we can add Raspberry Pi 5 profile to the import list:

imports = [
  <nixpkgs/nixos/modules/installer/sd-card/sd-image.nix>
  <nixos-hardware/raspberry-pi/5>
];

If we tried to build the image right now, we’d get the following initramfs error:

modprobe: FATAL: Module dw-hdmi not found in directory /nix/store/hrmcrm11myqzr7k0gxlhcy8fv7g01d2b-linux-rpi-aarch64-unknown-linux-gnu-6.12.47-stable_20250916-modules/lib/modules/6.12.47

Since dw-hdmi is not something we need to get Linux booting, we can use the boot.initrd.allowMissingModules to create a quick workaround for this:

boot.initrd.allowMissingModules = true;

We’re also going to enable extlinux to serve as a boot configuration menu, so that we can choose between NixOS generations:

boot.loader.generic-extlinux-compatible.enable = true;

Finally, we can populate rootfs (the /boot directory specifically) using an extlinux-compatible populate command:

populateRootCommands = ''
  mkdir -p ./files/boot
  ${config.boot.loader.generic-extlinux-compatible.populateCmd} -c ${config.system.build.toplevel} -d ./files/boot
'';

After rebuilding and reflashing the SD card, we can give booting Linux a go. Let’s boot into U-Boot and use the boot command:

U-Boot> boot
Cannot persist EFI variables without system partition
** Booting bootflow '<NULL>' with efi_mgr
Loading Boot0000 'mmc 0' failed
EFI boot manager: Cannot load any image
Boot failed (err=-14)
** Booting bootflow 'mmc@fff000.bootdev.part_2' with extlinux
------------------------------------------------------------
1:	NixOS - Default
Enter choice: 1:	NixOS - Default
Retrieving file: /boot/extlinux/../nixos/wfimd7gj9w3qkmx7win4572879qls3nf-linux-rpi-aarch64-unknown-linux-gnu-6.12.47-stable_20250916-Image
Retrieving file: /boot/extlinux/../nixos/88czmr4ly0ylyfz3rsz8irinc0c4djgm-initrd-linux-rpi-aarch64-unknown-linux-gnu-6.12.47-stable_20250916-initrd
append: init=/nix/store/rrnlsai61wslk3d7j0dhds1w29zx3qms-nixos-system-nixos-sd-card-25.11.5198.e576e3c9cf9b/init loglevel=4 lsm=landlock,yama,bpf
Retrieving file: /boot/extlinux/../nixos/wfimd7gj9w3qkmx7win4572879qls3nf-linux-rpi-aarch64-unknown-linux-gnu-6.12.47-stable_20250916-dtbs/broadcom/bcm2712-rpi-5-b.dtb
Moving Image from 0x80000 to 0x200000, end=0x22b0000
## Flattened Device Tree blob at 05600000
   Booting using the fdt blob at 0x5600000
Working FDT set to 5600000
   Loading Ramdisk to 1f475000, end 1ffff024 ... OK
   Loading Device Tree to 000000001f45e000, end 000000001f4743ac ... OK
Working FDT set to 1f45e000

At first, it correctly identifies and loads the kernel image and devicetree blob. Then, unfortunately, it crashes during early stages of Linux initialization:

[    0.240337] SError Interrupt on CPU3, code 0x00000000be000011 -- SError

U-Boot environment

After some research, it turned out that I have the 1.1 revision of the Raspberry Pi 5 board, which has the BCM2712D0 (D stepping) System-on-Chip, which the bcm2712-rpi-5-b.dtb device tree blob is incompatible with. Fortunately, there’s a bcm2712d0-rpi-5-b.dtb available for us to use

We can verify that Linux will boot correctly if we set the fdtfile environment variable in U-Boot to bcm2712d0-rpi-5-b.dtb. First, let’s inspect the environemnt ourselves:

U-Boot> printenv
[...]
fdtfile=broadcom/bcm2712-rpi-5-b.dtb

Then, let’s set it to our target:

U-Boot> setenv fdtfile broadcom/bcm2712d0-rpi-5-b.dtb
U-Boot>

Finally, let’s try booting Linux again:

U-Boot> boot

And it boots! We finally get to getty:

<<< Welcome to NixOS sd-card-25.11.5198.e576e3c9cf9b (aarch64) - ttyAMA10 >>>

Run 'nixos-help' for the NixOS manual.

nixos login: 

To automate this process, we can create an U-Boot environment, and install it during the populateFirmwareCommands step. Unfortunately, we need to provide the full environment, i.e. we can’t just change one variable. Let’s dump it with printenv and put it into a text file using writeText again:

ubootEnv = pkgs.writeText "uboot.env.txt" ''
  arch=arm
  baudrate=115200
  board=rpi
  board_name=5 Model B
  board_rev=0x17
  board_rev_scheme=1
  board_revision=0xE04171
  boot_targets=mmc usb pxe dhcp
  bootcmd=bootflow scan
  bootdelay=2
  bootm_size=0x20000000
  cpu=armv8
  dhcpuboot=usb start; dhcp u-boot.uimg; bootm
  ethaddr=88:a2:9e:87:9d:8b
  fdt_addr=2efec800
  fdt_addr_r=0x05600000
  fdtcontroladdr=3f730590
  fdtfile=broadcom/bcm2712d0-rpi-5-b.dtb
  kernel_addr_r=0x00080000
  kernel_comp_addr_r=0x02000000
  kernel_comp_size=0x03400000
  loadaddr=0x1000000
  preboot=pci enum; usb start;
  pxefile_addr_r=0x05500000
  ramdisk_addr_r=0x05700000
  scriptaddr=0x05400000
  serial#=f849b6d5b36eabd9
  soc=bcm283x
  stderr=serial,vidconsole
  stdin=serial,usbkbd
  stdout=serial,vidconsole
  usb_ignorelist=0x1050:*,
  usbethaddr=88:a2:9e:87:9d:8b
  vendor=raspberrypi
''; 

Now, let’s use mkenvimage command to create an environment image that U-Boot can read. Note that we’ll have to be careful here to use mkenvimage from the host machine architecture, i.e. the machine that we’re building the installer on, and not the target machine one. We can do that by referencing pkgs.buildPackages:

${pkgs.buildPackages.ubootTools}/bin/mkenvimage -s 16384 -o $NIX_BUILD_TOP/firmware/uboot.env ${ubootEnv}

With that done, we check if our environment is being used by U-Boot and whether we can boot:

Loading Environment from FAT... OK
[...]

<<< Welcome to NixOS sd-card-25.11.5198.e576e3c9cf9b (aarch64) - ttyAMA10 >>>

Run 'nixos-help' for the NixOS manual.

nixos login: 

Success! The final rpi5-installer.nix file is available here.