Multi-domain web hosting with OpenBSD & Nginx

This guide is for system administrators and developers who want to host static websites on multiple domains with a single OpenBSD server. You’ll learn how to set up secure and maintainable multi-domain hosting with automated SSL certificate management.

Project Background

I host my blog and other websites on a Vultr VM running OpenBSD. I created the VM 4 years ago with OpenBSD 6.6. Back then, I did most of the configuration manually, and while it worked well, my setup had a few rough edges. For example, the SSL configuration was a bit wonky; I used OpenBSD’s relayd as an SSL front-end because I couldn’t figure out how to get Nginx to work with my SSL certificates.

Last month, I decided to upgrade the instance and fix the configuration. Unfortunately, upgrading OpenBSD from 6.6 to the current version 7.6 isn’t supported: I would have had to update 6.6 to 6.7, then 6.7 to 6.8, and so forth until I reached 7.6. This would have been time-consuming, and I couldn’t do it anyway because older OpenBSD images were no longer available on the mirrors. So instead, I decided to create a fresh VM running 7.6 and reinstall everything on it. I used this opportunity to clean up my configuration and to automate its generation with a few shell scripts. I put everything in this repository on Sourcehut.

How it all fits together

All my websites are static, the content for each domain lives in /var/www/htdocs/<domain>; for example, this blog lives in /var/www/htdocs/henry.precheur.org.

I use Let’s Encrypt and OpenBSD’s acme-client to generate the SSL certificates for each domain. The certificates and keys are located in /etc/ssl/<domain>.cert and /etc/ssl/private/<domain>.key.

Each domain has its own access log at /var/www/logs/<domain>.log to analyze its traffic with tools like GoAccess or Awstats.

Below I’ll use example.com as my example domain.

acme-client

Let’s start with setting up SSL certificate management for each website. I use OpenBSD’s built-in acme-client to create and renew the SSL certificate using the HTTP server to authenticate the domain with Let’s Encrypt. The configuration looks like this:

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

...

domain example.com {
        domain key "/etc/ssl/private/example.com.key"
        domain full chain certificate "/etc/ssl/example.com.crt"
        sign with letsencrypt
}

Each domain has its own entry, and all the certificates and keys are written to /etc/ssl and /etc/ssl/private. To renew the certificate when needed there’s a script that runs weekly.

Nginx

Now let’s set up the Nginx web server. I chose Nginx because it is widely used and full-featured. OpenBSD’s own HTTP server felt a bit too limited for my own use.

The Nginx configuration follows a similar pattern; each domain has its own subsection. One thing that’s a bit awkward is that Nginx won’t start if any SSL certificate or key is missing. So before we generate the full configuration, we’ll have to set up a default server to validate with Let’s Encrypt that we control the domain with plain HTTP. This default entry gets Nginx working with acme-client for domains we haven’t registered yet:

http {
    ...

    server {
        listen      80;
        listen      [::]:80;

        location /.well-known/acme-challenge/ {
            rewrite ^/.well-known/acme-challenge/(.*) /\$1 break;
            root    /var/www/acme;
        } 
    }

    ...
}

With this server section, when we add a new domain it can get an SSL certificate issued with acme-client.

Once the certificates are created, we add a new server section to nginx.conf to handle it. Actually, we have two sections: one for plain HTTP, and one for HTTPS and HTTP2:

http {
   ...

   server {
        server_name "example.com";
        access_log /var/www/logs/example.com.log;
        root /var/www/htdocs/example.com;

        listen      80;
        listen      [::]:80;

        location /.well-known/acme-challenge/ {
            rewrite ^/.well-known/acme-challenge/(.*) /\$1 break;
            root    /var/www/acme;
        }

        include "/etc/nginx/common.conf";
    }

    server {
        server_name "example.com";
        access_log /var/www/logs/example.com.log;
        root /var/www/htdocs/example.com;

        ssl_certificate /etc/ssl/example.com.crt;
        ssl_certificate_key /etc/ssl/private/example.com.key;

        include "/etc/nginx/ssl.conf";
        include "/etc/nginx/common.conf";
    }

    ...
}

We preserve the acme location directory for HTTP in order to renew the SSL certificates with acme-client. We point to the SSL certificates in the HTTPS/HTTP2 section. There are two included subconfiguration files: common.conf for things applicable to both HTTP and HTTPS, while ssl.conf contains the SSL-specific configuration.

Newsyslog

Over time, domain access logs grow and can consume significant disk space if we don’t rotate and compress them regularly. OpenBSD uses newsyslog for log rotation; each domain’s log is rotated once a month with the following configuration:

/var/www/logs/example.com.log  644  7  *  $M1  Z  /var/run/nginx.pid SIGUSR1

This will keep 7 archived versions of the logs. I use GoAccess to get basic analytics for each of my domains. Here’s how I feed the logs to GoAccess to see how many visitors I get:

(
  zcat /var/www/logs/example.com.log.*.gz
  cat /var/www/logs/example.com.log
) | goaccess --log-format=COMBINED -

Conclusion

This multi-domain hosting solution is simple, low maintenance, and secure.

Check out the project and repository if you’d like to use this configuration for your own needs. And feel free to contribute or create a ticket if you want to improve it.