PXE netboot

The netboot workflow boots boards over PXE → NFS from a central server and falls back to local SD only on demand. The guiding invariant is always-netboot: every onboarded board always has a pxelinux.cfg on the RouterOS router, and a single default directive inside it selects the active boot mode.

Three pieces of state need to be in place before a board can come up on NFS:

  1. A custom PXE-first Armbian image — the U-Boot BOOT_TARGETS order is patched in at build time so the board reliably netboots from power-up.

  2. A per-host NFS rootfs carved out of that image on the netboot server, with hostname / machine-id / SSH host keys reset.

  3. PXE assets on the router — per-model kernel/initrd/dtb in /ip tftp, and a per-board pxelinux.cfg whose default directive selects nfs or sd.

The sections below walk through each piece with the workflow playbooks the collection ships and the file trees / rendered assets they produce. Every example threads the same concrete board — orange-pi-5-pro-01 with MAC aa:bb:cc:dd:ee:ff — so you can read the variables straight across.

Note

Early-stage 0.0.x — expect breaking changes between releases. Pin a specific version in your requirements.yml.

The setup

Install the collection plus the RouterOS reference dependencies:

ansible-galaxy collection install david_igou.armbian
ansible-galaxy collection install -r playbooks/routeros/requirements.yml

The examples below assume this inventory shape. Replace the four hosts (builder, netboot server, RouterOS router, board) with your own and adjust addresses; everything else is verbatim:

# inventory/hosts.yml
all:
  children:
    armbian_builders:
      hosts:
        builder-01:
          ansible_host: 192.0.2.5
    netboot_server:
      hosts:
        truenas-01:
          ansible_host: 192.0.2.10
          ansible_user: admin
          ansible_become: true
    routeros_router:
      hosts:
        rb5009:
          ansible_host: 192.0.2.1
          ansible_user: ansible-netboot
          ansible_port: 2222
    boards:
      children:
        orange_pi_5_pro:
          hosts:
            orange-pi-5-pro-01:
              ansible_host: 192.0.2.111
              ansible_user: armbian
              armbian_board_mac: "aa:bb:cc:dd:ee:ff"
              armbian_board_model: orange-pi-5-pro
              armbian_boot_mode: nfs
              armbian_poe_switch: rb5009
              armbian_poe_port: ether3

# inventory/group_vars/all.yml
armbian_server_ip: 192.0.2.10
armbian_nfs_rootfs_path: /srv/netboot/rootfs
armbian_nfs_assets_export: /srv/netboot/boot-files
armbian_tftp_flash_dir: sbc
armbian_tftp_cache_dir: "{{ playbook_dir }}/../.cache/sbc-tftp"
armbian_image_cache: /var/lib/armbian/cache
armbian_router: rb5009

# inventory/group_vars/orange_pi_5_pro.yml (model layer)
armbian_board_config_model:
  armbian_board_name: orangepi5pro
  armbian_dl_dir: orangepi5pro
  armbian_support: standard
  dtb: rockchip/rk3588s-orangepi-5-pro.dtb
  console: ttyS2,1500000
  earlycon: uart8250,mmio32,0xfeb50000
armbian_build_model:
  branch: current
  release: bookworm

The build

Stock Armbian images don’t boot PXE first; image_build produces a custom .img.xz with the PXE-first BOOT_TARGETS order baked into U-Boot at compile time, via an armbian/build userpatches overlay.

Drop the playbook below into playbooks/local/build.yml and run it. The vars block carries every value verbatim — no resolver primitives, no inventory layering — so you can read what the role actually consumes:

---
- name: Build a PXE-first Armbian image for orange-pi-5-pro-01
  hosts: builder-01
  gather_facts: true
  tasks:
    - ansible.builtin.include_role:
        name: david_igou.armbian.image_build
      vars:
        armbian_build_board:   orangepi5pro
        armbian_build_host:    orange-pi-5-pro-01
        armbian_build_branch:  current
        armbian_build_release: bookworm
        armbian_build_userpatches:
          - dest: config/boards/orangepi5pro.conf
            content: |
              function pre_config_uboot_target__orangepi5pro_pxe_first() {
                  declare -a t=("pxe" "dhcp" "mmc1" "mmc0" "nvme" "scsi" "usb" "spi")
                  sed -i -e "s/#define BOOT_TARGETS.*/#define BOOT_TARGETS \"${t[*]}\"/" \
                      include/configs/rockchip-common.h
              }
ansible-playbook playbooks/local/build.yml

The output lands at $HOME/armbian_build/output/orange-pi-5-pro-01/ on the builder:

$HOME/armbian_build/output/
└── orange-pi-5-pro-01/
    ├── Armbian_26.2.0-trunk_Orangepi5pro_bookworm_current_6.12.img.xz
    ├── Armbian_26.2.0-trunk_Orangepi5pro_bookworm_current_6.12.img.xz.sha
    └── manifest.json

The accompanying manifest.json records what was built; the role uses it as its idempotency key on subsequent runs:

{
  "patch_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "armbian_build_ref": "v26.2.0-trunk.844",
  "board":   "orangepi5pro",
  "branch":  "current",
  "release": "bookworm",
  "image_filename": "Armbian_26.2.0-trunk_Orangepi5pro_bookworm_current_6.12.img.xz",
  "built_at": "2026-05-30T12:34:56Z"
}

For the fleet workflow that runs this per board and publishes the result to the netboot server, use playbooks/build_and_publish_from_inventory.yml.

The rootfs

Each board gets its own NFS rootfs so two same-model boards have independent identity on the wire. rootfs_provision runs on the netboot server, downloads the .img.xz from the publish URL, extracts the rootfs into a per-host directory, stages kernel/initrd/dtb to a TFTP cache, and resets identity (hostname, machine-id, SSH host keys).

Drop this into playbooks/local/rootfs.yml:

---
- name: Provision the NFS rootfs for orange-pi-5-pro-01
  hosts: truenas-01
  become: true
  gather_facts: false
  tasks:
    - ansible.builtin.include_role:
        name: david_igou.armbian.rootfs_provision
      vars:
        armbian_rootfs_src:  "https://192.0.2.10/boot-files/images/orange-pi-5-pro-01/Armbian_26.2.0-trunk_Orangepi5pro_bookworm_current_6.12.img.xz"
        armbian_rootfs_host: orange-pi-5-pro-01
        armbian_rootfs_dtb:  rockchip/rk3588s-orangepi-5-pro.dtb
ansible-playbook playbooks/local/rootfs.yml

Afterwards the netboot server holds the per-host NFS rootfs and a per-host TFTP staging cache:

/srv/netboot/rootfs/orange-pi-5-pro-01/         # armbian_rootfs_target_dir
├── etc/
│   ├── hostname                                # → orange-pi-5-pro-01
│   ├── machine-id                              # zeroed (first-boot trigger)
│   └── ssh/
│       ├── ssh_host_rsa_key(.pub)              # freshly regenerated
│       ├── ssh_host_ecdsa_key(.pub)
│       └── ssh_host_ed25519_key(.pub)
├── var/lib/dbus/machine-id                     # zeroed
├── ...                                         # rest of extracted rootfs
└── .armbian_rootfs_provision_complete          # idempotency sentinel (JSON)

/var/lib/armbian/cache/sbc-tftp/orange-pi-5-pro-01/   # armbian_rootfs_tftp_dir
├── vmlinuz
├── initrd.img
└── board.dtb                                   # always staged under this fixed name

The sentinel records what the rootfs was provisioned from; re-running rootfs.yml is a no-op until src or host change (or you pass -e armbian_rootfs_force_refresh=true):

{
  "host": "orange-pi-5-pro-01",
  "provisioned_at": "2026-05-30T12:34:56Z",
  "src": "https://192.0.2.10/boot-files/images/orange-pi-5-pro-01/Armbian_26.2.0-trunk_Orangepi5pro_bookworm_current_6.12.img.xz",
  "src_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}

The PXE assets

Two pieces of router-side state make a board actually boot over the network: per-model TFTP assets registered in /ip tftp, and a per-board pxelinux.cfg keyed on the board’s MAC.

Per-model TFTP assets

stage_router.yml fetches the kernel/initrd/dtb staged by rootfs_provision down to a controller-side cache, then uploads them to the router. To do that explicitly for the demo board, first sync the three files from the netboot server:

mkdir -p .cache/sbc-tftp/orange-pi-5-pro
rsync -av \
  admin@192.0.2.10:/var/lib/armbian/cache/sbc-tftp/orange-pi-5-pro-01/{vmlinuz,initrd.img,board.dtb} \
  .cache/sbc-tftp/orange-pi-5-pro/

Then invoke the RouterOS reference playbook with the model list inline — no inventory wiring required:

ansible-playbook playbooks/routeros/upload_tftp_assets.yml \
  -e armbian_tftp_flash_dir=sbc \
  -e armbian_tftp_cache_dir=$PWD/.cache/sbc-tftp \
  -e '{"armbian_tftp_upload_models": ["orange-pi-5-pro"]}'

Afterwards the router’s flash holds the per-model directory with three /ip tftp rows registered against it:

flash:/sbc/armbian/
└── orange-pi-5-pro/
    ├── vmlinuz
    ├── initrd.img
    └── board.dtb

/ip tftp> print
 # REQ-FILENAME                            REAL-FILENAME                                IP-ADDRESSES
 0 armbian/orange-pi-5-pro/vmlinuz         flash/sbc/armbian/orange-pi-5-pro/vmlinuz    0.0.0.0/0
 1 armbian/orange-pi-5-pro/initrd.img      flash/sbc/armbian/orange-pi-5-pro/initrd.img 0.0.0.0/0
 2 armbian/orange-pi-5-pro/board.dtb       flash/sbc/armbian/orange-pi-5-pro/board.dtb  0.0.0.0/0

Per-board pxelinux.cfg

The per-board pxelinux.cfg is the file U-Boot fetches first; its default directive selects which label (and therefore which rootfs) the board boots. Render it locally with pxelinux_render — the role runs on localhost and writes one 01-<mac> file per board.

Drop this into playbooks/local/pxelinux.yml:

---
- name: Render the pxelinux.cfg for orange-pi-5-pro-01
  hosts: localhost
  gather_facts: false
  connection: local
  tasks:
    - ansible.builtin.include_role:
        name: david_igou.armbian.pxelinux_render
      vars:
        pxelinux_render_board_mac:     "aa:bb:cc:dd:ee:ff"
        pxelinux_render_boot_mode:     nfs
        pxelinux_render_nfs_server_ip: "192.0.2.10"
        pxelinux_render_nfs_root_path: /srv/netboot/rootfs
        pxelinux_render_hostname:      orange-pi-5-pro-01
        pxelinux_render_output_dir:    "{{ playbook_dir }}/../.cache/pxelinux.cfg"
        # pxelinux_render reads .console / .earlycon / .armbian_board_name
        # from armbian_board_config; supply it inline here so the template
        # has what it needs without a resolver step.
        armbian_board_config:
          armbian_board_name: orangepi5pro
          console:  "ttyS2,1500000"
          earlycon: "uart8250,mmio32,0xfeb50000"
ansible-playbook playbooks/local/pxelinux.yml

The role writes one file:

.cache/pxelinux.cfg/
└── 01-aa-bb-cc-dd-ee-ff     # default → nfs

The full rendered content of 01-aa-bb-cc-dd-ee-ff — every label that exists for this board, with default pointed at nfs:

# Ansible managed
# pxelinux.cfg for orange-pi-5-pro-01 (Orange Pi 5 Pro)
# MAC: aa:bb:cc:dd:ee:ff
# Active mode: nfs

default nfs
timeout 50
prompt  0

label nfs
  menu label Armbian NFS root (orange-pi-5-pro-01)
  kernel armbian/orange-pi-5-pro-01/vmlinuz
  initrd armbian/orange-pi-5-pro-01/initrd.img
  fdt    armbian/orange-pi-5-pro-01/board.dtb
  append root=/dev/nfs nfsroot=192.0.2.10:/srv/netboot/rootfs/orange-pi-5-pro-01,nfsvers=3,rw ip=dhcp console=ttyS2,1500000 rootwait rw

label sd
  menu label Armbian on SD (orange-pi-5-pro-01)
  kernel armbian/orange-pi-5-pro-01/vmlinuz
  initrd armbian/orange-pi-5-pro-01/initrd.img
  fdt    armbian/orange-pi-5-pro-01/board.dtb
  append root=LABEL=armbi_root rootfstype=ext4 rootwait rw console=ttyS2,1500000

label local
  menu label Armbian on local disk (orange-pi-5-pro-01)
  kernel armbian/orange-pi-5-pro-01/vmlinuz
  initrd armbian/orange-pi-5-pro-01/initrd.img
  fdt    armbian/orange-pi-5-pro-01/board.dtb
  append root=LABEL=armbi_root_local rootfstype=ext4 rootwait rw console=ttyS2,1500000

label local_kernel
  menu label Boot local kernel from NVMe (orange-pi-5-pro-01)
  localboot 0

Switching pxelinux_render_boot_mode to sd re-renders the same file with default sd — the label blocks themselves are unchanged.

Upload it to the router with the RouterOS reference playbook. The playbook reads each board’s MAC from hostvars[item].armbian_board_mac, so the board must be in inventory:

ansible-playbook playbooks/routeros/upload_pxelinux_cfg.yml \
  -e armbian_tftp_flash_dir=sbc \
  -e armbian_tftp_cache_dir=$PWD/.cache \
  -e '{"armbian_pxelinux_upload_boards": ["orange-pi-5-pro-01"]}'

Router-side, the pxelinux.cfg directory now holds one file per board, registered under a regex /ip tftp row so U-Boot’s MAC-prefixed request resolves to it:

flash:/sbc/pxelinux.cfg/
└── 01-aa-bb-cc-dd-ee-ff

/ip tftp> print where req-filename~"01-"
 # REQ-FILENAME                  REAL-FILENAME                              IP-ADDRESSES
 3 01-aa-bb-cc-dd-ee-ff          flash/sbc/pxelinux.cfg/01-aa-bb-cc-dd-ee-ff 0.0.0.0/0

Converge and verify

The workhorse playbook converge_boot_mode.yml composes everything above (plumbing check → render → upload → PoE cycle → wait → verify rootfs matches declared mode):

ansible-playbook playbooks/converge_boot_mode.yml \
  -e target_hosts=orange-pi-5-pro-01

To flip a board’s boot mode ad hoc without editing inventory:

ansible-playbook playbooks/set_boot_mode.yml \
  --limit orange-pi-5-pro-01 \
  -e armbian_boot_mode=sd

See the per-role reference (linked from the sidebar) for the full set of inputs each role accepts.