FreeBSD on a Hetzner Cloud VPS
Hetzner has no FreeBSD image and their ISO library stops at 15.0. You can still get any release on there, unattended, in about an hour.
The box runs AthletOS, a training app I am building. Predefined programs, session logging and tracking.
— I picked FreeBSD for ZFS boot environments and jails, so a bad deploy is a rollback instead of an incident.
This is the whole procedure, in order, with the things that went wrong and what fixed them. I used FreeBSD’s own ISO rather than a prebuilt image, so the checksum can be verified against the project’s manifest.
1. Create the server
A CX23 gives 2 vCPU and 4 GB, which is plenty. Firewall first, then the box.
for; do
done
The image is a throwaway. It only exists so the server boots at all.
2. Boot into rescue
3. Fetch and verify the ISO
Take bootonly, not disc1. The rescue root is a small RAM overlay and a 1.3 GB ISO plus its extraction plus the rebuild will not fit.
B=https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/15.1
I=FreeBSD-15.1-RELEASE-amd64-bootonly.iso
E=
A=
[ && ||
4. Remaster the ISO
Three edits: a serial console so we can watch it, the prompt that would otherwise block forever, and the network the scripted install never configures for itself.
&& &&
# append, never replace: the original carries vfs.root.mountfrom
# the serial path asks for a terminal type with an unconditional read
# a scripted install never configures the network
What went wrong
I replaced loader.conf instead of appending, which threw away vfs.root.mountfrom and left it at a mountroot prompt.
Then it hung at Console type [vt100]: because that read runs before the script is ever looked for.
Then distfetch died on DNS, because the interactive path exports BSDINSTALL_CONFIGCURRENT and the scripted path does not.
5. Write the unattended install script
PARTITIONS=DEFAULT
DISTRIBUTIONS="kernel.txz base.txz"
#!/bin/sh
# ARC takes half of RAM by default. On a 4GB box that is 2GB it does not
# need, and it turns up later looking like a database problem.
&&
10.0.2.2 is QEMU’s host address. Serving the distribution sets from rescue skips DNS entirely and is quicker than fetching them twice.
&&
B=https://download.freebsd.org/releases/amd64/15.1-RELEASE
for; do ; done
while ; do
[ || continue
[ &&
done
&
Tip: leave that server running. The installer fetches from it in step 7, not now.
6. Build the ISO and wipe the disk
Delete the source ISO before building, or you run out of space in the overlay.
# zfs labels sit at both ends of the device, so wipefs alone is not enough
SECTORS=
MIB=
What went wrong
An earlier attempt got far enough to create a zroot pool before failing.
The next run found it and stopped to ask for a different pool name, which is another dialog nobody is there to answer. Wipe both ends between attempts.
7. Run the installer under QEMU
Hetzner Cloud has no nested virtualisation, so there is no /dev/kvm and this runs under TCG emulation. It takes roughly half an hour. Nothing to do but watch the log.
|
# zfsboot mount distfetch checksum distextract bootconfig config
# entropy umount script
Kill QEMU once the script step finishes, or it reboots straight back into the installer.
8. Boot it, and fix fstab
# the installer ran under qemu virtio (vtbd0), hetzner presents da0.
# the pool is fine because zfs scans devices. swap is not.
What went wrong
The generated fstab pointed at /dev/vtbd0p2, which does not exist on the real machine.
It booted with no swap and said nothing about it. Refer to it by GPT label and the device name stops mattering.
Result
Result
15.1 on root-on-ZFS, a release the ISO library does not carry, from media you can verify.
Under load
I measured this from a separate Hetzner VPS in the same datacentre, hel1, against a control: an identical CX23 running Ubuntu 24.04, the same Caddy 2.11.4 serving byte-identical files, with an ECDSA P-256 certificate to match. vegeta, a fresh TCP and TLS connection per request, which is what a crawler does and what a browser never does.
|
# freebsd 100/s 4.9ms 250/s 4.7ms 500/s 4.0ms mean
# ubuntu 100/s 5.7ms 250/s 5.0ms 500/s 5.2ms mean
At 100, 250 and 500 fresh connections a second both boxes delivered the full offered rate with 100% success, and the FreeBSD box was the faster of the two at every step.
The crypto is not the constraint either.
# sign/s
# 37920 this box (epyc, one core)
# 32614 ubuntu control (xeon, one core)
The EPYC signs 16% faster than the control’s Xeon, which is worth stating because it means the hardware favours the FreeBSD box rather than the other way round. Against 500 connections a second, 37,920 signatures is roughly 75x headroom. Whatever the ceiling is, it is not asymmetric crypto.
Two things I did not establish
Above 500/s the measurements were not reproducible. One clean run at 1000/s had FreeBSD at 497/s and the Ubuntu control at 999/s. A later run gave 179/s at 1000/s, and then 566/s at 1500/s — more offered load producing more throughput, which is not a property a server can have. It is the single load generator accumulating TIME_WAIT between runs. A real answer needs several generators.
An earlier attempt measured this from a Windows laptop over the public internet and got 50-100 connections a second. That was low by five to ten times, and the cause was ephemeral-port exhaustion on the client. The first measurement was wrong, and it was wrong in a way that produced a confident and completely incorrect explanation.
One hypothesis died on the way. FreeBSD’s kern.ipc.soacceptqueue is 128 and Ubuntu’s net.core.somaxconn is 4096, so the listen backlog differs by 32x, which looked like the obvious cause and survived the arithmetic. I set the control to 128, restarted Caddy so the listener picked the new value up, and it still delivered 999.9/s. Refuted.
That leaves pf, which redirects 443 to Caddy’s high port, as the untested suspect. Untested because that high port is not reachable from outside, so there was no way to bypass the redirect and compare against it.
The same steps work for any release. Change the version in step 3 and the rest follows.
Every command above ran against a real CX23 in hel1.