Raspberry Pi: Network boot with the root filesystem on iSCSI¶
AI-generated guide
This guide was written by an AI. I did not write the text myself. I did, however, carry out the procedure on a Raspberry Pi 3B with Raspberry Pi OS Lite 64-bit (Trixie), a Debian 13 Netboot VM on Proxmox and OPNsense with Dnsmasq, and the Pi now boots entirely over the network. The steps for the Pi 3B+, 4 and 5 are based on the official documentation but have not been tested by me.
The Pi boots over the network and uses a virtual disk of a Proxmox VM as its root filesystem. From the Pi's point of view it is a normal block device (/dev/sda with ext4), from Proxmox's point of view it is just another VM disk. That gives you:
- No SD card wear and no corrupted cards after power loss
- Snapshots and Proxmox Backup Server (PBS) backups of the Pi like of any VM
- Restoring a Pi = restoring a VM
How it works¶
| Step | What happens | Served by |
|---|---|---|
| 1. DHCP | Pi gets its IP and the address of the TFTP server (option 66) | OPNsense (Dnsmasq) |
| 2. TFTP | Firmware loads config.txt, kernel and initramfs from a folder named after the Pi's serial number |
Netboot VM |
| 3. iSCSI | initramfs logs into the iSCSI target and mounts it as / |
Netboot VM |
| 4. NFS | The TFTP folder is mounted as /boot/firmware, so kernel updates land where the Pi boots from |
Netboot VM |
Why not NFS for the root filesystem?
NFS root is simpler to set up, but it is a file share, not a disk. Some software (Docker overlay storage, SQLite, file locking) behaves differently on NFS. iSCSI is a block device, the Pi formats it with ext4 itself and everything behaves like a local disk. A whole-disk restore via PBS also matches the "Pi = VM" idea better.
Compatibility¶
| Pi 3B | Pi 3B+ | Pi 4 | Pi 5 | |
|---|---|---|---|---|
| Network boot lives in | Boot ROM (buggy) | Boot ROM (fixed) | EEPROM bootloader | EEPROM bootloader |
| Enable network boot | OTP bit, permanent | enabled from factory | boot order in EEPROM, changeable any time | same as Pi 4 |
| SD card in operation | tiny SD with only bootcode.bin (ROM bugs) |
none | none | none |
| First TFTP request | bootcode.bin (TFTP root) |
bootcode.bin (TFTP root) |
start4.elf in the serial-number folder |
files in the serial-number folder |
| Kernel / initramfs (64-bit Pi OS) | kernel8.img / initramfs8 |
kernel8.img / initramfs8 |
kernel8.img / initramfs8 |
kernel_2712.img / initramfs_2712 |
| Ethernet | 100 Mbit/s | ~300 Mbit/s (behind USB 2.0) | 1 Gbit/s | 1 Gbit/s |
| PoE HAT | no | yes | yes | yes |
Tested target OS: Raspberry Pi OS Lite 64-bit. Netboot VM: Debian 13.
Why is the OTP bit on the Pi 3B permanent?
OTP means one-time programmable: tiny fuses inside the SoC. A bit can be burned from 0 to 1 once, there is no circuit to reset it.
The Pi 3 has no writable bootloader storage. Network boot is part of the boot ROM inside the chip, and the only persistent place the ROM can read a setting from before any OS runs is the OTP memory.
Raspberry Pi did not enable the new boot modes by default because they first wanted to check that they work as expected. Later, bugs showed up in the ROM, which were fixed in the 3B+. The 3B+ ships with the bit already set.
Setting it is harmless in practice: the SD card is still tried first. USB and network are only tried if no bootable SD card is present.
The Pi 4 and 5 have their own EEPROM bootloader, so the boot order is a normal setting there.
Before you start¶
- A DHCP server that can send option 66 (TFTP server) to the Pi. This guide uses OPNsense with Dnsmasq. If you are still on ISC DHCP, I recommend migrating since ISC is end-of-life: OPNsense: Migrate from ISC DHCP to Dnsmasq. ISC DHCP, Kea or any other DHCP server that can set option 66 works as well, only the menu paths differ.
- The Netboot VM and the Pis must be in the same subnet. The Pi 3 boot ROM cannot reach a TFTP server through a router.
- One SD card with Raspberry Pi OS Lite 64-bit for the initial setup of each Pi.
Values in angle brackets like <HOST> are placeholders. Every step that uses one has a box Values used in this step that tells you where to get it.
Part 1: One-time setup¶
1.1 Create the Netboot VM¶
In the Proxmox GUI, on the node whose storage should hold the Pi disks:
| Setting | Value |
|---|---|
| OS | Debian 13 netinst, only SSH server + standard system utilities |
| CPU / RAM | 1 vCPU, 1 GB |
| SCSI controller | VirtIO SCSI single |
Disk scsi0 |
8 GB (system) |
| Network | same bridge/VLAN as the Pis |
| Options | Start at boot on, Start/Shutdown order 1 |
The disks for the Pis are added later, one per Pi.
Start order
The Pis cannot boot without this VM. Give it a low start order so it is up early after a node reboot.
1.2 Fixed IP for the VM (OPNsense)¶
Services → Dnsmasq DNS & DHCP → tab Hosts: add a fixed assignment for the VM (MAC from Proxmox → VM → Hardware → Network Device). Apply and restart the VM's network or reboot it.
From here on, this address is <VM-IP>. Example: 192.168.1.20.
1.3 Packages (VM)¶
apt install targetcli-fb dnsmasq nfs-kernel-server
Make the iSCSI configuration survive reboots:
systemctl enable --now rtslib-fb-targetctl.service
1.4 TFTP server (VM)¶
Check the interface name with ip a (usually ens18).
# TFTP only, no DNS and no DHCP
port=0
interface=ens18
bind-interfaces
enable-tftp
tftp-root=/srv/tftp
Create the TFTP root before restarting, otherwise dnsmasq refuses to start (TFTP directory /srv/tftp inaccessible):
mkdir -p /srv/tftp
systemctl restart dnsmasq
systemctl status dnsmasq --no-pager
1.5 TFTP server option (OPNsense)¶
Services → Dnsmasq DNS & DHCP
-
Tab DHCP tags →
+→ Name:rpi→ Save.Create the tag first
The Tag fields in the other dialogs are dropdowns that only list existing tags. Typing a new name there only searches and shows No results matched.
-
Tab DHCP options →
+(bottom right of the table):
| Field | Value |
|---|---|
| Interface | Any |
| Type | Set |
| Option | tftp-server [66] |
| Option6 | None |
| Tag | rpi (select from the dropdown) |
| Value | <VM-IP> |
| Force | off |
Apply. Only hosts with the rpi tag receive the TFTP server, other PXE clients in the network are not affected.
IP address, not a hostname
The Pi boot ROM has no DNS resolver. Option 66 must contain the IP address.
Different DHCP server
With ISC DHCP, Kea or another DHCP server, the goal is the same: the Pi's fixed assignment gets option 66 with the IP of the Netboot VM. Ideally only the Pis get it.
Why no ProxyDHCP?
Many guides run a second dnsmasq as ProxyDHCP next to the router. That exists for consumer routers that cannot send custom DHCP options. OPNsense can, so one DHCP server is enough. If a Pi hangs in DHCP or boot-loops, DHCP reply delay in Dnsmasq → General or a ProxyDHCP on the VM are possible workarounds.
Part 2: Per Pi¶
Repeat this part for every Pi.
The SD card is only a workbench
You set up the Pi on an SD card first. Later, the whole system on that card is copied 1:1 to the iSCSI disk (root filesystem) and to the TFTP folder (kernel, initramfs, boot config). Everything installed and configured on the SD card, including the iSCSI client, ends up in the network-booted system. After the switch the card is no longer used.
Start with a fresh installation. An old card with random software and settings on it would be copied as well.
2.1 Install the Pi from SD card (Pi)¶
-
Raspberry Pi Imager: Raspberry Pi OS Lite (64-bit), set hostname, user and SSH.
The hostname is
<HOST>from here on. Use lowercase letters, digits and hyphens only. Example:kitchen-pi. -
Boot the Pi from the SD card and update:
sudo apt update && sudo apt full-upgrade -y && sudo reboot
2.2 Fixed IP and tag for the Pi (OPNsense)¶
Get the MAC address of the Pi (line link/ether, e.g. b8:27:eb:12:34:56):
ip link show eth0
Services → Dnsmasq DNS & DHCP → tab Hosts: add a fixed assignment with this MAC and select rpi in Tag [set]. Apply.
This address is <PI-IP> from here on. Example: 192.168.1.50.
2.3 Enable network boot (Pi)¶
Check the OTP bit:
vcgencmd otp_dump | grep 17:
| Output | Meaning |
|---|---|
17:3020000a |
already enabled |
17:1020000a |
not enabled yet |
Enable it (permanent, see Compatibility):
echo "program_usb_boot_mode=1" | sudo tee -a /boot/firmware/config.txt
sudo reboot
Check again, it must show 17:3020000a. Then remove the line from /boot/firmware/config.txt so the card does not program other Pis by accident.
vcgencmd otp_dump | grep 17:
Must show 17:3020000a (factory default). If not, proceed as for the Pi 3B.
sudo raspi-config
- Advanced Options → Bootloader Version → Latest
- Advanced Options → Boot Order → Network Boot
- Exit and reboot.
Check:
sudo rpi-eeprom-config
BOOT_ORDER must contain a 2, e.g. 0xf21 = SD card, then network, then retry.
2.4 Add a disk for the Pi (Proxmox)¶
Netboot VM → Hardware → Add → Hard Disk:
| Setting | Value |
|---|---|
| Bus/Device | SCSI, next free number (first Pi scsi1, second Pi scsi2, ...) |
| Storage | SSD storage, ideally LVM-thin or ZFS so snapshots and thin provisioning work |
| Size | 32 GB, see below |
| Discard, SSD emulation | on |
On the VM, find the stable path of the new disk:
ls -l /dev/disk/by-id/ | grep scsi
This path is <DISK> from here on. Example: /dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1.
Does the disk have to match the SD card size?
No. The system is copied file by file, not as a disk image. The VM disk only has to hold the data actually in use plus room to grow, the size of the SD card does not matter. A fresh Raspberry Pi OS Lite uses about 2 to 3 GB, check with df -h / on the Pi.
The disk can be enlarged later while the Pi is running:
- Proxmox → Netboot VM → Hardware → disk → Disk Action → Resize
- On the Pi, let iSCSI pick up the new size and grow the filesystem:
sudo iscsiadm -m session --rescan sudo resize2fs /dev/sda
No partition needs to be resized because the disk has no partition table.
2.5 iSCSI target (VM)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
<DISK> |
path of the Pi's VM disk: run ls -l /dev/disk/by-id/ \| grep scsi on the VM |
/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1 |
<IQN-PREFIX> |
iqn. + year-month + . + a domain you own, reversed. Same for every Pi |
iqn.2026-10.com.example |
iSCSI identifies the target (VM side) and the initiator (Pi side) by an IQN. Both are built from the values above:
| Name | How to build it | Example |
|---|---|---|
| Target IQN | <IQN-PREFIX>:netboot.<HOST> |
iqn.2026-10.com.example:netboot.kitchen-pi |
| Initiator IQN | <IQN-PREFIX>:<HOST> |
iqn.2026-10.com.example:kitchen-pi |
What is an IQN?
iqn.2026-10.com.example:netboot.kitchen-pi
│ │ │ └─ free text
│ │ └─ a domain you own, reversed
│ └─ year and month you owned that domain
└─ fixed prefix
The date and domain only exist to make names globally unique. Neither the Pi nor the server checks them, they only compare the full string. Pick any domain you own and any date, and never change it afterwards.
Open targetcli:
targetcli
Run inside targetcli:
/backstores/block create name=<HOST> dev=<DISK>
/iscsi create <IQN-PREFIX>:netboot.<HOST>
/iscsi/<IQN-PREFIX>:netboot.<HOST>/tpg1/luns create /backstores/block/<HOST>
/iscsi/<IQN-PREFIX>:netboot.<HOST>/tpg1/acls create <IQN-PREFIX>:<HOST>
saveconfig
exit
/backstores/block create name=kitchen-pi dev=/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1
/iscsi create iqn.2026-10.com.example:netboot.kitchen-pi
/iscsi/iqn.2026-10.com.example:netboot.kitchen-pi/tpg1/luns create /backstores/block/kitchen-pi
/iscsi/iqn.2026-10.com.example:netboot.kitchen-pi/tpg1/acls create iqn.2026-10.com.example:kitchen-pi
saveconfig
exit
| Command | What it does |
|---|---|
backstores/block create |
registers the VM disk with the iSCSI service under an internal name |
iscsi create |
creates the target the Pi connects to |
luns create |
attaches the disk to the target |
acls create |
only an initiator with exactly this IQN may connect |
saveconfig |
saves everything |
Undo a step
Every create has a matching delete, e.g. /backstores/block delete <HOST>. Registering a backstore does not write anything to the disk itself. targetcli saves on exit by default, so a later delete is needed even after leaving the shell.
2.6 TFTP folder and NFS export (VM)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
<SERIAL> |
serial number of the Pi: run grep Serial /proc/cpuinfo on the Pi, take the last 8 characters, lowercase |
00000000a1b2c3d4 → a1b2c3d4 |
<PI-IP> |
fixed IP of the Pi: OPNsense → Dnsmasq → Hosts, or hostname -I on the Pi |
192.168.1.50 |
The Pi firmware requests its boot files from a TFTP folder named after its serial number. The real folder is named after the hostname, the serial number only becomes a symlink to it. Replacing a Pi later then means moving one symlink.
Why the serial number?
The boot firmware knows nothing about iSCSI or your configuration, it only knows its own serial number. Only the files loaded from that folder contain the iSCSI settings.
mkdir -p /srv/tftp/<HOST>
ln -s <HOST> /srv/tftp/<SERIAL>
ls -l /srv/tftp/
mkdir -p /srv/tftp/kitchen-pi
ln -s kitchen-pi /srv/tftp/a1b2c3d4
ls -l /srv/tftp/
ls must show the folder and the symlink pointing to it:
total 4
lrwxrwxrwx 1 root root 10 Oct 25 16:39 a1b2c3d4 -> kitchen-pi
drwxr-xr-x 2 root root 4096 Oct 25 16:41 kitchen-pi
| Entry | Meaning |
|---|---|
kitchen-pi (d at the start) |
real folder, the boot files go here later |
a1b2c3d4 -> kitchen-pi (l at the start) |
symlink named after the serial number, pointing to the folder. The number before the date is just the length of the target name |
Export the hostname folder (never the symlink) to the Pi:
/srv/tftp/<HOST> <PI-IP>(rw,sync,no_subtree_check,no_root_squash)
/srv/tftp/kitchen-pi 192.168.1.50(rw,sync,no_subtree_check,no_root_squash)
exportfs -ra
2.7 iSCSI client (Pi)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<IQN-PREFIX> |
iqn. + year-month + . + a domain you own, reversed. Same for every Pi |
iqn.2026-10.com.example |
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
<VM-IP> |
IP of the Netboot VM: OPNsense → Dnsmasq → Hosts, or ip a on the VM |
192.168.1.20 |
sudo apt install open-iscsi nfs-common
nfs-common is needed to mount the TFTP folder as /boot/firmware via NFS.
The IQNs must be exactly the ones used for the iSCSI target on the VM:
InitiatorName=<IQN-PREFIX>:<HOST>
InitiatorName=iqn.2026-10.com.example:kitchen-pi
ISCSI_INITIATOR=<IQN-PREFIX>:<HOST>
ISCSI_TARGET_NAME=<IQN-PREFIX>:netboot.<HOST>
ISCSI_TARGET_IP=<VM-IP>
ISCSI_TARGET_PORT=3260
ISCSI_INITIATOR=iqn.2026-10.com.example:kitchen-pi
ISCSI_TARGET_NAME=iqn.2026-10.com.example:netboot.kitchen-pi
ISCSI_TARGET_IP=192.168.1.20
ISCSI_TARGET_PORT=3260
Change the existing lines in /etc/iscsi/iscsid.conf:
node.session.timeo.replacement_timeout = -1 # queue I/O until the VM is back, never fail
node.conn[0].timeo.noop_out_interval = 0 # no keepalive pings, avoids recovery on short hiccups
node.conn[0].timeo.noop_out_timeout = 0 # same
The root session is created in the initramfs, so also set the timeout at boot:
w /sys/class/iscsi_session/session*/recovery_tmo - - - - -1
Why wait forever?
The default replacement_timeout is 120 seconds. A negative value means: keep I/O queued until the session is logged in again, without any limit.
| Timeout expires (default) | -1 |
|
|---|---|---|
| Pending writes | fail, data is lost | written as soon as the VM is back |
| Filesystem | ext4 switches to read-only | stays writable |
| Programs | crash or hang with errors | continue |
| VM back again | Pi stays broken, power-cycle needed | Pi recovers by itself |
While the VM is unreachable, programs that need the disk freeze, everything in RAM keeps running. The amount of unwritten data is limited by the kernel (vm.dirty_ratio): once the limit is reached, writing programs are paused, nothing is dropped. Data still in RAM is only lost if the Pi loses power during the outage.
There is no real downside: while the VM is gone, the Pi hangs either way. The only difference is that without a timeout it can recover by itself once the VM is back. With a timeout it stays broken and always needs a power-cycle.
Rebuild the initramfs and check:
sudo update-initramfs -u
grep auto_initramfs /boot/firmware/config.txt
ls /boot/firmware/initramfs*
auto_initramfs=1 must be set (add it otherwise) and a fresh initramfs8 (Pi 3/4) or initramfs_2712 (Pi 5) must exist.
2.8 Connect and format the iSCSI disk (Pi)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<VM-IP> |
IP of the Netboot VM: OPNsense → Dnsmasq → Hosts, or ip a on the VM |
192.168.1.20 |
<IQN-PREFIX> |
iqn. + year-month + . + a domain you own, reversed. Same for every Pi |
iqn.2026-10.com.example |
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
sudo systemctl restart iscsid
sudo iscsiadm -m discovery -t st -p <VM-IP>
sudo iscsiadm -m node -T <IQN-PREFIX>:netboot.<HOST> -p <VM-IP> --login
lsblk
sudo systemctl restart iscsid
sudo iscsiadm -m discovery -t st -p 192.168.1.20
sudo iscsiadm -m node -T iqn.2026-10.com.example:netboot.kitchen-pi -p 192.168.1.20 --login
lsblk
The new 32 GB disk is /dev/sda, the SD card is mmcblk0. Format the whole disk without a partition table:
sudo mkfs.ext4 -L piroot /dev/sda
sudo blkid /dev/sda
The value of UUID="..." is <UUID> from here on.
/dev/sda: LABEL="piroot" UUID="3f6c2b1e-8a4d-4c7e-9b21-5d0e7a9c4f12" BLOCK_SIZE="4096" TYPE="ext4"
2.9 Copy the system (Pi)¶
sudo mount /dev/sda /mnt
sudo rsync -aAXHx --info=progress2 / /mnt/
-x stays on the root filesystem. /boot/firmware, /proc, /sys, /dev and /run are created as empty folders only.
2.10 fstab on the iSCSI disk (Pi)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<UUID> |
UUID of the iSCSI disk: run sudo blkid /dev/sda on the Pi, value of UUID="..." |
3f6c2b1e-8a4d-4c7e-9b21-5d0e7a9c4f12 |
<VM-IP> |
IP of the Netboot VM: OPNsense → Dnsmasq → Hosts, or ip a on the VM |
192.168.1.20 |
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
Replace the whole file:
proc /proc proc defaults 0 0
UUID=<UUID> / ext4 defaults,noatime,_netdev 0 1
<VM-IP>:/srv/tftp/<HOST> /boot/firmware nfs defaults,_netdev 0 0
proc /proc proc defaults 0 0
UUID=3f6c2b1e-8a4d-4c7e-9b21-5d0e7a9c4f12 / ext4 defaults,noatime,_netdev 0 1
192.168.1.20:/srv/tftp/kitchen-pi /boot/firmware nfs defaults,_netdev 0 0
2.11 Boot files to the TFTP folder (Pi)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<VM-IP> |
IP of the Netboot VM: OPNsense → Dnsmasq → Hosts, or ip a on the VM |
192.168.1.20 |
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
<UUID> |
UUID of the iSCSI disk: run sudo blkid /dev/sda on the Pi, value of UUID="..." |
3f6c2b1e-8a4d-4c7e-9b21-5d0e7a9c4f12 |
sudo mkdir -p /mnt2
sudo mount -t nfs <VM-IP>:/srv/tftp/<HOST> /mnt2
sudo rsync -rlt /boot/firmware/ /mnt2/
sudo mkdir -p /mnt2
sudo mount -t nfs 192.168.1.20:/srv/tftp/kitchen-pi /mnt2
sudo rsync -rlt /boot/firmware/ /mnt2/
In cmdline.txt, change only the root device and add ip=dhcp. Everything else stays as it is (WiFi region, cloud-init settings from the Raspberry Pi Imager, etc.):
| Before | After |
|---|---|
root=PARTUUID=... (SD card) |
ip=dhcp root=UUID=<UUID> (iSCSI disk, network up in the initramfs) |
sudo sed -i 's|root=PARTUUID=[^ ]*|ip=dhcp root=UUID=<UUID>|' /mnt2/cmdline.txt
cat /mnt2/cmdline.txt
sudo sed -i 's|root=PARTUUID=[^ ]*|ip=dhcp root=UUID=3f6c2b1e-8a4d-4c7e-9b21-5d0e7a9c4f12|' /mnt2/cmdline.txt
cat /mnt2/cmdline.txt
The file must still be one single line:
console=serial0,115200 console=tty1 ip=dhcp root=UUID=3f6c2b1e-8a4d-4c7e-9b21-5d0e7a9c4f12 rootfstype=ext4 fsck.repair=yes rootwait cfg80211.ieee80211_regdom=DE ds=nocloud;i=rpi-imager-1234567890
What is ds=nocloud;i=...?
Since Raspberry Pi OS Trixie, the Raspberry Pi Imager applies its settings (user, SSH, WiFi, ...) via cloud-init on the first boot. This parameter tells cloud-init where its data is and which instance it belongs to. Keep it unchanged: cloud-init then knows the setup already ran and does not apply it again.
2.12 bootcode.bin in the TFTP root (VM, Pi 3B / 3B+ only, once)¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<HOST> |
hostname of the Pi: run hostname on the Pi |
kitchen-pi |
The Pi 3 needs bootcode.bin before anything else. Who reads it from where:
| Model | Reads bootcode.bin from |
Copy in the TFTP root needed? |
|---|---|---|
| Pi 3B+ | TFTP root (/srv/tftp/bootcode.bin) |
yes |
| Pi 3B with tiny SD card | the tiny SD card (step 2.13) | no, but it is the easiest source for creating that card |
| Pi 4 / 5 | not used | no |
Copy it from the Pi's boot files into the TFTP root:
cp /srv/tftp/<HOST>/bootcode.bin /srv/tftp/
cp /srv/tftp/kitchen-pi/bootcode.bin /srv/tftp/
If cp reports No such file or directory, your Raspberry Pi OS image does not ship the file. Get it from the official Raspberry Pi firmware repository instead:
wget -O /srv/tftp/bootcode.bin https://github.com/raspberrypi/firmware/raw/master/boot/bootcode.bin
bootcode.bin is not specific to a Pi: it contains no serial number, hostname or configuration and is identical for every Pi 3. One copy in the TFTP root serves all Pi 3 on this VM, so this step is only needed once. Keep it there, it is only about 50 KB.
2.13 Switch over (Pi)¶
Unmount everything and shut down the Pi:
sudo umount /mnt2 /mnt
sudo iscsiadm -m node -u
sudo poweroff
Keep the SD card with the full system as a fallback.
The Pi 3B needs a tiny SD card that contains nothing but bootcode.bin. Its boot ROM has network boot bugs, the newer bootcode.bin from the card works around them. The card is only read at boot, never written, so wear is not an issue. Any size works. Since bootcode.bin is the same for every Pi 3, the card is not tied to this Pi either: it works in any Pi 3B, and you can prepare several identical cards in advance.
Do this on a Linux PC.
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<VM-IP> |
IP of the Netboot VM: OPNsense → Dnsmasq → Hosts, or ip a on the VM |
192.168.1.20 |
<SD-DEVICE> |
the SD card: insert it and run lsblk, it is the new disk with the matching size |
/dev/sdc |
-
Get
bootcode.binfrom the VM:scp root@<VM-IP>:/srv/tftp/bootcode.bin .scp root@192.168.1.20:/srv/tftp/bootcode.bin . -
Prepare the card: one FAT32 partition, nothing else.
GUI: KDE Partition Manager or GNOME Disks → select the SD card → new partition table MS-DOS (MBR) → one partition over the whole card, filesystem FAT32 → apply. Then copy
bootcode.binto the card in the file manager.CLI:
Check
<SD-DEVICE>twiceThese commands erase the whole device. A wrong device name wipes another disk.
lsblk sudo umount <SD-DEVICE>* sudo wipefs -a <SD-DEVICE> sudo parted -s <SD-DEVICE> mklabel msdos mkpart primary fat32 1MiB 100% sudo mkfs.vfat -F 32 <SD-DEVICE>1 sudo mount <SD-DEVICE>1 /mnt sudo cp bootcode.bin /mnt/ ls /mnt sudo umount /mntlsblk sudo umount /dev/sdc* sudo wipefs -a /dev/sdc sudo parted -s /dev/sdc mklabel msdos mkpart primary fat32 1MiB 100% sudo mkfs.vfat -F 32 /dev/sdc1 sudo mount /dev/sdc1 /mnt sudo cp bootcode.bin /mnt/ ls /mnt sudo umount /mntls /mntmust show onlybootcode.bin. -
Insert the tiny SD card into the Pi. It stays there permanently.
Remove the SD card. Nothing else is needed.
Power on the Pi.
Verify¶
| Where | Command | Expected |
|---|---|---|
| VM | journalctl -u dnsmasq -f while the Pi boots |
Pi 3: sent .../bootcode.bin, then files from the serial-number folder. Pi 4/5: files from the serial-number folder directly |
| Pi 4/5 | HDMI screen during boot | diagnostics screen shows IP, TFTP server and serial number |
| Pi | sudo iscsiadm -m session |
one session to the target |
| Pi | findmnt / and findmnt /boot/firmware |
/dev/sda and NFS |
| Pi | cat /sys/class/iscsi_session/session1/recovery_tmo |
-1 |
Enable TRIM so deleted space is returned to the thin-provisioned VM disk:
sudo systemctl enable --now fstrim.timer
Optional tweaks¶
Swap only in RAM (Pi)¶
Since Raspberry Pi OS Trixie, swap is managed by rpi-swap. The default Mechanism=auto currently resolves to zram+file: a compressed swap area in RAM (zram), plus a file /var/swap that rarely used pages are written to later. With network boot, that file lives on the iSCSI disk, so every write goes over the network and a VM outage also freezes swapped-out programs.
I recommend keeping zram but dropping the file:
sudo mkdir -p /etc/rpi/swap.conf.d
printf '[Main]\nMechanism=zram\n' | sudo tee /etc/rpi/swap.conf.d/90-zram-only.conf
sudo reboot
All values for Mechanism
| Value | Behaviour |
|---|---|
auto (default) |
currently zram+file, may change in future releases |
zram |
compressed swap in RAM only, all [File] options are ignored |
zram+file |
zram plus /var/swap as writeback storage for idle pages |
swapfile |
classic swap file on the root filesystem |
none |
no swap, an existing swap file is removed |
After the reboot:
swapon --show
/dev/zram0 only. A leftover /var/swap can be deleted with sudo rm /var/swap.
Troubleshooting¶
| Symptom | Cause / fix |
|---|---|
| No TFTP requests in the dnsmasq log | Pi host lacks the rpi tag, option 66 missing, or Pi and VM in different subnets |
file .../<SERIAL>/... not found |
symlink missing or wrong. Check ls -l /srv/tftp/, serial in lowercase? |
| Pi 3B boots unreliably without the tiny SD card | expected, boot ROM bug. Use the SD card with bootcode.bin |
| Pi hangs in DHCP or boot-loops | boot ROM timing bug. Try DHCP reply delay in Dnsmasq → General |
| Pi 4/5 does not try network boot | BOOT_ORDER without 2, or outdated bootloader (step 2.3) |
Waiting for root device |
initramfs without iSCSI (step 2.7 done before 2.9?), or ACL IQN does not match the Pi IQN |
| Pi loses the connection shortly after boot | NetworkManager reconfigures eth0. Mark eth0 as unmanaged in NetworkManager |
| Boot fails after a kernel update | /boot/firmware was not mounted via NFS during the update |
Operation¶
| Situation | What to do |
|---|---|
| Short reboot of the Proxmox node | nothing, the Pi freezes and continues afterwards |
| Longer maintenance | shut down the Pis first |
| Pi hangs after an outage | power-cycle it. Pi 3B+/4/5 with a PoE HAT via the switch port, Pi 3B via a smart plug |
| Backup | the PBS backup of the Netboot VM contains all Pi disks (crash-consistent) |
| Restore | restoring the Netboot VM restores all Pis. For a single Pi: restore to a new VMID and reassign only its disk |
| Performance | the Pi 3B with 100 Mbit/s is not suited for databases or heavy I/O |
Recovery¶
The system does not live on the Pi, so almost everything can be repaired from the Netboot VM without touching the Pi hardware.
| Problem | Where to fix it |
|---|---|
Pi does not boot after a change to cmdline.txt or config.txt |
Fix the boot files |
Broken fstab, network config or other system file |
Fix files on the root disk |
| Broken package or system update, commands needed | Run commands in the Pi system |
| Everything broken | Roll back |
| You want to see what happens during boot | See the boot process |
Fix the boot files¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<HOST> |
hostname of the Pi, also the folder name in /srv/tftp/ on the VM: ls -l /srv/tftp/ |
kitchen-pi |
The boot files are plain files on the VM. Edit them directly, then power-cycle the Pi.
nano /srv/tftp/<HOST>/cmdline.txt
nano /srv/tftp/<HOST>/config.txt
nano /srv/tftp/kitchen-pi/cmdline.txt
nano /srv/tftp/kitchen-pi/config.txt
Fix files on the root disk¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<DISK> |
path of the Pi's VM disk: run ls -l /dev/disk/by-id/ \| grep scsi on the VM |
/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1 |
Only while the Pi is off
Never mount the disk on the VM while the Pi is using it. Two systems writing to the same ext4 filesystem destroy it.
- Shut down the Pi, or cut its power if it hangs.
-
On the VM, check that no iSCSI session is active anymore:
It must reporttargetcli sessions(no open sessions). After cutting the power this can take up to about a minute. -
Release the disk from the iSCSI service. As long as the target is active, it holds the disk exclusively and
mountfails with Can't open blockdev:targetcli saveconfig targetctl clearAffects all Pis
This stops the iSCSI target for every Pi on this VM. Running Pis freeze and continue once the target is restored in step 5.
-
Mount the disk, fix the file, unmount. Use the
by-idpath,/dev/disk/by-uuid/...does not exist on the VM because the Pi formatted the disk over iSCSI and the VM never noticed:
mount <DISK> /mnt
nano /mnt/etc/fstab
umount /mnt
mount /dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1 /mnt
nano /mnt/etc/fstab
umount /mnt
-
Restore the iSCSI target and check that it is back:
targetctl restore targetcli lstargetcli lsmust show the backstore, the target with its ACL and the LUN again. Without this step no Pi can boot. -
Power on the Pi.
Run commands in the Pi system¶
Values used in this step
| Placeholder | Where to get it | Example |
|---|---|---|
<DISK> |
path of the Pi's VM disk: run ls -l /dev/disk/by-id/ \| grep scsi on the VM |
/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1 |
<HOST> |
hostname of the Pi, also the folder name in /srv/tftp/ on the VM |
kitchen-pi |
When files alone are not enough (e.g. a broken package), you can run the Pi's own programs on the VM. The VM is x86, the Pi system is ARM, so qemu-user-static emulates the ARM binaries. Slow, but apt, dpkg and update-initramfs work.
Same rules as above: the Pi must be off (targetcli sessions shows (no open sessions)).
Release the disk from the iSCSI service (affects all Pis on this VM):
targetcli saveconfig
targetctl clear
Once, on the VM:
apt install qemu-user-static
Mount the disk, the boot files and the system folders, then switch into the Pi system:
mount <DISK> /mnt
mount --bind /srv/tftp/<HOST> /mnt/boot/firmware
for d in proc sys dev dev/pts; do mount --bind /$d /mnt/$d; done
chroot /mnt /bin/bash
mount /dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1 /mnt
mount --bind /srv/tftp/kitchen-pi /mnt/boot/firmware
for d in proc sys dev dev/pts; do mount --bind /$d /mnt/$d; done
chroot /mnt /bin/bash
Check that you are inside the Pi system, it must show aarch64:
uname -m
Repair, e.g. dpkg --configure -a or apt install --reinstall <package>. Then leave, unmount everything and restore the iSCSI target:
exit
umount -R /mnt
targetctl restore
targetcli ls
Roll back¶
| Method | When |
|---|---|
| Proxmox snapshot of the Netboot VM | quick undo after a risky change. Take the snapshot before the change |
| PBS restore | older state. Restoring the whole VM affects all Pis. For a single Pi: restore to a new VMID and move only its disk to the Netboot VM |
Shut down the affected Pis before rolling back. The boot files in /srv/tftp/ are on the VM's system disk and are rolled back with the VM, not with the Pi disk.
See the boot process¶
| Method | Extra hardware | What you see |
|---|---|---|
journalctl -u dnsmasq -f on the VM |
no | whether the Pi requests its boot files and which ones |
| Serial console | USB-UART adapter on the Pi's GPIO pins, connected to another computer nearby | the complete boot including initramfs, also when the network fails. console=serial0,115200 is already in cmdline.txt |
| Network KVM (e.g. PiKVM, NanoKVM, JetKVM) on HDMI | yes, permanently attached | screen and keyboard in the browser |
Kernel log over the network (netconsole, untested)
The kernel can send its messages via UDP to another host. On the Pi, netconsole is a kernel module, so it has to be in the initramfs and configured with the module syntax.
On the Pi:
echo netconsole | sudo tee -a /etc/initramfs-tools/modules
sudo update-initramfs -u
Append to cmdline.txt (same single line), with the IP of the VM (<VM-IP>, e.g. 192.168.1.20):
netconsole.netconsole=@/eth0,6666@<VM-IP>/
netconsole.netconsole=@/eth0,6666@192.168.1.20/
Listen on the VM:
apt install netcat-openbsd
nc -klu 6666
Messages start once the network driver is up, so very early boot errors are not covered. Not tested with this setup yet.
Replace a Pi¶
A new Pi has a new serial number and a new MAC address. The iSCSI disk, target and IQN stay the same, so the new Pi boots exactly the old system.
| Item | Changes? |
|---|---|
| iSCSI disk, target, IQN | no |
/etc/exports, fstab |
no (they point to the hostname folder) |
symlink <SERIAL> |
yes |
| MAC in OPNsense | yes |
- Shut down and disconnect the old Pi.
- Prepare the new Pi:
| Model | Preparation |
|---|---|
| Pi 3B | boot once from a normal SD card, set the OTP bit (step 2.3), note the serial. Then insert the tiny SD card with bootcode.bin |
| Pi 3B+ | boot once from a normal SD card and note the serial |
| Pi 4 / 5 | boot once from a normal SD card, enable network boot (step 2.3), note the serial |
- Move the symlink on the VM. Old and new serial: run
grep Serial /proc/cpuinfoon each Pi (booted from SD), last 8 characters, lowercase.<HOST>stays the same.
rm /srv/tftp/<OLD-SERIAL>
ln -s <HOST> /srv/tftp/<NEW-SERIAL>
rm /srv/tftp/a1b2c3d4
ln -s kitchen-pi /srv/tftp/e5f6a7b8
- OPNsense → Dnsmasq → Hosts → entry of the Pi → new MAC → Apply. IP and tag stay.
- Power on the new Pi.
Changing the model
64-bit Raspberry Pi OS ships the firmware and kernels for all models, so the same disk usually boots on a Pi 3, 4 or 5. Run sudo update-initramfs -u -k all on the old Pi first, so the iSCSI initramfs exists for every kernel. A Pi 3 additionally needs bootcode.bin in the TFTP root (step 2.12).
Alternative for Pi 4 / 5 only
The EEPROM setting TFTP_PREFIX can make the firmware use a fixed string or the MAC address instead of the serial number as folder name. The symlink works the same way for every model, so this guide sticks with it.
Sources¶
- Raspberry Pi blog: Pi 3 booting part I (OTP, boot modes)
- Raspberry Pi docs (fork): Network boot on the Pi 3
- nobodd: Netboot on the Pi
- openSUSE: netboot-rpi3b (DHCP options)
- Warmest Robot: Raspberry Pi Network Boot Guide 2.0
- zenn.dev: Raspberry Pi 4B PXE + iSCSI network boot
- Debian Wiki: SAN/iSCSI/LIO
- Red Hat: iSCSI root timeouts
- RFC 3721: iSCSI naming
- rpi-swap: swap.conf reference
- OPNsense docs: Dnsmasq DNS & DHCP