Bare Metal Provisioning: PXE Boot, iPXE, and Fully Automated OS Installs
Somewhere in most homelabbers’ histories is a cardboard box of USB drives, each labeled in marker — “Ubuntu 22.04,” “Debian 12,” “Proxmox 8.1 (old).” Adding a new machine means hunting down the right drive, plugging it in, clicking through an installer, answering the same twenty questions you’ve answered a hundred times, and waiting. For one machine that’s an annoyance. For five machines it’s a Saturday. For fifty it’s a job.
There is a better way, and it has existed since 1998. PXE — the Preboot Execution Environment — lets a machine boot entirely over the network. The server powers on, a DHCP packet goes out, and minutes later an operating system is installed and configured, fully unattended, no USB drive in sight. Modern tooling built on top of PXE — iPXE, cloud-init, Ubuntu autoinstall, and preseed — makes this process reliable, scriptable, and fast.
This guide builds a complete bare-metal provisioning pipeline from the ground up: the network boot infrastructure, the bootloader, the automated OS installers for Ubuntu, Debian, and Rocky Linux, and the cloud-init layer that handles post-install configuration. By the end you will have a stack where plugging in a new server and powering it on is all that is required.
Part 1: Why Automate Bare Metal Provisioning?
The USB Drive Problem
Clicking through an OS installer is not just slow — it is a source of configuration drift from the very first boot. Every time you answer a partitioning question, choose a locale, or pick packages, there is a chance you’ll do it differently than last time. One server ends up with LVM, another with a flat partition table. One has a different admin username. One has password authentication enabled by mistake. These divergences compound over months into machines that are impossible to reason about.
Automated provisioning eliminates the human from the install path entirely. Every server gets the exact same configuration, defined in version-controlled files.
The Math
Provisioning one server manually takes roughly 20 minutes of active time: boot from USB, answer the installer, wait for packages, configure the basics. At 50 servers that is over 16 hours of repetitive work. With PXE and autoinstall, provisioning 50 servers takes 20 minutes — you power them all on simultaneously and walk away.
Real-World Use Cases
- K3s cluster expansion: adding three worker nodes to a homelab Kubernetes cluster without touching an installer
- Proxmox cluster nodes: spinning up identical hypervisor hosts with consistent storage and network config
- Rack deployments: a rack of 20 servers provisioned overnight before a project kickoff
- Hardware failure recovery: a failed server rebuilt from scratch in under 15 minutes with no manual steps
- Lab refresh: wiping and reprovisioning an entire lab environment for a new experiment
The goal is simple: plug in a server, power it on, and have a fully configured OS waiting for Ansible or Terraform to take over. No interaction required.
Part 2: How PXE Boot Works
PXE is a specification, not software. It defines how a network card can load a bootloader before the local OS exists. Here is the full sequence:
The PXE Boot Sequence
-
Server powers on. The NIC’s built-in PXE firmware broadcasts a
DHCPDISCOVERpacket on the network with a special vendor class identifier (PXEClient). -
DHCP server responds. The DHCP server returns an IP address along with two PXE-specific options:
next-server(option 66): the IP address of the TFTP serverfilename(option 67): the path to the bootloader file on the TFTP server
-
NIC downloads the bootloader via TFTP. TFTP (Trivial File Transfer Protocol, UDP port 69) is a primitive, unauthenticated file transfer protocol with no directory listing and no security — but it is what PXE requires for the initial bootstrap.
-
Bootloader runs. The bootloader (PXELINUX, GRUB, or iPXE) may download additional files — a configuration menu, a kernel (
vmlinuz), and an initial ramdisk (initrd.img). -
Kernel boots. The kernel and initrd load into memory. The initrd contains a minimal OS environment that runs the installer.
-
Installer runs. Depending on the OS, this is Debian’s installer (d-i), Ubuntu’s Subiquity, or Anaconda (RHEL/Rocky). With proper automation files pointed to via kernel cmdline parameters, the installer runs without human input.
BIOS vs UEFI
Legacy BIOS systems and UEFI systems use different bootloaders:
| BIOS / Legacy | UEFI | |
|---|---|---|
| Bootloader file | undionly.kpxe or pxelinux.0 |
ipxe.efi or grubnetx64.efi |
| DHCP client-arch | 0 | 7 (x86_64-EFI) or 9 (x86_64 HTTP) |
| Secure Boot | N/A | May require signed bootloader |
| Transfer speed | Slow (TFTP only) | Faster with HTTP boot |
Your DHCP server needs to detect which type of machine is asking and serve the correct filename. We will cover this in the dnsmasq configuration.
Network Requirements
A minimal PXE server needs:
- DHCP — to assign IPs and point to the TFTP server
- TFTP (UDP 69) — to serve the initial bootloader
- HTTP (TCP 80/443) — for larger payloads like kernels, initrds, and OS images (TFTP is too slow for files over a few megabytes)
The Limitation of Legacy PXE
Stock PXE firmware is primitive: TFTP only, no HTTPS, no scripting, no menus beyond what PXELINUX provides, and a 32 KB packet size limit that makes loading anything substantial painful. This is why almost everyone using PXE at scale moves to iPXE.
Part 3: iPXE — The Modern Upgrade
What iPXE Is
iPXE is an open-source network boot firmware that replaces (or chainloads from) the built-in PXE ROM in your NIC. It supports:
- HTTP and HTTPS for downloading boot files (dramatically faster than TFTP)
- iSCSI, AoE, FCoE for network block device boot
- A full scripting language for complex boot menus and logic
- DNS, NTP, VLAN, LACP — real network features
- Chainloading — starting from legacy PXE and handing off to iPXE
iPXE Boot Methods
Method 1: Chainload from existing PXE (most common)
Configure DHCP to serve undionly.kpxe (BIOS) or ipxe.efi (UEFI) as the bootfile. The machine boots the legacy PXE ROM, downloads the iPXE image via TFTP, and iPXE takes over. From that point forward, iPXE handles everything over HTTP.
Method 2: Flash into NIC firmware
Some NICs support flashing custom firmware. iPXE can be embedded permanently. This is common in data centers where you control the hardware.
Method 3: Custom ISO or USB
Build a bootable ISO containing iPXE. Used for machines that cannot PXE boot natively or when you want iPXE without network infrastructure changes.
iPXE Scripting
iPXE scripts are plain text files beginning with #!ipxe. The key commands:
|
|
A Complete iPXE Boot Menu
This menu presents multiple OS options and is served from your HTTP server:
|
|
Netboot.xyz: The Pre-Built All-in-One Menu
Netboot.xyz is a maintained iPXE menu that provides installers for virtually every major OS: Ubuntu, Debian, Fedora, Arch, Alpine, Proxmox, Rancher, and more. Instead of managing individual kernel and initrd files, you boot into netboot.xyz and pick an OS from an extensive menu.
Self-hosting netboot.xyz with Docker:
|
|
Self-hosting netboot.xyz gives you a web UI to manage menus, the ability to serve your own custom menus alongside the standard ones, and local caching of OS images so you’re not hitting upstream mirrors every time.
Part 4: Setting Up the PXE Infrastructure
Directory Layout
Before configuring services, establish your file layout:
/srv/tftp/
├── ipxe/
│ ├── undionly.kpxe # BIOS chainload target
│ └── ipxe.efi # UEFI chainload target
└── boot.ipxe # entry point — chain to HTTP
/srv/http/
├── boot.ipxe # iPXE menu (main)
├── boot/
│ ├── ubuntu/
│ │ ├── 22.04/
│ │ │ ├── vmlinuz
│ │ │ └── initrd
│ │ └── 24.04/
│ │ ├── vmlinuz
│ │ └── initrd
│ ├── debian/
│ │ └── 12/
│ │ ├── vmlinuz
│ │ └── initrd.gz
│ └── rocky/
│ └── 9/
│ ├── vmlinuz
│ └── initrd.img
├── autoinstall/
│ ├── ubuntu2204/
│ │ ├── user-data # Ubuntu autoinstall config
│ │ └── meta-data # required but can be empty
│ └── ubuntu2404/
│ ├── user-data
│ └── meta-data
├── preseed/
│ └── debian12.cfg # Debian preseed file
└── kickstart/
└── rocky9.ks # Rocky Linux Kickstart file
The TFTP root holds only the minimum: the iPXE binaries and a tiny bootstrap script that immediately chains to HTTP. Everything else is served over HTTP.
DHCP Configuration with dnsmasq
dnsmasq is the most popular homelab choice because it combines DHCP and TFTP in one lightweight service with excellent PXE support.
|
|
The critical trick here is the dhcp-userclass=set:ipxe,iPXE line. Legacy PXE clients announce themselves without this class. iPXE, once running, announces itself with the iPXE user class. By serving different dhcp-boot values based on whether the client is already running iPXE, you avoid an infinite chainload loop:
- BIOS machine → gets
undionly.kpxe→ loads iPXE - Machine now running iPXE → gets
http://192.168.1.10/boot.ipxe→ loads menu
pfSense / OPNsense
If your router runs pfSense or OPNsense, you can configure PXE directly in the DHCP server settings:
- pfSense: Services → DHCP Server → select your LAN interface → scroll to “Network Booting” → enable, set Next Server and Default BIOS file name
- OPNsense: Services → DHCPv4 → your interface → Network Booting section
For proper BIOS/UEFI detection you will want a separate dnsmasq instance or use pfSense’s “Additional BOOTP/DHCP Options” to set option 93 (client-arch) conditionally. In practice, many homelab setups run a dedicated dnsmasq container and configure the router’s DHCP to point DHCP option 66/67 at it — or disable the router’s DHCP entirely and let dnsmasq handle it.
ISC DHCP (dhcpd)
If you run ISC DHCP (isc-dhcp-server), the relevant options look like this:
# /etc/dhcp/dhcpd.conf
subnet 192.168.1.0 netmask 255.255.255.0 {
range 192.168.1.100 192.168.1.200;
option routers 192.168.1.1;
option domain-name-servers 192.168.1.1;
next-server 192.168.1.10; # TFTP server IP
# BIOS boot file
if option arch = 00:00 {
filename "undionly.kpxe";
# UEFI boot file
} elsif option arch = 00:07 or option arch = 00:09 {
filename "ipxe/ipxe.efi";
} else {
filename "undionly.kpxe";
}
}
TFTP Server (standalone)
If you want a standalone TFTP server rather than dnsmasq’s built-in:
|
|
HTTP Server: nginx
Serve kernels, initrds, and config files over HTTP. nginx as a simple file server:
|
|
Acquiring iPXE Binaries
Download pre-built iPXE binaries from boot.ipxe.org, or build from source to embed custom scripts:
|
|
Embedding the chainload URL in the binary means DHCP only needs to serve the iPXE binary itself — the menu URL is baked in, which simplifies DHCP configuration.
Extracting Kernels and Initrds
For Ubuntu, extract from the live server ISO:
|
|
For Debian, use the netboot tarball:
|
|
For Rocky Linux, use the network install kernel from the DVD ISO or netinstall image:
|
|
Part 5: Ubuntu Autoinstall
How Autoinstall Works
Ubuntu 20.04 and later use Subiquity as the installer, with a new automated install format called “autoinstall.” It replaces the older preseed format entirely for modern Ubuntu releases.
When the Ubuntu installer boots, it looks for autoinstall configuration via the kernel command line. The parameter ds=nocloud-net;s=http://... tells cloud-init where to find a user-data file. Subiquity reads that file and proceeds with a fully unattended install.
The URL must point to a directory (ending with /) that contains:
user-data— the autoinstall configuration (YAML, cloud-init based)meta-data— instance metadata (can be an empty file, but must exist)
A Complete user-data for Ubuntu 22.04
|
|
The meta-data File
The meta-data file must exist at the same URL path as user-data. It can be empty or contain a minimal instance ID:
|
|
If you are serving multiple servers from the same URL, meta-data can be empty. If you need per-machine instance IDs (to force cloud-init to re-run after a reprovision), set a unique ID per machine.
iPXE Stanza for Ubuntu Autoinstall
The kernel cmdline is the critical piece that links the iPXE boot to the autoinstall config:
|
|
The \; escaping is important — iPXE interprets ; as a command separator, so the semicolon in the ds= parameter must be escaped.
The --- at the end of imgargs is the Subiquity separator between kernel arguments and installer arguments.
Per-Host Autoinstall with MAC-Based URLs
Serve different user-data to different servers based on MAC address:
|
|
Your HTTP server at http://192.168.1.10/autoinstall/aa:bb:cc:dd:ee:ff/ serves a user-data that sets the correct hostname, IP, and role for that specific machine.
Part 6: Debian Preseed
Overview
Debian’s installer (d-i, or “debian-installer”) has used the preseed format since Debian 4. It remains the standard for Debian 12 and older Ubuntu releases. Preseed files are flat key-value pairs in a specific format read by the installer before and during the installation process.
A Complete Debian 12 Preseed File
# /srv/http/preseed/debian12.cfg
# Debian 12 Bookworm — fully unattended preseed
### Localization
d-i debian-installer/locale string en_US.UTF-8
d-i keyboard-configuration/xkb-keymap select us
### Network
d-i netcfg/choose_interface select auto
d-i netcfg/get_hostname string debian-node
d-i netcfg/get_domain string lunarops.local
d-i netcfg/wireless_wep_key_type select Open
d-i hw-detect/load_firmware boolean true
### Mirror
d-i mirror/country string manual
d-i mirror/http/hostname string deb.debian.org
d-i mirror/http/directory string /debian
d-i mirror/http/proxy string
### Clock and timezone
d-i clock-setup/utc boolean true
d-i time/zone string America/Chicago
d-i clock-setup/ntp boolean true
### Partitioning — automatic LVM layout
d-i partman-auto/method string lvm
d-i partman-lvm/device_remove_lvm boolean true
d-i partman-md/device_remove_md boolean true
d-i partman-lvm/confirm boolean true
d-i partman-lvm/confirm_nooverwrite boolean true
# Use the largest available disk
d-i partman-auto/disk string /dev/sda
d-i partman-auto-lvm/new_vg_name string data
d-i partman-auto-lvm/guided_size string max
# Partition recipe: /boot (512MB) + LVM for root
d-i partman-auto/choose_recipe select atomic
# Confirm partitioning without prompts
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish
d-i partman/confirm boolean true
d-i partman/confirm_nooverwrite boolean true
### Users
# Root account — disable root login, use sudo instead
d-i passwd/root-login boolean false
# Create admin user
d-i passwd/user-fullname string Admin User
d-i passwd/username string admin
# Password hash — generate with: openssl passwd -6 'yourpassword'
d-i passwd/user-password-crypted password $6$rounds=4096$randomsalt$hashedpasswordhere
d-i passwd/user-default-groups string sudo
### Package selection
tasksel tasksel/first multiselect standard, ssh-server
d-i pkgsel/include string \
curl wget git vim htop jq \
sudo openssh-server \
qemu-guest-agent \
ca-certificates \
unattended-upgrades \
apt-transport-https
# Do not install recommended packages (keep install lean)
d-i pkgsel/install-recommends boolean false
d-i pkgsel/upgrade select full-upgrade
### Grub bootloader
d-i grub-installer/only_debian boolean true
d-i grub-installer/bootdev string default
### Post-install commands
d-i preseed/late_command string \
in-target sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config; \
in-target sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config; \
echo "admin ALL=(ALL) NOPASSWD:ALL" > /target/etc/sudoers.d/admin; \
chmod 440 /target/etc/sudoers.d/admin; \
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyReplaceWithYourActualPublicKey admin@lunarops" \
>> /target/home/admin/.ssh/authorized_keys; \
in-target chown -R admin:admin /home/admin/.ssh; \
in-target chmod 700 /home/admin/.ssh; \
in-target chmod 600 /home/admin/.ssh/authorized_keys
### Finish up — no eject prompt, just reboot
d-i finish-install/reboot_in_progress note
Preseed vs Autoinstall
| Preseed | Autoinstall | |
|---|---|---|
| Format | Key-value pairs (d-i format) | YAML (cloud-init based) |
| Ubuntu support | Ubuntu 18.04 and older | Ubuntu 20.04+ |
| Debian support | All versions | Not supported |
| Scripting | Limited (late_command as semicolon-joined shell) |
Richer (late-commands list, user-data subkey) |
| Debugging | Harder — installer logs in /var/log/installer/ |
cloud-init status --long, clearer error messages |
| Storage config | partman directives | Full YAML disk layout model |
For new Ubuntu deployments, prefer autoinstall. For Debian, preseed is your only option.
Part 7: RHEL / CentOS / Rocky Linux: Kickstart
What Kickstart Is
Kickstart is Red Hat’s automated installation format, used by Anaconda (the installer behind RHEL, Fedora, Rocky Linux, AlmaLinux, and CentOS Stream). The Kickstart file is a script-like configuration that covers everything from partitioning to package selection to post-install shell scripts.
A Complete Kickstart File for Rocky Linux 9
|
|
Kickstart Kernel Parameter
Reference the Kickstart file via the inst.ks= kernel parameter:
|
|
Part 8: Cloud-init for Post-Install Configuration
What Cloud-init Is
Cloud-init is the industry standard for configuring instances on first boot. AWS, GCP, Azure, Hetzner, and DigitalOcean all use it. But it works equally well on bare metal — you can feed it configuration via a mounted ISO, a local file, or an HTTP URL.
Cloud-init separates the OS install (handled by the installer) from the post-install configuration (handled by cloud-init on first boot). This separation means you can have a minimal autoinstall/preseed/Kickstart that provisions a base OS, then cloud-init handles the environment-specific configuration: users, SSH keys, packages, services, and custom files.
Data Sources
Cloud-init discovers its configuration from a “data source.” The relevant ones for bare metal:
- NoCloud: reads from a locally mounted disk or ISO (
/var/lib/cloud/seed/nocloud/) - NoCloud-Net: fetches from an HTTP URL specified via kernel cmdline (
ds=nocloud-net;s=http://...) - Hetzner, AWS, GCP, Azure: platform-specific metadata services
A Complete Cloud-init user-data
This user-data can be used standalone (with a Proxmox template, for example) or embedded inside an Ubuntu autoinstall’s user-data key:
|
|
Cloud-init with Proxmox Templates
If you are using Proxmox VM templates (see the Proxmox VE guide), cloud-init integrates directly. Proxmox injects a cloud-init drive as a virtual CD-ROM (IDE2), which cloud-init reads via the NoCloud data source. The VM template handles the OS; cloud-init handles the per-instance configuration. The user-data above works identically whether it comes from Proxmox’s cloud-init UI, a local NoCloud ISO, or an HTTP URL.
Debugging Cloud-init
|
|
Part 9: Putting It All Together
The Docker Compose Provisioning Stack
Run the entire provisioning server — DHCP, TFTP, HTTP file serving, and netboot.xyz — as a Docker Compose stack on a dedicated server or VM on your network:
|
|
The corresponding nginx config:
|
|
The Complete Boot-to-Configured Flow
With the stack running, here is what happens when you power on a new server:
-
Server powers on. NIC firmware broadcasts a DHCP DISCOVER.
-
dnsmasq responds. Server receives an IP and is told to fetch
undionly.kpxe(BIOS) oripxe.efi(UEFI) from the TFTP server at192.168.1.10. -
Server downloads iPXE via TFTP. Takes 2-3 seconds.
-
iPXE runs. It sends another DHCP request; dnsmasq recognizes the
iPXEuser class and returnshttp://192.168.1.10/boot.ipxe. -
iPXE downloads and executes the boot menu. The menu appears (or auto-selects after a timeout).
-
OS selected. iPXE fetches
vmlinuzandinitrdfor Ubuntu 22.04 via HTTP. Much faster than TFTP — a 100 MB initrd takes a few seconds over gigabit. -
Kernel boots. The initrd contains Subiquity. The kernel cmdline includes
ds=nocloud-net;s=http://192.168.1.10/autoinstall/ubuntu2204/. -
Autoinstall fetches
user-data. nginx serves the file. Subiquity reads the configuration and proceeds without prompting. -
OS installs. Partitioning, package installation, user creation — all automatic. Takes 8-15 minutes depending on internet speed (packages come from Ubuntu’s mirrors).
-
Late commands run. SSH hardening, agent installation, custom MOTD.
-
Server reboots. Cloud-init runs on first boot, installs Docker, writes config files, runs
runcmd. -
Server is ready. SSH is open, the admin user has the correct key, Docker is running. Ansible can take over from here.
MAC-Based Provisioning
For a rack of servers where each machine should get a specific hostname, IP, and role, structure your HTTP directory by MAC address:
/srv/http/autoinstall/
├── default/ # fallback config
│ ├── user-data
│ └── meta-data
├── aa:bb:cc:dd:ee:01/ # worker-01
│ ├── user-data # hostname: worker-01, role: k3s-worker
│ └── meta-data
├── aa:bb:cc:dd:ee:02/ # worker-02
│ ├── user-data
│ └── meta-data
└── aa:bb:cc:dd:ee:03/ # storage-01
├── user-data # hostname: storage-01, role: nfs-server
└── meta-data
In the iPXE menu, ${mac} expands to the client’s MAC address (in lowercase, colon-separated format):
|
|
You can also combine this with dnsmasq static host entries to ensure each MAC address gets its reserved IP:
|
|
Part 10: Tools That Build on Top of PXE
Raw PXE infrastructure gives you full control but requires manual management of distros, configs, and DHCP entries. Several tools add higher-level management:
Cobbler
Cobbler is a provisioning server that wraps DHCP, DNS, TFTP, and distro management into a single service. You add a distribution (point it at an ISO), create profiles (associate a distro with a Kickstart/preseed), and assign systems (MAC address → profile). Cobbler handles the DHCP and TFTP configuration automatically.
Best for: RHEL/CentOS/Rocky environments that want a single provisioning authority. Less popular than it once was due to poor Ubuntu autoinstall support.
Foreman
Foreman is full lifecycle management: provision via PXE, configure with Puppet or Ansible, and monitor — all from one web UI. It integrates with Red Hat Satellite, supports RBAC, has a rich API, and handles both bare metal and cloud VMs. The learning curve is steep but the capability is deep.
Best for: mixed environments, larger teams, or anywhere you want a unified CMDB + provisioning + configuration management tool.
MAAS (Metal as a Service)
Canonical’s MAAS treats bare metal servers like cloud VMs. You register machines (either manually or via IPMI/BMC discovery), commission them (MAAS boots a RAM-based test environment and inventories the hardware), and then deploy OSes to them on demand — all from an API or web UI. MAAS handles DHCP, TFTP, and HTTP automatically.
|
|
MAAS integrates with Juju for application deployment and supports custom cloud-init user-data per machine. It is particularly powerful for homelab clusters where you want cloud-like machine management without an actual cloud.
Tinkerbell
Tinkerbell is a cloud-native bare metal provisioning system originally developed at Equinix Metal (formerly Packet). It runs as a set of microservices in Kubernetes (or Docker Compose) and uses a workflow-based model: define a workflow of actions (pull an OS image, partition a disk, write config files, reboot), and Tinkerbell executes it on the target machine.
Best for: Kubernetes-native teams, complex custom provisioning workflows, or environments where you want GitOps to manage provisioning.
Clonezilla Server
For identical hardware, Clonezilla Server can image-clone a fully configured machine to dozens of targets simultaneously over the network. Less flexible than template-based provisioning (you cannot easily customize per-machine) but very fast — imaging a 50 GB OS drive takes minutes when done in parallel.
Part 11: Tips, Gotchas, and Troubleshooting
UEFI Secure Boot
Secure Boot requires that every bootloader in the chain be signed by a trusted key. iPXE is not signed by Microsoft’s key by default, so it will fail on Secure Boot-enabled systems.
Options:
- Disable Secure Boot in firmware (simplest; acceptable for a controlled environment)
- Use a signed shim: Ubuntu and Debian ship a Shim bootloader signed by Microsoft that can chainload GRUB, which can then chainload iPXE. More complex to set up.
- Use grubnetx64.efi.signed: serve Debian/Ubuntu’s signed GRUB EFI binary as the UEFI bootfile instead of
ipxe.efi. GRUB can then load iPXE or use its own menu.
For most homelabs, disabling Secure Boot is the pragmatic choice.
“PXE boot failed” — Common Causes
|
|
Common failures:
- No DHCPOFFER: dnsmasq is not running, or it is not listening on the right interface. Check
interface=indnsmasq.conf. - DHCPOFFER has no
next-server: Thedhcp-bootoption is missing or misconfigured. - TFTP times out: The firewall is blocking UDP port 69. Check
ufw allow 69/udpor equivalent. Also verifytftp-rootexists and is readable. - iPXE loads but HTTP fails: Check that nginx is running and the URL path matches the directory structure.
curl http://192.168.1.10/boot.ipxefrom another machine to verify.
DHCP Conflicts
Running two DHCP servers on the same broadcast domain causes chaos — clients randomly get responses from one or the other, leading to IP conflicts, wrong bootfile names, and intermittent failures. If you are adding a dnsmasq PXE server to a network that already has a DHCP server (your router), you have two options:
- Disable DHCP on the router and let dnsmasq handle all DHCP.
- Use dnsmasq in proxy-DHCP mode: dnsmasq responds only to PXE requests and does not assign IPs, letting the router handle normal DHCP. Add
dhcp-range=192.168.1.0,proxytodnsmasq.conf.
Proxy-DHCP is more complex to configure correctly but avoids disrupting the existing DHCP server.
TFTP Is Slow — Use HTTP for Kernels
TFTP has no flow control and transfers at whatever rate UDP allows on your network. A 100 MB initrd over TFTP on a busy network can take over a minute. Over HTTP on gigabit, it takes 1-2 seconds.
The solution: put only the iPXE binaries themselves on TFTP. Everything else — the menu script, the kernel, the initrd, the autoinstall configs — goes on HTTP. The iPXE bootstrap via TFTP is small (the undionly.kpxe binary is ~70 KB).
Testing Without Real Hardware
You can test your entire PXE stack using QEMU/KVM before touching physical hardware:
|
|
The QEMU VM will PXE boot against your dnsmasq and nginx stack exactly as a physical machine would. This lets you iterate on your user-data and preseed files without waiting for real hardware to reboot.
Alternatively, use Proxmox or virt-manager to create a VM with PXE boot — same effect, easier UI.
Wipe and Reprovision
Before reprovisioning a machine that already has an OS, wipe the partition table to avoid installer confusion:
|
|
In your preseed or Kickstart, the clearpart / partman-lvm/device_remove_lvm directives handle this automatically during install. But if a previous install left a broken state, wiping first prevents edge cases.
Validating Autoinstall Before Deploying
Ubuntu’s autoinstall validator:
|
|
For preseed files, install debconf-utils and use debconf-set-selections --check preseed.cfg on a Debian system.
For Kickstart, use the ksvalidator tool from the pykickstart package:
|
|
Putting It in Production
A provisioning server that lives on your network permanently, ready for any new machine you power on, is one of the highest-leverage infrastructure investments in a homelab. Once it is running:
- New Proxmox nodes join the cluster minutes after power-on
- K3s workers appear in your cluster before you finish your coffee
- A dead server becomes a resolved incident, not a recovery project
- Every machine in your environment is identical, documented in git, and reproducible
The stack is not complex — a dnsmasq container, an nginx container, and a directory of YAML files. The investment is in writing good user-data and Kickstart files, testing them thoroughly in QEMU, and committing them to version control alongside the rest of your infrastructure code.
From there, bare metal provisioning stops being a task and becomes a superpower.
Related posts: Proxmox VE Setup — for using cloud-init with VM templates | Ansible Playbooks — for post-provisioning configuration management | Docker Compose for Homelab — for structuring the provisioning stack
Comments