OpenBSD mascot

httpd rocks

Setup an HTTPS-enabled web server with httpd on OpenBSD. Includes A+ security report configuration with haproxy.

I’m far from an expert! Please help improve this project


Before You Begin…

This guide assumes you have already setup OpenBSD on your desired server of choice. Most commands will need to run via doas, since you should be logged in as a created user - never root directly.

All the examples in this guide use httpd.rocks for the domains (how meta…). Please remember to change this to your desired URL.

Prep Your Domain(s)

Make sure your DNS records are setup and working as intended with your desired domain. You can check their status with:

dig httpd.rocks

pf.conf

Before doing anything else, you need to make sure your /etc/pf.conf is allowing traffic on ports 80 and 443. Make sure you include the following:

pass in on any proto { tcp, udp } from any to any port 53
pass out on any proto { tcp, udp } from any to any port 53

pass in on any proto tcp from any to any port {80 443}
pass out on any proto tcp from any to any port {80 443}

httpd.conf

Make initial website folder and files:

doas mkdir -p /var/www/htdocs/httpd.rocks

Place your website files into this new folder and set proper permissions:

doas chmod -R 755 /var/www/htdocs/httpd.rocks
doas chown -R www:www /var/www/htdocs/httpd.rocks

Create the initial /etc/httpd.conf file:

server "httpd.rocks" {
    listen on * port 80
    root "/htdocs/httpd.rocks"

    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }
}

Then get httpd up and running:

doas rcctl start httpd

Note: If you encounter runtime errors with httpd, you might be required to add the following to your /etc/rc.conf.local file:

httpd_flags=""

If everything was setup properly, you should be able to visit the HTTP-only version of your website online. The only problem is HTTPS isn’t setup…

..yet!

acme-client.conf

Before anything else, we need to create proper directories for acme-client (our next steps) and set their permissions:

doas mkdir -p -m 750 /etc/ssl/private
doas mkdir -p -m 755 /var/www/acme

Create the /etc/acme-client.conf file and include the following:

authority letsencrypt {
    api url "https://acme-v02.api.letsencrypt.org/directory"
    account key "/etc/acme/letsencrypt-privkey.pem"
}

domain httpd.rocks {
    domain key "/etc/ssl/private/httpd.rocks.key"
    domain full chain certificate "/etc/ssl/httpd.rocks.fullchain.pem"
    sign with letsencrypt
}

Now we can run the core acme-client command to generate our certificates:

doas acme-client -v httpd.rocks

If everything goes smoothly, your new certificates should be generated and issued. The next thing you will want to do is automatically check for expired certs.

First create a separate script (this will be helpful if you plan to host multiple sites on a single server). Name it something like renew_certs.sh and save it under a local directory (ie. /home/username/scripts):

#!/bin/sh

DOMAINS="httpd.rocks example1.com example2.com example3.com"
CERTS_OUTPUT_DIR="/etc/ssl/certs"

echo "Checking certificates for each domain..."

# Loop through each domain and run acme-client only if renewal is needed
for DOMAIN in $DOMAINS; do
    if ! doas acme-client -n "$DOMAIN"; then
        echo "Certificate for $DOMAIN needs renewal, running acme-client..."
        doas acme-client "$DOMAIN" || exit 1
    else
        echo "Certificate for $DOMAIN is still valid, skipping renewal."
    fi
done

# Combine .fullchain.pem and .key into a single .pem file in /etc/ssl/certs
echo "Combining .fullchain.pem and .key files for each domain..."
for DOMAIN in $DOMAINS; do
    FULLCHAIN="/etc/ssl/$DOMAIN.fullchain.pem"
    KEY="/etc/ssl/private/$DOMAIN.key"
    COMBINED_PEM="$CERTS_OUTPUT_DIR/$DOMAIN.pem"

    if [ -f "$FULLCHAIN" ] && [ -f "$KEY" ]; then
        doas sh -c "cat '$FULLCHAIN' '$KEY' > '$COMBINED_PEM'"
        doas chmod 644 "$COMBINED_PEM"
        echo "Combined $FULLCHAIN and $KEY into $COMBINED_PEM"
    else
        echo "Missing $FULLCHAIN or $KEY for $DOMAIN, skipping."
    fi
done

# Reload httpd after updating certificates
echo "Reloading httpd..."
doas rcctl reload httpd
echo "httpd reloaded successfully."

For reference I have included multiple domains if you decide to host several websites through one server. Remove these if you only plan to host a single domain.

Set executable permissions:

doas chmod +x /path/to/renew_certs.sh

Then setup the following cronjob by running crontab -e and entering in:

0 0 * * * /path/to/renew_certs.sh

This will check if you need to renew certificates every day at midnight (server time). If new certs are needed, it will properly combine the generated fullchain.pem and key files into a single <project-name>.pem file under the shared directory /etc/ssl/certs.

haproxy.cfg

Many people tend to reach for relayd in OpenBSD when deciding to setup proxies or include security headers for their sites. Maybe I’m just too dull, but I always struggle to get things running smoothly with it.

That’s why I opt for using HAProxy.

First, install the package:

doas pkg_add haproxy

Now configure the core /etc/haproxy/haproxy.cfg (take note of the extension! OpenBSD uses cfg rather than the standard conf for HAProxy) and add the following to the existing file:

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    timeout connect 5000ms
    timeout client  50000ms
    timeout server  50000ms

frontend http_in
    bind *:80
    # Redirect all www requests to non-www
    http-request redirect prefix https://%[hdr(host),regsub(^www\.,)] code 301 if { hdr_beg(host) -i www }
    redirect scheme https if !{ ssl_fc }
    default_backend main_backend

frontend https_in
    bind *:443 ssl crt /etc/ssl/certs/
    # Redirect all www requests to non-www
    http-request redirect prefix https://%[hdr(host),regsub(^www\.,)] code 301 if { hdr_beg(host) -i www }
    default_backend main_backend

# Backend to httpd with security headers, no TLS
backend main_backend
    http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
    http-response set-header X-Content-Type-Options "nosniff"
    http-response set-header X-Frame-Options "DENY"
    http-response set-header X-XSS-Protection "1; mode=block"
    http-response set-header Content-Security-Policy "script-src 'self'"
    http-response set-header Referrer-Policy "no-referrer"
    http-response set-header Permissions-Policy "microphone=()"
    server local_httpd 127.0.0.1:8080

The haproxy.cfg in a nutshell:

Important: Take note of the line:

bind *:443 ssl crt /etc/ssl/certs/

This tells HAProxy to dynamically scan a directory containing our certificates. We set this up previously with our automated renew_certs.sh script.

This is handy if you decide to host multiple sites on a single server. Otherwise, you would have to edit and reload your HAProxy config everytime you setup a new website.

So we need to create our certs directory:

doas mkdir /etc/ssl/certs
doas chmod 644 /etc/ssl/certs

And then create /etc/ssl/certs/httpd.rocks.pem by combining your certificate and private key if not done:

cat /etc/ssl/httpd.rocks.crt /etc/ssl/private/httpd.rocks.key > /etc/ssl/certs/httpd.rocks.pem

Once that’s complete, test that everything is working, and if so, enable and start HAProxy:

doas haproxy -c -f /etc/haproxy/haproxy.cfg
doas rcctl enable haproxy
doas rcctl start haproxy

It’s Alive!

Now check out your website!

Everything should work as intended. You should have valid TLS, your standard HTTP request should forward to HTTPS, www requests should forward to non-www, and your security headers should score an A+.

That’s it!


References

I am far from an OpenBSD expert. Please refer to these additional (and mostly better) resources and documentation: