OpenBSD migration


Now that the announced downtime (which lasted this entire afternoon) is successfully behind, I can blog about the reason: I switched my OVH VPS from AlmaLinux + Caddy to base OpenBSD.

openbsd logo.svg

Why §

I wanted something much simpler than RedHat/Debian (Alpine was considered), can rely on my trusty UNIX fu skills and Caddy was starting to feel too bloated for my simple use case (and like most Go stuff, too accepting of LLM "coding" for my taste).

So when news of OpenBSD's 7.9 release greeted my feed, I remembered that it got a builtin HTTP server quite some time ago. A little perusing of the documentation (and this cool blog) convinced me it had everything I could ever want (including precompressed gzip serving and (Fast)CGI if I ever want some dynamism).

Supplementary cool fact to consider: everything else I need (openrsync, acme-client, mg, even tmux) is in base. My choice was made.

Installation §

Sadly, OVH really makes it annoying to run something different than the images they propose. So buckle up and open your can of elbow grease!

First, go to the VPS admin panel and find the (slightly hidden) menu to "Reboot in rescue mode". That'll take a few minutes then you'll get a temporary password to ssh to your rescue session.

ovh control panel.png

What do you do then? First, I tried to use qemu -nographic to boot the netinst disk image (miniroot.img or ISO (cd.iso) only for the boot process to get stuck at "entry point at 0x…". Too old qemu or PEBKAC? Don't know and didn't get much help on IRC, but after an hour, I tried simply writing the miniroot to the disk and it Just Werked™. So:

$ ssh root@world-playground-deceit.net
$ curl -L https://cdn.openbsd.org/pub/OpenBSD/7.9/amd64/miniroot79.img |
      dd of=/dev/sdb bs=1M

Then reboot the VPS (in normal mode), open the web KVM and proceed normally with the OpenBSD installer but do use MBR instead of GPT if you want a bootable result and don't forget to keep sshd enabled!

Only a few steps left to get a working server:

1. (Optional but useful) Enable persistent shell history, really saves some time.

$ echo 'export ENV=~/.kshrc' >>.profile
$ cat <<EOF >>.kshrc
export HISTFILE=~/.ksh_history
export HISTSIZE=100000
export HISTCONTROL=ignoredups:ignorespace
EOF

2. Configure httpd and acme-client to get a basic TLS website setup up and running:

$ sed 's/example\.com/world-playground-deceit.net/g' \
    /etc/examples/httpd.conf >/etc/httpd.conf
$ httpd -n # Check config
$ sed '/alternative names/d; s/example\.com/world-playground-deceit.net/g' \
    /etc/examples/acme-client.conf >/etc/acme-client.conf
$ acme-client -n # Check config
$ rcctl enable httpd
$ rcctl start httpd
$ acme-client -v world-playground-deceit.net
...
$ EDITOR=mg crontab -e # Add a daily acme-client call like in the manpage

3. ???

4. Profit! Only a few openrsync compatibility tweaks needed for the rsync command used to push website updates: add a --rsync-path=openrsync option and remove --exclude '.*' (implies "fixing" my generator to avoid littering the output directory) which apparently doesn't play well with --delete (gave me some protocol errors).

The last mile §

Well, my httpd.conf still needs some massaging to match (or improve upon) the caddy setup I had: more mimetypes (mainly for video files), no dir listing to avoid being DOSed by scrapers, (precompressed) gzip serving and two rewrite rules. The final config remains deceptively simple, if you ignore the workaround that won't be needed for long:

prefork 2

types {
    include "/usr/share/misc/mime.types"
    application/atom+xml atom xml
}

server "world-playground-deceit.net" {
    listen on * port 80
    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }
    location * {
        block return 302 "https://$HTTP_HOST$REQUEST_URI"
    }
}

server "world-playground-deceit.net" {
    listen on * tls port 443
    tls {
        certificate "/etc/ssl/world-playground-deceit.net.fullchain.pem"
        key "/etc/ssl/private/world-playground-deceit.net.key"
    }
    tcp nodelay
    directory no index
    gzip-static
    location "/" {
        request rewrite "/welcome.html"
        # cf https://blog.nutts.org/2026/02/24/gzip-static-location-rewrites-httpd-openbsd/
        # Fix pending: https://marc.info/?l=openbsd-tech&m=177835277715839&w=2
        gzip-static
    }
    location "/favicon.ico" {
        request rewrite "/resources/favicon.ico"
    }
}

And finally, to get those static gzip sidecars, a relatively portable sh pipeline:

# NB: POSIX 2024 sez that `[PATH1 -nt PATH2]` succeeds if PATH2 doesn't exist,
#     but few implementations are compliant (e.g. dash >=0.5.13)
find dst/ -type f |
    grep -E '\.(html|css|svg|xml)$' |
    tr '\n' '\000' |
    xargs -n1 -0 -P$(getconf NPROCESSORS_ONLN) sh -c \
    'if ! [ -f "$1".gz ] || [ "$1" -nt "$1".gz ]; then gzip -9fk "$1"; fi' argv0

(Though I recommend libdeflate or something else instead of plain zlib, cf benchmarks)