Files
OpenPXE/runbooks/linux-network-boot.md
T
2026-05-06 14:14:09 -04:00

13 KiB
Raw Blame History

Runbook: Boot a Linux machine from an ISO over the network

End-to-end walkthrough: spin up OpenPXE, load an Ubuntu (or any Linux) ISO into it, target a specific bare-metal or VM client by its MAC address, and have that machine PXE-boot the installer over the LAN — no USB stick, no console babysitting.

This runbook assumes:

  • You have one Linux host to run the OpenPXE container (any distro with Docker / Podman; 2 GB RAM, ~50 GB disk for the ISO library).
  • That host sits on the same broadcast domain / VLAN as the client you want to boot. PXE is L2-broadcast — routed/VLANd networks need a DHCP relay and are out of scope here.
  • An existing DHCP server is already handing out IP leases on that VLAN (your home router, OPNsense, Windows Server, etc.). OpenPXE runs as a DHCP proxy — it never leases IPs, it only layers the boot information on top of the existing DHCP exchange.
  • The target client is configured to PXE-boot in BIOS/UEFI firmware (usually F12 boot menu → Network, or set as first boot device).

If those dont hold, stop and read troubleshooting.md or docs/architecture.md first.


0. Pick your hosts LAN IP

You need the IPv4 address OpenPXE will advertise to clients. From the host:

ip -4 -o addr show | awk '{print $2, $4}'

Pick the address on the interface that faces the PXE VLAN — for example 10.0.0.5/24 on eno1. From here on we call it PXE_HOST_IP.

Why this matters. Every URL handed to clients (TFTP server, iPXE chain URL, ISO URL) is built from this IP. If OpenPXE auto-detects the wrong interface or loopback, clients will fetch from an unreachable address and silently fail. The startup will fail loudly if it can only auto-detect a loopback address.


1. Run OpenPXE

The MVP path is a single docker run against the published image, with --network host so the container can see DHCP broadcasts on the LAN.

mkdir -p ~/openpxe/isos ~/openpxe/work

docker run -d --name openpxe \
  --restart unless-stopped \
  --network host \
  -e OPENPXE_PUBLIC_IP=10.0.0.5 \
  -e OPENPXE_DHCP_MODE=proxy \
  -v ~/openpxe/isos:/var/lib/openpxe/isos \
  -v ~/openpxe/work:/var/lib/openpxe/work \
  ghcr.io/YOUR-ORG/openpxe:0.2.0

Substitute your OPENPXE_PUBLIC_IP, of course. If youre building from this repo instead of pulling, see the README quick start.

Verify its alive

curl -fsS http://10.0.0.5/healthz   # → 200 ok
curl -fsS http://10.0.0.5/readyz    # → 200 ready  (iPXE binaries present)
curl -fsS http://10.0.0.5/api/status | jq .

If /readyz is not 200, your container is missing iPXE binaries. Fix that before going further — clients have nothing to boot otherwise. See README — Container health probes.

Check the listening ports

OpenPXE holds three privileged UDP/TCP ports. From another shell on the host:

sudo ss -lnup | grep -E ':(67|69|4011)\b'   # DHCP proxy + TFTP
sudo ss -lntp | grep ':80\b'                # HTTP UI / boot scripts

All four should be present. If port 67 is taken by dnsmasq or the hosts own DHCP, stop that service or run OpenPXE on a separate box — two listeners on :67 will fight.


2. Load the ISO

Two options. Pick one.

  1. Open http://10.0.0.5/ in a browser.
  2. Sidebar → Storage.
  3. Click Upload ISO, pick e.g. ubuntu-24.04.1-live-server-amd64.iso.
  4. Wait for upload + introspection. The row turns into a card showing:
    • Distro family (debian_ubuntu)
    • Volume label
    • Detected kernel/initrd paths (/casper/vmlinuz, /casper/initrd)
    • File size and SHA-256

Big ISOs stream — there is no 2 GB limit, but expect upload to be throttled by your browser ↔ host link. The UI shows a progress bar; the animated anvil on the Dashboard tab fires up while imaging is in flight.

If you already have a folder of ISOs on the host, skip the browser:

# Dry run first — see what would be imported, no writes:
docker exec openpxe openpxe seed \
  --from /seed \
  --dry-run

# For real, mount the source dir read-only into the container:
docker run --rm \
  -v /my/iso-library:/seed:ro \
  -v ~/openpxe/isos:/var/lib/openpxe/isos \
  -v ~/openpxe/work:/var/lib/openpxe/work \
  -e OPENPXE_PUBLIC_IP=10.0.0.5 \
  ghcr.io/YOUR-ORG/openpxe:0.2.0 seed --from /seed

Each *.iso in /seed runs through the same upload pipeline as the web UI: copy → introspection → boot-entry generation → metadata sidecar. Re-running is idempotent.

Confirm the ISO is registered

curl -fsS http://10.0.0.5/api/isos | jq '.[] | {id, name, family, size}'

You should see something like:

{
  "id": "ubuntu-24-04-1-live-server-amd64",
  "name": "ubuntu-24.04.1-live-server-amd64.iso",
  "family": "debian_ubuntu",
  "size": 2748000000
}

The id is the slug. Remember it — youll bind a MAC to it in the next step.


3. Find the target machines MAC address

You need the MAC of the NIC that will PXE, not the OSs loopback or wifi.

3a. From the target itself (if its already running an OS)

ip -o link | awk '/ether/ {print $2, $17}'    # Linux

Pick the line for the wired NIC plugged into the PXE VLAN.

3b. From the firmware (if its a fresh box)

Most BIOS/UEFI screens display the NIC MAC during the network-boot attempt — usually as MAC: AA-BB-CC-DD-EE-FF flashing on the splash right before "PXE-E53: No boot filename received". Write it down.

3c. By letting it boot once and watching OpenPXE

Easiest if the box is in front of you:

  1. Power on, hit F12, pick Network boot.
  2. Without any binding configured, the client will land on the OpenPXE menu (Default / Installers / Tools / Queued Deployment).
  3. Dont pick anything. On your laptop:
    curl -fsS http://10.0.0.5/api/clients | jq .
    
  4. The most-recent entry is your target. Copy its mac.

From here on we call this MAC TARGET_MAC (e.g. aa:bb:cc:dd:ee:ff). Hyphens vs colons, upper vs lower case — OpenPXE normalizes both.


4. Pin that machine to the Ubuntu ISO

This is the per-MAC host binding. With it set, the client wont see the menu at all — it goes straight to the bound boot entry, Tinkerbell-style.

4a. Via the web UI

  1. Sidebar → Hosts.
  2. Add binding:
    • MAC: aa:bb:cc:dd:ee:ff
    • Target: pick ubuntu-24-04-1-live-server-amd64 from the dropdown.
    • Label: free-form, e.g. lab-rack3-node07.
  3. Save.

4b. Via the API

curl -fsS -X POST http://10.0.0.5/api/hosts \
  -H 'content-type: application/json' \
  -d '{
    "mac":    "aa:bb:cc:dd:ee:ff",
    "target": "ubuntu-24-04-1-live-server-amd64",
    "label":  "lab-rack3-node07"
  }' | jq .

The binding is persisted to ~/openpxe/work/hosts.json and survives container restart.

Confirm

curl -fsS http://10.0.0.5/api/hosts | jq '.[] | select(.mac=="aa:bb:cc:dd:ee:ff")'

You should see your entry with created_at and updated_at timestamps.


5. Trigger the network boot on the target

Now actually boot the machine.

5a. Boot order

In firmware setup, set the wired NIC as the first boot device (or hold F12 / F9 / Esc — vendor-specific — to pick "Network Boot" interactively).

5b. What you should see on the target screen

In order, with timing:

Stage Approximate duration What appears
Firmware DHCPDISCOVER ~1 s Start PXE over IPv4 / Station IP address …
TFTP iPXE binary fetch ~1 s TFTP… snponly.efi (or undionly.kpxe for legacy BIOS)
iPXE banner ~1 s The blue iPXE splash, version string
iPXE second-stage DHCP ~1 s Configuring (net0 …) then ok
HTTP boot script fetch <1 s http://10.0.0.5/boot.ipxe?mac=…
Per-MAC chain <1 s OpenPXE: per-MAC binding -> ubuntu-24-04-1-…
Kernel + initrd HTTP 530 s Two 200-OK fetches against /iso/<id>/casper/vmlinuz and …/initrd
Kernel boot 510 s Kernel banner, then the Ubuntu/cloud-init splash
Installer comes up 3060 s The distros normal Live/installer environment

If everything works, youre looking at the Ubuntu Server installer welcome screen end-to-end without ever touching a USB stick.

5c. Watch it from the server

In a third shell, tail the live log:

curl -N http://10.0.0.5/api/log/stream

Youll see each protocol step as it happens:

INFO openpxe::dhcp: reply mac=aa:bb:cc:dd:ee:ff arch=X8664Uefi target=tftp/snponly.efi
INFO openpxe::tftp: RRQ snponly.efi blksize=1468 windowsize=8 → 982 KiB in 412 ms
INFO openpxe::dhcp: reply mac=aa:bb:cc:dd:ee:ff (iPXE) target=http/boot.ipxe
INFO openpxe::http: GET /boot.ipxe?mac=aa:bb:cc:dd:ee:ff → host binding hit
INFO openpxe::http: GET /iso/ubuntu-…/casper/vmlinuz Range=bytes=0- 200 OK 14 MiB
INFO openpxe::http: GET /iso/ubuntu-…/casper/initrd  Range=bytes=0- 200 OK 75 MiB

The Terminal tab in the web UI shows the same thing live, plus a short whitelisted command palette (status, clients, gate, hosts, log).

5d. Internet-side ISO sources

The runbook title says “via the internet” — the client itself boots from your LAN, but the underlying ISO can come from anywhere your host can reach:

  • Direct upload from a remote workstation via the web UI (HTTPS reverse-proxied if you put OpenPXE behind nginx/Caddy).
  • NFS mount of a remote share — Sidebar → StorageNFSnfs://files.lab.example.com/exports/isos. Mounted ISOs show up in the same list and are PXE-bootable directly without copying.
  • Pre-seed from a CI job that curls a vendor mirror and runs openpxe seed --from.

OpenPXE itself never reaches out to the internet at boot time — all client traffic stays on the LAN, served from the host.


6. After the install

Once Ubuntu has finished installing to the targets disk, you want the next reboot to come up off the new local disk, not PXE again. Two ways:

6a. One-shot — release the binding

curl -fsS -X DELETE http://10.0.0.5/api/hosts/aa:bb:cc:dd:ee:ff

Without a binding, the client either gets the menu (BIOS still set to PXE first) or boots local disk normally.

6b. Permanent — pin to local disk

Re-bind to the reserved local-boot target:

curl -fsS -X POST http://10.0.0.5/api/hosts \
  -H 'content-type: application/json' \
  -d '{ "mac": "aa:bb:cc:dd:ee:ff", "target": "_local", "label": "lab-rack3-node07 (installed)" }'

Now if anyone hits F12 → Network by accident, OpenPXE replies with a script that says "chain back to local HDD" and the box boots its real OS instead of re-imaging itself. This is the safest default for production hardware.


7. Re-imaging — the “Queued Deployment” flow

Different scenario: you have a rack of 30 servers to image identically, all at once. Dont bind 30 MACs by hand. Use the gate.

  1. Dont create host bindings.
  2. PXE-boot every machine. They land on the menu.
  3. On each: select Queued Deployment. They get position #1, #2, …, #30 and start long-polling.
  4. In the UI: Forge Gate tab shows all 30 lined up. Pick the ISO, click Assign to all waiting.
  5. Every clients open long-poll wakes up at the same instant and chains the same boot script. They all start imaging simultaneously — the “horse race gate” opens.

The animated anvil widget on the Dashboard runs while any client is still in the kernel-fetch phase.


Cheat sheet

Goal Command
Health check curl http://$IP/healthz
List ISOs curl http://$IP/api/isos | jq .
List clients seen curl http://$IP/api/clients | jq .
Bind MAC → ISO POST /api/hosts with {mac,target,label}
Bind MAC → local disk same with target=_local
Release binding DELETE /api/hosts/<mac>
Live log curl -N http://$IP/api/log/stream
Prometheus metrics curl http://$IP/metrics
Bulk import folder openpxe seed --from /path

Where to look when things break

  • Client gets PXE-E53: No boot filename received — DHCP proxy isnt replying. Check :67 is bound (ss -lnup), check --network host, check the host firewall on UDP 67/69/4011.
  • iPXE shows No more network devices — firmware NIC isnt in PXE mode, or VLAN tagging is wrong.
  • iPXE prints Connection timed out (http://…)OPENPXE_PUBLIC_IP is wrong. Clients cant reach that IP. Check /api/statuspublic_base_url and ping it from the client subnet.
  • Kernel panics during initrd load — corrupt ISO upload. Check /api/isos, compare the SHA-256 to the vendors, re-upload.
  • Boot menu shows but the bound entry doesnt fire — the binding target slug doesnt match any ISO id. Recheck GET /api/hosts against GET /api/isos. The binding falls back to the menu on miss (by design — never lock a client out).
  • General confusion — Terminal tab → status, then log. That tells you what protocol stages have run and which havent.

For deeper protocol-level debugging, see docs/architecture.md.