Setting up a Webserver on DragonFly

In the previous article DragonFly on Hetzner Cloud we covered how to install DragonFly BSD on a Hetzner Cloud server. This article dives into setting up a web server on DragonFly, including Let’s Encrypt certificates, specifically when using Hetzner DNS Robot. It also covers setting up SSH access to the server, setting up time servers, updating packages, etc.

The server I am operating on is which stands for 1st cloud server. Once it is up and running, it will be aliased as and also serve HTTP for my second domain

Setting up network

root@c1 $ echo dhcpcd_enable=YES >> /etc/rc.conf
root@c1 $ service dhcpcd start

Setting up SSH

To transfer the public key to my server, I use py38-magic-wormhole ( would do as well as the key is public):

root@c1 $ pkg ins py38-magic-wormhole

As root on I run:

root@c1 $ mkdir /root/.ssh
root@c1 $ workhole ssh invite

It’s now waiting for someone (hopefully it’s me) to send it’s ssh key and enter the correct one-time-code. IMHO it’s actually a bit unsafe, as if someone can guess the one-time-code with a chance of 1 to 65000, he can gain access to my server. Never mind.

On my FreeBSD laptop, I also need wormhole installed. Here it is package py39-magic-wormhole. Then I create a public/private key pair:

laptop $ ssh-keygen -f .ssh/

and send it over to the server:

laptop $ wormhole ssh accept **CODE**

Here, you have to provide the correct **CODE**. It will ask you which ssh key to send over to the other side. Choose the one you have just created.

In addition, on my FreeBSD system, I configure .ssh/config to use the key for

  IdentityFile ~/.ssh/

Or as I will later add another user:

Match host user root
  IdentityFile ~/.ssh/

Now I should be able to log in with

laptop $ ssh

Finally, I remove py38-magic-wormhole from the server again:

root@c1 $ pkg remove -y py38-magic-wormhole
root@c1 $ pkg autoremove

It’s probably much much easier to just copy and paste your public key via the Hetzner Robot Console. As I just found out about wormhole a couple of days ago I wanted to give it a try :)

Updating packages

Next, we want to update the packages, cleanup no longer needed packages (cdrtools for instance) and install vim and tmux.

root@c1 $ pkg remove -y cdrtools
root@c1 $ pkg autoremove -y
root@c1 $ pkg upgrade -y
root@c1 $ pkg ins -y vim tmux

Configuring the system

We want to enable NTP time synchronization using dntpd and setting up a hostname:

root@c1 $ echo dntpd_enable=YES >> /etc/rc.conf
root@c1 $ echo >> /etc/rc.conf
root@c1 $ service hostname restart
root@c1 $ service dntpd start

Setting up the timezone:

root@c1 $ tzsetup

Choose your timezone from the dialog.

Configuring Let’s Encrypt

On my old server I am using but wasn’t really happy with it. After some research I found lego which is a ACME Client written in Go that has all features I need and is easy to use:

root@c1 $ pkg ins lego

In order to configure ACME with lego, you need to modify the and scripts and populate domains.txt (all located under /usr/local/etc/lego).

Certificate creation

We first update the /usr/local/etc/lego/ script. This is what gets called to setup a certification account, request certificates and renew certificates. Once it’s working, we will enable it with periodic.


---      2022-06-26 22:13:40.000000000 +0200
+++     2022-09-13 22:12:33.576483000 +0200
@@ -1,7 +1,8 @@
 #!/bin/sh -e
 # Email used for registration and recovery contact.
@@ -24,10 +25,11 @@
 run_or_renew() {
        /usr/local/bin/lego --path "${SSLDIR}" \
                --email="${EMAIL}" \
                $(printf -- "--domains=%s " $line) \
-               --http --http.webroot="/usr/local/www/lego" \
+               --dns hetzner \

Here I am using dns-01 challenges with the Hetzner DNS Robot API. You need to create an API key first with your Hetzner DNS Robot.

Next we populate the domains.txt:

echo >> /usr/local/etc/lego/domains.txt

This will create a certificate for with subject altnames for all the other domains listed thereafter. There will be only one certificate and the file will be named after

We are now ready to run lego for the first time (make sure you run it as user _lego):

root@c1  $ su -fm _lego
_lego@c1 $ /usr/local/etc/lego/ run

You should now see a certificate and a private key for generated as well as some internal book-keeping for lego:

root@c1 $ ls -l /usr/local/etc/ssl/lego/certificates
total 24
-rw-------  1 root  _lego  5394 Sep 14 13:37
-rw-------  1 root  _lego  3751 Sep 14 13:37
-rw-------  1 root  _lego   233 Sep 14 13:37
-rw-------  1 root  _lego   227 Sep 14 13:37

Certificate deployment

We want to adapt the /usr/local/etc/lego/ script to copy the certificates and private keys into /usr/local/etc/ssl/certs and /usr/local/etc/ssl/private and then restart the web server.

root@c1 $ mkdir -p /usr/local/etc/ssl/{certs,private}
root@c1 $ chmod o+w /usr/local/etc/lego/

We also make the following changes to

---    2022-06-26 22:13:40.000000000 +0200
+++   2022-09-13 22:58:52.080816000 +0200
@@ -1,6 +1,7 @@
 #!/bin/sh -e
 copy_certs () {
   local certdir certfile domain keyfile rc
@@ -26,5 +27,5 @@
 if copy_certs
-  output=$(service nginx reload 2>&1) || (echo "$output" && exit 1)
+  output=$(service ${RELOAD_SERVICE} reload 2>&1) || (echo "$output" && exit 1)

By default it will reload nginx but as we will be using h2o we have to fix it there.

Before continuing with the web server configuration, let’s run now (as root). It will copy over the certificates but then fail to reload h2o, which is fine.

Setting up the web server

Install and enable the h2o web server:

root@c1 $ pkg ins -y h2o
root@c1 $ echo h2o_enable=YES >> /etc/rc.conf

Create a dhparam.pem file (this may take quite long time):

root@c1 $ openssl dhparam -out /usr/local/etc/ssl/dhparam.pem 4096

Edit /usr/local/etc/h2o/h2o.conf:

--- h2o.conf.sample	2022-07-03 14:09:42.000000000 +0200
+++ h2o.conf	2022-09-13 23:05:17.376781000 +0200
@@ -5,9 +5,9 @@
 user: www
 pid-file: /var/run/
 # log normal access to file
-access-log: /var/log/h2o/access.log
+access-log: "| cat /dev/null"
 # send errors to syslog
-error-log:  "| logger -i -p daemon.err -t h2o"
+error-log:  "| cat /dev/null"
 # as of 2017-12-01 the following TLS config and headers, with
 # DNS CAA records and custom diffie-hellmann parameters via
@@ -22,9 +22,8 @@
     # using at least TLS1.2 restricts many older devices
     minimum-version: TLSv1.1
     dh-file: /usr/local/etc/ssl/dhparam.pem
-    # generate your own certificates with security/acme-client
-    certificate-file: /usr/local/etc/ssl/acme/
-    key-file: /usr/local/etc/ssl/acme/private/
+    certificate-file: /usr/local/etc/ssl/certs/
+    key-file: /usr/local/etc/ssl/private/
     cipher-preference: server
@@ -48,50 +47,12 @@
   application/atom+xml: .xml
   "text/html; charset=utf-8": .html
-# per-host configurations
-  # a basic fileserver
-    # enable Apache-style directory listings
-    file.dirlisting: on
-    file.send-gzip: on
-        file.dir: "/var/www/"
-      # a simple permanent URL redirect
-      "/blog":
-        redirect:
-          status: 301
-          url:
-      # a password-restricted url
-      "/server-status":
-        mruby.handler: |
-          require "htpasswd.rb"
-"/usr/local/etc/h2o/private/htpasswd", "")
-        status: ON
-      # redireect Lets Encrypt ACME protocol to a specific challenge directory
-      "/.well-known/acme-challenge":
-        file.dir: "/var/www/acme"
-  # virtual directory layout to support serving FreeBSD packages built by poudriere
-    paths:
-      "/poudriere":
-        file.dir: "/usr/local/poudriere/data/logs/bulk"
-      "/FreeBSD:10:amd64":
-        file.dir: "/usr/local/poudriere/data/packages/10_amd64-default/"
-      "/FreeBSD:11:amd64":
-        file.dir: "/usr/local/poudriere/data/packages/11_amd64-default/"
-  # a simple ruby-powered embedded JSON API
-    paths:
-      "/ok.json":
         mruby.handler: |
  do |env|
             [200, {'content-type' => 'application/json'}, ['{"status":"ok"}']]
-  # a websockets-aware reverse proxy
-    paths:
-      "/":
-        proxy.websocket: ON
-        proxy.reverse.url: "http://localhost:1080/"

Let’s test the configuration now:

root@c1 $ service h2o configtest

This might fail, as there is a “bug” in the rc.d/h2o script.

Either run it directly:

root@c1 $ /usr/local/etc/rc.d/h2o configtest

Or fix it by editing /usr/local/etc/rc.d/h2o and adding env "${h2o_env}" before running the command:

h2o_configtest() {
	env "${h2o_env}" "${command}" -c "${h2o_config}" -t

Or wait until the h2o port gets updated.

If the configtest succeeds, start h2o:

root@c1 $ service h2o start

When everything works well, and the DNS records for correctly point to the server, we should not be able to point our browser to and see it delivering the JSON {"status": "ok"}.

Note that I have disabled any logging of accesses and errors via:

access-log: "| cat /dev/null"
error-log:  "| cat /dev/null"

I do this for privacy reasons (DSGVO) and because I don’t really need the log information. If you turn on logging, don’t forget to rotate your logs.

Advanced web server configuration

In h2o.conf I disable the two HTTP headers:

# header.set: "X-Content-Type-Options: nosniff"
# header.set: "Content-Security-Policy: default-src https:"

because they lead to problems in one of my web sites that use:

<link ref="stylesheet" href=".css">

Changing that to

<link ref="stylesheet" href=".css" type="text/css">

should mitigate the nosniff problem, but I am too lazy atm to fix it.

As I am not using POST HTTP requests, I limit the body to just 1 MiB (from 10):

limit-request-body: 1048576 # 1MiB

Finally, this is my hosts configuration:

          status: 301
          url: ""

          status: 301
          url: ""

        file.dir: "/var/www-data/"

        file.dir: "/var/www-data/"

          status: 301
          url: ""

          status: 301
          url: ""

        file.dir: "/var/www-data/"

        file.dir: "/var/www-data/"
        mruby.handler: |
 do |env|
            [200, {'content-type' => 'application/json'}, ['{"status":"ok"}']]

As I often type in just the domain name without www. prefix, I add redirections to the domain with www.. I do this for both port 80 and 443 (SSL). What I don’t do is to automatically redirect port 80 to 443. If someone wants to see my page unencrypted via port 80 he or she can still do. Modern browsers will anyway try to use 443 and warn about unecrypted HTTP.

The actual website data is located under /var/www-data/$DOMAIN which is owned by user www-data. Let’s create the user:

root@c1 $ pw user add \
   -n www-data \
   -u 81 \
   -d /var/www-data \
   -g www \
   -L default \
   -m \	
   -s /usr/local/libexec/git-core/git-shell \
   -w no

Within /var/www-data, for each domain I have a git repository, e.g. that contains the whole static website. On my laptop, I have a copy of that repo and whenever I update the site via a static site generator, I commit the changes into that repo and git push them efficiently to the server. Then I check it out into the /var/www-data/ directory.

This looks roughly like this:

laptop $ git --git-dir=./deploy-site.git push -u \ master
laptop $ ssh \
    "git --git-dir=/var/www-data/ --work-tree=/var/www-data/ checkout -f master"

Note that you first have to create a bare git repo like this:

root@c1 $ cd /var/www-data
root@c1 $ git init --bare
root@c1 $ chown -R www-data:www

You furthermore have to setup a ssh-key for user www-data and properly configure git-shell. I haven’t done the git-shell configuration yet, so I am using a normal tcsh shell instead.

Configuring periodic tasks

We want daily cleanup of HAMMER2

root@c1 $ echo daily_clean_hammer2_enable=YES >> /etc/periodic.conf

This is on by default, so you don’t need to configure it.

If you want, you can enable daily HAMMER2 snapshots by setting:


I don’t do this as none of the data needs to be backed up.

Important is to enable lego in /etc/periodic.conf:

root@c1 $ echo weekly_lego_enable=YES >> /etc/periodic.conf
root@c1 $ echo weekly_lego_renewscript=/usr/local/etc/lego/ >> /etc/periodic.conf
root@c1 $ echo weekly_lego_deployscript=/usr/local/etc/lego/ >> /etc/periodic.conf

This will renew certificates every week, deploy them and reload h2o. We quickly want to test if the period scripts work as expected, so let’s run them:

root@c1 $ periodic weekly

Afterwards, start mail to see the weekly report:

root@c1:~ # mail
Mail version 8.1 6/6/93.  Type ? for help.
"/var/mail/root": 3 messages 3 new
>N  1      Thu Sep 15 05:01 1065/95275 " daily security run output"
 N  2      Thu Sep 15 05:01  80/2959  " daily run output"
 N  3      Thu Sep 15 14:54  29/960   " weekly run output"

It’s the email numbered 3, so type 3 and hit enter. Once done hit x for exit (or q for quit which will save the mails in a mbox file).

Good that I checked it, as I am seeing the following message:

Checking Let's Encrypt certificate status:
2022/09/15 14:54:44 Error while loading the certificate for domain
        open /usr/local/etc/ssl/lego/certificates/ permission denied

Inspecting the file

root@c1:~ # ls -la /usr/local/etc/ssl/lego/certificates/
-rw-------  1 root  _lego  5394 Sep 14 13:37 /usr/local/etc/ssl/lego/certificates/

shows that it has the wrong file permissions. The problem is, I was running once as root and not as user _lego, so it was creating the file as root. Later on, the periodic script runs correctly as user _lego and can’t access that file anymore. To fix it, I delete the whole lego/certificates directory and properly run

root@c1  $ rm -rf /usr/local/etc/ssl/lego/certificates
root@c1  $ su -fm _lego
_lego@c1 $ /usr/local/etc/lego/ run

As root I am now running periodic weekly again and validate the email report via mail. It’s now printing success:

Checking Let's Encrypt certificate status:
2022/09/15 15:05:05 [] The certificate expires in 89 days, the number of days defined to perform the renewal is 30: no renewal.
Deploying Let's Encrypt certificates:

Open tasks

  • Setting up git-shell properly, restricting ssh access for user www-data to just `git.

  • Creating a HAMMER2 snapshot and comparing the changes.

  • Daily package updates / audits with pkg upgrade --dry-run and pkg audit --fetch --recursive

  • Enable Log rotation

  • Setting up sshlockout

  • Setting up a mail server

  • Configure doas

  • Automating the whole web server setup with a simple script.