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

Configure Firewall (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}

Once that is saved, simply reload the ruleset:

pfctl -f /etc/pf.conf

Setting Up httpd

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 "www.httpd.rocks" {
    listen on * port 80
    root "/htdocs/httpd.rocks"

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

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!

Configure 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 {
    alternative names { www.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 * * * doas -u <user> /path/to/renew_certs.sh

Replace <user> with your username.

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.

Since we haven’t run this script yet, we should execute it for building the initial pem files required for HAProxy:

doas sh /path/to/renew_certs.sh

Configure 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 scheme https if !{ ssl_fc }
    default_backend main_backend

frontend https_in
    bind *:443 ssl crt /etc/ssl/certs/
    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:

frontend http_in:
redirects all HTTP requests to HTTPS
uses main_backend to set security headers
frontend https_in:
uses main_backend to set security headers
backend main_backend:
sets sane security header defaults (change these as you please)

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 (only if you didn’t do this earlier when configuring the renew_certs script):

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

Forward Traffic to Non-WWW

Return to the core /etc/httpd.conf file and add the following redirect block to your www server section:

server "www.httpd.rocks" {
    listen on * port 80
    root "/htdocs/httpd.rocks"
    block return 301 "https://httpd.rocks$REQUEST_URI"

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

Restart your httpd server again:

rcctl restart httpd

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, all 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: