OpenBSD httpd and local https

This tutorial configures a local OpenBSD web server for:

  • Hostname: minifishpc01.ronverbs.dev

  • Document root: /var/www/htdocs/chronicle-my-brain

  • Access: the OpenBSD host itself

  • Web server: OpenBSD httpd

  • Certificate authority: a private, locally trusted CA

The result will be available at:

https://minifishpc01.ronverbs.dev/

Because this is a local-only hostname, Let’s Encrypt’s HTTP-01 validation is not appropriate. A private CA lets the browser verify the server certificate without making the development site publicly accessible.

The .dev top-level domain is treated as HTTPS-only by modern browsers. A certificate warning may therefore be difficult or impossible to bypass. The correct solution is to import the private CA certificate as a trusted authority.

1. Configure local hostname resolution

Since the website will be opened only on this OpenBSD machine, /etc/hosts is sufficient. Add:

127.0.0.1    minifishpc01.ronverbs.dev

Edit the file:

doas vi /etc/hosts

Test the name:

ping -c 1 minifishpc01.ronverbs.dev

It should resolve to 127.0.0.1.

2. Create a private certificate authority

If an existing private CA is already installed and trusted, reuse it and skip to the server-certificate section. Do not create another CA unless a separate trust hierarchy is actually wanted.

Create /etc/ssl/ronverbs-local-ca.cnf:

[ req ]
distinguished_name = dn
x509_extensions = v3_ca
prompt = no

[ dn ]
CN = Ronverbs Local Development CA

[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, keyCertSign, cRLSign

Generate the CA private key:

doas openssl genrsa \
  -out /etc/ssl/private/ronverbs-local-ca.key 4096

Protect it:

doas chmod 600 /etc/ssl/private/ronverbs-local-ca.key

Generate a CA certificate valid for ten years:

doas openssl req -x509 -new -sha256 -days 3650 \
  -key /etc/ssl/private/ronverbs-local-ca.key \
  -out /etc/ssl/ronverbs-local-ca.crt \
  -config /etc/ssl/ronverbs-local-ca.cnf

The private key must remain private:

/etc/ssl/private/ronverbs-local-ca.key

Only the public CA certificate should be imported into browsers:

/etc/ssl/ronverbs-local-ca.crt

3. Create the server certificate

Modern browsers verify the Subject Alternative Name rather than relying solely on the certificate’s Common Name. Create /etc/ssl/minifishpc01.ronverbs.dev.cnf:

[ v3_server ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[ alt_names ]
DNS.1 = minifishpc01.ronverbs.dev

Generate the server private key:

doas openssl genrsa \
  -out /etc/ssl/private/minifishpc01.ronverbs.dev.key 2048

Protect it:

doas chmod 600 \
  /etc/ssl/private/minifishpc01.ronverbs.dev.key

Create a certificate-signing request:

doas openssl req -new -sha256 \
  -key /etc/ssl/private/minifishpc01.ronverbs.dev.key \
  -out /tmp/minifishpc01.ronverbs.dev.csr \
  -subj "/CN=minifishpc01.ronverbs.dev"

Sign the request with the private CA:

doas openssl x509 -req -sha256 -days 365 \
  -in /tmp/minifishpc01.ronverbs.dev.csr \
  -CA /etc/ssl/ronverbs-local-ca.crt \
  -CAkey /etc/ssl/private/ronverbs-local-ca.key \
  -CAcreateserial \
  -out /etc/ssl/minifishpc01.ronverbs.dev.crt \
  -extfile /etc/ssl/minifishpc01.ronverbs.dev.cnf \
  -extensions v3_server

Remove the temporary request:

rm /tmp/minifishpc01.ronverbs.dev.csr

Inspect the certificate:

openssl x509 \
  -in /etc/ssl/minifishpc01.ronverbs.dev.crt \
  -noout -subject -issuer -dates -ext subjectAltName

Confirm that the CA signature is valid:

openssl verify \
  -CAfile /etc/ssl/ronverbs-local-ca.crt \
  /etc/ssl/minifishpc01.ronverbs.dev.crt

The result should be:

/etc/ssl/minifishpc01.ronverbs.dev.crt: OK

4. Configure OpenBSD httpd

Add these server blocks to /etc/httpd.conf:

server "minifishpc01.ronverbs.dev" {
        listen on 127.0.0.1 port 80

        block return 301 \
                "https://minifishpc01.ronverbs.dev$REQUEST_URI"
}

server "minifishpc01.ronverbs.dev" {
        listen on 127.0.0.1 tls port 443

        tls {
                certificate "/etc/ssl/minifishpc01.ronverbs.dev.crt"
                key "/etc/ssl/private/minifishpc01.ronverbs.dev.key"
        }

        root "/htdocs/chronicle-my-brain"
        directory index "index.html"
}

OpenBSD httpd is chrooted under /var/www. Consequently, this configuration path:

/htdocs/chronicle-my-brain

refers to this actual filesystem directory:

/var/www/htdocs/chronicle-my-brain

The server’s private key stays outside the chroot under /etc/ssl/private.

5. Validate and start httpd

Check the configuration before restarting anything:

doas httpd -n

The expected result is:

configuration OK

Enable and restart the service:

doas rcctl enable httpd
doas rcctl restart httpd

Confirm that it is running:

doas rcctl check httpd

If troubleshooting is required, confirm that ports 80 and 443 are listening:

netstat -an -f inet | grep LISTEN

6. Import the CA into Firefox

Copy the public CA certificate into Downloads so the browser’s restricted file picker can see it:

cp /etc/ssl/ronverbs-local-ca.crt "$HOME/Downloads/"

In Firefox:

  1. Open Settings.

  2. Select Privacy & Security.

  3. Find Certificates.

  4. Select View Certificates.

  5. Open Authorities.

  6. Select Import.

  7. Choose ronverbs-local-ca.crt.

  8. Enable trust for identifying websites.

  9. Restart Firefox.

Import only the .crt file. Never import or copy the CA’s private .key file.

7. Import the CA into Chrome or Chromium

The CA certificate should already be in Downloads:

$HOME/Downloads/ronverbs-local-ca.crt

In Chrome or Chromium:

  1. Open chrome://certificate-manager.

  2. Select Authorities.

  3. Select Import.

  4. Choose ronverbs-local-ca.crt.

  5. Enable Trust this certificate for identifying websites.

  6. Completely close and restart the browser.

NSS command-line alternative

If the browser does not provide an Import button, install the NSS utilities:

doas pkg_add nss

Close Chrome or Chromium completely. Create its NSS database if one does not already exist:

mkdir -p "$HOME/.pki/nssdb"
certutil -N --empty-password -d "sql:$HOME/.pki/nssdb"

If the database already exists, do not recreate it. Import the CA:

certutil -A \
  -d "sql:$HOME/.pki/nssdb" \
  -n "Ronverbs Local Development CA" \
  -t "C,," \
  -i "$HOME/Downloads/ronverbs-local-ca.crt"

Confirm the import:

certutil -L -d "sql:$HOME/.pki/nssdb"

The certificate list should contain:

Ronverbs Local Development CA

Restart the browser.

8. Test the finished site

Test the TLS handshake independently of the browser:

openssl s_client \
  -connect minifishpc01.ronverbs.dev:443 \
  -servername minifishpc01.ronverbs.dev \
  -CAfile /etc/ssl/ronverbs-local-ca.crt </dev/null

Near the end of the output, look for:

Verify return code: 0 (ok)

Test the HTTP-to-HTTPS redirect:

curl -I http://minifishpc01.ronverbs.dev/

Test HTTPS using the private CA explicitly:

curl --cacert /etc/ssl/ronverbs-local-ca.crt \
  -I https://minifishpc01.ronverbs.dev/

Finally, open:

https://minifishpc01.ronverbs.dev/

9. Check certificate expiration later

Check the certificate file directly:

openssl x509 \
  -in /etc/ssl/minifishpc01.ronverbs.dev.crt \
  -noout -subject -issuer -dates

Check whether it will expire within 30 days:

openssl x509 \
  -in /etc/ssl/minifishpc01.ronverbs.dev.crt \
  -noout -checkend 2592000

When it is time to renew, create and sign a new server certificate with the existing CA. The browsers do not need the CA imported again as long as the same CA is reused and remains trusted.

Troubleshooting checklist

If the page does not open cleanly, check the following in order:

  1. minifishpc01.ronverbs.dev resolves to 127.0.0.1.

  2. doas httpd -n reports configuration OK.

  3. httpd is running.

  4. Ports 80 and 443 are listening on loopback.

  5. The server certificate contains DNS:minifishpc01.ronverbs.dev as a Subject Alternative Name.

  6. The server certificate verifies against ronverbs-local-ca.crt.

  7. The private key path in /etc/httpd.conf is correct.

  8. The document-root path is relative to /var/www inside the httpd chroot.

  9. The public CA certificate—not the server certificate and certainly not the private key—was imported under the browser’s trusted authorities.

  10. The browser was completely restarted after importing the CA.

At that point the local site should work with normal trusted HTTPS, leaving only the website itself to cause problems.