If your homelab systems do not have OOB / BMC / IMPI, this is the right place to look at.
This project allows you to easily setup a PXE boot environment with the help of docker containers, so you can network boot and autoinstall some machines without user interaction. It is only possible because iPXE exists and it is heavily inspired by netboot.xyz, but has a key difference: You also host the first iPXE config file and you do not rely on the webservice https://boot.netboot.xyz.
I have a small homelab that (simplified) looks like this:
As you can guess, the three Beelink machines are my guinea pig systems for e.g. spinning up proxmox, kubernetes or incus clusters. I reinstall them often enough that I felt the need to use something more comfortable then running downstairs with a bunch of USB sticks and install every machine manually again and jumping through the installer menus. And since this is consumer hardware without OOB / BMC /IPMI management and I am too stingy for things like nanoKVM, the only thing left is PXE!
This will guide you through an autoinstallation for Ubuntu 24.04 with SSH-Key setup, so you can e.g. manage the machine later with ansible.
- You understand, that autoinstalling a system overwrites the disk(s) on that system
- You can configure settings in your routers DHCP server - if not, take a look at other options
- You have a docker host
- You have a machine that is capable of being PXE booted
- You understand that this repo only applies for x86_64 EFI capable systems - if you have something different you have to adopt yourself (please create a PR if you have improvements)
You need to build your own EFI image in order to be able to point it to your PXE server IP address and port. Luckily, we can also do this inside a docker container.
-
Edit the efi_image_creator/embed.ipxe file and put in the IP address of your docker host and desired http port.
-
Build the EFI image. If you are building on a different architecture (e.g. Apple Silicon) builds can take quite some time, my last attempt took 20 minutes. On normal x86_64 it should take around a minute or two.
cd efi_image_creator docker build --platform linux/amd64 -t ipxe-builder . docker run --rm --platform linux/amd64 -v "$(pwd)/output:/ipxe/output" ipxe-builder cd ..
More on why you need to build an own EFI image can be found here: Why building an own EFI image? and why a custom port.
-
Copy the efi image from step one into the pxe_images folder
cp efi_image_creator/output/ipxe_x86_64.efi pxe_images
-
Modify the pxe_configs/menu.ipxe file and change the
srv_ipto the same IP you used inside theembed.ipxefrom step1. -
Modify the pxe_configs/autoinstall/meta-data and pxe_configs/autoinstall/user-data files to match your desires (e.g. change SSH-Key, etc.)
- Hint: To get the NIC names, a manually installed system is helpful to get that information first. Usually the distributions follow a certain pattern.
- Hint: this user-data file differs from the "usual" ones used in cloud environments. In our case, we fully rely on
autoinstallwhich behaves different and has different settings and file structures. - Bonus: If you want to have different configurations for the machines (e.g. different hostnames), you can achieve that by creating folders inside pxe_configs/autoinstall which represent the mac address of a certain nic, e.g.
e8ff1ede7ea0. You paths then would look like pxe_configs/autoinstall/e8ff1ede7ea0/meta-data and pxe_configs/autoinstall/e8ff1ede7ea0/user-data. Just make sure to also adopt your pxe_configs/menu.ipxe file to also use the mac address (section "Autoinstall configuration URL").
-
Change the HTTP port inside the docker-compose.yml file if needed and then start the container:
docker compose up -d
If you do not want to pull the kernel and initrd file fresh every time from the internet, you can of course put these files into the pxe_configs folder and change pxe_configs/menu.ipxe accordingly. If you are looking kernel and initrd files from other distributions, take a look at where to obtain them.
- Go to the settings of your DHCP server
- Configure or add a setting for key
next-serverand put in the IP address of the docker host (e.g.192.168.30.10) - Configure or add a setting for key
boot-file-nameand put in the EFI file name you created (e.g.ipxe_x86_64.efi)
Here is an example for a Microtik router:

- Go to the BIOS and search for the boot settings.
- Set PXE as the first option.
- In my case I had to enable the network stack first in a hard to find menu, otherwise PXE did not show up.
- Save and reboot.
WARNING: Due to the late-commands: setting in the pxe_configs/autoinstall/user-data file, the system will now boot to disk instead of booting via PXE, but every following reboot, the system will reinstall itself. If this is not desired, do not set PXE as the first boot option but instead, select PXE boot on demand. Depending on your BIOS, you might e.g. use F7 or F8 to enter the boot menu.
The initial EFI image contains the an initial iPXE config file. Of course you could put your whole config inside that and build the image. The drawback is: Every time you want to change or update things, you need to rebuild the image. To circumvent this issue, you can just "chain" additional iPXE config files, by creating an initial config file like this:
#!ipxe
dhcp
chain http://192.168.30.10:4480/menu.ipxeWith this, you can basically dynamically load more iPXE configuration menus from your own hosted server. There are existing projects like e.g. netboot.xyz, but if you use their docker container and try to achieve something like full auto-booting a system without prompting you for menu selections, you will quickly notice that this is no longer working. This is because the EFI images netboot.xyz ships in their docker container have an initial iPXE config file that points to their internet presence:
#!ipxe
dhcp
chain http://boot.netboot.xyz/menu.ipxeThis is awesome to get something up and running for experimenting, but lacks the customizability. If you, on the other hand, use plain netboot.xyz (without docker), you can easily use their ansible tooling to setup your fully self hosted infrastructure by just overwriting the site_name and boot_domain inside the user_overrides.yml file. But I wanted to have my stuff in docker so here we are ^^
First things first: The intentions came from a home-labber and thus are for home labs, where resources and money are always scarce.
You might not have a fancy router that can do VLANs or you might have other services running on that same docker host, which already occupy the "normal" http ports (80, 443) because you have e.g. Nextcloud running there too. But you have a lot of other ports, so why not use them?
I am sorry, but from this point I have to send you to a side-quest. You must disable your router’s DHCP server and set up your own DHCP server (maybe also on the docker host?) to take control of the network. Once you’ve claimed the reins of your IP addresses, return, and you will be rewarded with a bunch more knowledge.
Hint: Or you go even one step further and get yourself a router which let's you do all the things, either by flashing your current one with OpenWRT or you buy a cheap Mikrotik hEX series device or some old enterprise hardware second hand.
Yes, but there might be caveats: Depending on your setup, your container might need host-network access. If you run podman on a modern M-chip based Macbook, podman technically runs inside a VM and thus has no control over the host network. Be aware of that.
- Ubuntu via cloud-init
- Debian via preseed.cfg
- CentOS/RHEL/Rocky/Alma/Fedora via Kickstart
- openSUSE via autoyast.xml
PXE uses TFTP which listens on port 69/udp for handshake, but transfers of the EFI files happen on an random high number udp port, thus we need both the priviledged flag and the host network.
Technically, you can avoid the host networking, but you cannot get around the priviledged flag:
---
services:
containered-pxe:
image: ghcr.io/berryit/conatinered-pxe:latest
container_name: conatinered-pxe
privileged: true
networks:
pxe-net:
ipv4_address: 192.168.30.10
ports:
- "69:69/udp"
- "4480:80"
volumes:
- ./pxe_images:/pxe_images
- ./pxe_configs:/pxe_configs
#- ./nginx_config:/nginx_config
restart: unless-stopped
networks:
pxe-net:
driver: macvlan
driver_opts:
parent: enp2s0
ipam:
config:
- subnet: 192.168.30.0/24
gateway: 192.168.30.1
ip_range: 192.168.30.10/32
This only works, when the IP address 192.168.30.10 is free to use (so do not configure it on enp2s0).
You can also get creative with this approach: Imagine the docker host has two NICs (eno1 and enp2s0). Now imagine, that eno1 is connected to VLAN 20 (normal devices that should access docker freely) and enp2s0 to VLAN 30 (devices that should autoboot via PXE). This way you can split functionality into different networks while maintaining the services on a single machine.
You can of course use a DNS name instead of an IP address to point to your PXE container. IP addresses are just the lowest hanging fruit to get started. When using DNS, just make sure it always points to your docker container.
Most likely the installer was not able to reach the user-data and meta-data files and thus assumes, you are installing manually.
Most likely you get a message with something like "kernel installing error". This is a hint, that your user-data file is missing some information or has typos. In general, this part is much more fragile in comparison to normal cloud environments where you also use user-data. Things that are marked as optional in the ubuntu documentation might actually be needed for bare metal. Take the example and build from there.
In general, you can again use the awesome work from <netboot.xyz> and take a look at https://github.com/netbootxyz/netboot.xyz/blob/development/roles/netbootxyz/defaults/main.yml. Note down the mirror and base_dir variables, as well as the code_name for the version you are looking into. Example for Debian:
- mirror =
http://deb.debian.org - base_dir =
debian - code_name =
trixie
Now find the matching ipxe menu template, e.g. https://github.com/netbootxyz/netboot.xyz/blob/development/roles/netbootxyz/templates/menu/debian.ipxe.j2 and search in that file for :mirrorcfg. Below that line you will find another line that starts with set dir. This is the URL path which is used to fetch the kernel and the initrd files. The variables inside that string can be replaced with the values from before: debian_base_dir equals base_dir, debian_version equals code_name and the os_arch can be found at the top of that ipxe template (lines 11, 12 and 13; the set command at the end of each line).
#set dir ${debian_base_dir}/dists/${debian_version}/main/installer-${os_arch}/current/images/netboot/
# becomes
set dir debian/dists/trixie/main/installer-amd64/current/images/netboot/Moving further from here, you can follow the dir variable and its various places where it is used and repeat the steps by replacing the variables with their real values:
#set dir ${dir}${menu}-installer/${os_arch}
# becomes
set dir debian/dists/trixie/main/installer-amd64/current/images/netboot/debian-installer/amd64And then ultimately:
#kernel ${debian_mirror}/${dir}/linux ${install_params} ${netcfg} ${mirrorcfg} {{ kernel_params }}
#initrd ${debian_mirror}/${dir}/initrd.gz
# becomes
kernel http://deb.debian.org/debian/dists/trixie/main/installer-amd64/current/images/netboot/debian-installer/amd64/linux ${install_params} ${netcfg} ${mirrorcfg} {{ kernel_params }}
initrd http://deb.debian.org/debian/dists/trixie/main/installer-amd64/current/images/netboot/debian-installer/amd64/initrd.gzThese are the paths where you can find (and fetch) your kernel and initrd files from. From this point, you can replace the kernel_url variable in the pxe_configs/menu.ipxe that ships with this repository, of course with trimming away the file names (linux, initrd.gz). Repeat everything for the iso_url and you should be able to boot a debian instead of an ubuntu.
You could use the curtin command line to modify the efi boot order directly after installation to change it
late-commands:
- curtin in-target --target=/target bash -c 'if command -v efibootmgr; then efibootmgr -o $(efibootmgr | grep -i ubuntu | sed -E "s/Boot([0-9A-F]+).*/\1/" | tr -d "\n"); fi'- Building an EFI image on x86_64 hardware: 1-2 minutes
- Building an EFI image on an M2 Macbook air: 20 minutes
- Autoinstalling Ubuntu from cold boot to installation finished (shutdown): 7 minutes
- Autoinstalling Ubuntu from cold boot to ready os: 8 minutes
Any PR is welcomed :)
