Using OpenBSD to Host a Website
By B.E. Alejandro
Context and motivation
I decided to migrate my website’s hosting to OpenBSD as a deliberate exercise in control, simplicity, and security. It wasn’t a decision based on performance, commercial scalability, or extreme automation, but on fully understanding the system.
OpenBSD forces you to understand every configuration file, every active service, and every permission. It doesn’t hide complexity: it exposes it. That was the central motivation behind this migration.
The goal was clear: an auditable, predictable, and minimal server.
Infrastructure and VM
The server runs on a low-cost rented virtual machine (VPS), sufficient for static content and minimal services.
General VM specs:
- 1 vCPU
- 1–2 GB RAM
- SSD storage
- Dedicated public IP
- Initial root access for bootstrapping
The provider isn’t relevant: OpenBSD doesn’t depend on host-specific features. The system stays portable and reproducible.
Choosing the stack
The stack was chosen deliberately to reduce the attack surface:
- Operating system: OpenBSD
- Web server: httpd (native)
- HTTPS: acme-client (Let’s Encrypt)
- Static site generator: Zola
- Theme: Duckquill (modified)
- Repository: Git + GitHub
- Remote access: SSH with a YubiKey (OpenPGP)
- Hidden service: Tor (.onion mirror)
No nginx, no Docker, no web panels, no Node in production. Just base-system binaries and text files.
Migrating to OpenBSD
The migration included:
- A clean OpenBSD install
- Manual network and DNS configuration
- Explicit user creation
- Exclusive use of doas (no sudo)
- Services disabled by default
Clear separation between: the system (/etc), content (/var/www), and keys (/etc/ssl, /etc/acme). Nothing was left “at default” without understanding it first.
Web server with httpd
The web server was configured using native httpd, with no external modules.
Simplified base configuration:
server "nezzontli.xyz" {
listen on * port 80
root "/htdocs"
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
}
}
server "nezzontli.xyz" {
listen on * tls port 443
root "/htdocs"
tls {
certificate "/etc/ssl/nezzontli.xyz.fullchain.pem"
key "/etc/ssl/private/nezzontli.xyz.key"
}
}
HTTPS with acme-client
OpenBSD ships acme-client in base, which removes external dependencies.
Actual flow:
- Configure httpd for HTTP only
- Resolve DNS correctly (A records pointing at the VM)
- Run:
doas acme-client -v nezzontli.xyz - Verify certificate creation in /etc/ssl
- Enable TLS in httpd
- Restart the service
No external scripts are used. Renewal is handled through the system’s cron.
Problems encountered (and their solutions)
1. 404 error on the ACME challenge
Cause: httpd wasn’t correctly serving /var/www/acme.
Solution:
2. DNS not propagated
During the first attempt, the domain resolved to multiple old IPs: dig nezzontli.xyz +short
Solution: wait for full propagation and remove conflicting records.
3. Confusion around DNSSEC
DNSSEC is not a requirement for Let’s Encrypt HTTP-01. DNSSEC is independent of TLS. It wasn’t enabled at this stage.
Building the site with Zola
The site is 100% static.
Basic flow:
There’s no runtime generation. No writes from the web server.
Tor mirror (.onion)
A Tor Hidden Service was set up as a mirror of the site.
/etc/tor/torrc:
HiddenServiceDir /var/tor/hidden_service/
HiddenServicePort 80 127.0.0.1:80
httpd listens only on localhost for Tor:
server "*" {
listen on 127.0.0.1 port 80
root "/tor"
}
Dedicated Tor build:
Tor Browser integration (Onion-Location):
Deployment automation
A single script for clearnet + Tor:
#!/bin/sh
ONION_URL="http://<onion>.onion"
Installed at: /usr/local/bin/update-site
Access control: SSH + YubiKey
Remote access with no persistent passwords:
- OpenPGP key on a YubiKey
- PIN + physical touch
- gpg-agent as the SSH agent
- ssh-ed25519
The private key never touches disk.
Version control and signing
Repository hosted on GitHub:
- Plain Git
- Signed commits
- Default trust model
- No mandatory CI
The server doesn’t build from remote hooks: deployment is deliberate and manual.
Project structure
site/
├── config.toml
├── content/
├── templates/
├── static/
├── public/
└── scripts/
On the server:
/var/www/
├── htdocs/
├── tor/
└── acme/
What’s left to do
Actual remaining work:
- Additional httpd hardening
- Document the full bootstrap process from scratch
- Minimal monitoring (without invasive agents)
- Publish configs as a reproducible reference
- Security header audit
Core idea
This project isn’t about convenience or development speed.
It’s about:
- Understanding
- Control
- Auditability
- Reducing implicit state
OpenBSD doesn’t speed up the work. It eliminates silent failures. That’s the value.