Automating TLS Using Dehydrated

Minimalist Setup for HTTPS with Nginx on Debian

It’s Saturday night, and I’m a 37 year old IT guy: let’s deploy a website with TLS using Dehydrated!

Prerequisites

To do that, I setup a virtual machine on Exoscale running Debian 12 “Bookworm”; the hostname being webserver for a lack of creativity. I picked the “Micro” option, which is quite minimalistic (and cheap) with a single CPU core, 10 GB of local storage and 512 MB of memory.

It’s helpful to add the IP address to /etc/hosts:

159.100.252.190 webserver

My SSH key has been copied to the server automatically upon provisioning, so I can start using it right away:

$ ssh debian@webserver

The website should show some Dad Jokes, for which I created a DNS A record for dadjokes.paedubucher.ch on my provider’s web interface, which propagated quite fast to the other DNS servers around:

$ dig dadjokes.paedubucher.ch +short
159.100.252.190

Having both the server and the domain ready, let’s get up our website.

Serving HTTP with Nginx

First, we need a web server. Let’s stay conventianal for now and pick Nginx:

$ sudo apt install -y nginx

The Nginx welcome page should already show up on http://159.100.252.190/. It can be deactivated as follows:

$ sudo rm /etc/nginx/sites-enabled/default

Let’s configure the website for the dad jokes instead. The files should go into /var/www/dadjokes, which should belong to the user www-data running Nginx:

$ sudo mkdir /var/www/dadjokes
$ sudo chown -R www-data:www-data /var/www/dadjokes

An index.html file containing some hilarious dad joke goes into /var/www/dadjokes:

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<title>Dad Jokes</title>
	</head>
	<body>
		<h1>Dad Jokes</h1>
		<p>—What's brown and sounds like a bell?</p>
		<p><em>*Dunggg*</em></p>
	</body>
</html>

A minimalistic (i.e. not following best practices) Nginx configuration is created under /etc/nginx/sites-available/dadjokes.conf, only serving HTTP on port 80 for now:

server {
	listen 80;
	root /var/www/dadjokes;
	server_name dadjokes.paedubucher.ch;

	location / {
		try_files $uri $uri/ =404;
	}
}

The site needs to be enabled using a symbolic link from the sites-available to the sites-enabled folder:

$ sudo ln -s /etc/nginx/sites-available/dadjokes.conf /etc/nginx/sites-enabled/dadjokes.conf

After which the Nginx configuration must be reloaded through systemd:

$ sudo systemctl reload nginx.service

And the dad joke is served on dadjokes.paedubucher.ch, but only using HTTP, which is a shame.

Dehydrated Setup

So, why using Dehydrated instead of Certbot? Well, Certbot comes with a lot of heavy dependencies:

$ sudo apt install certbot
The following additional packages will be installed:
  python3-acme python3-certbot python3-configargparse python3-icu python3-josepy python3-openssl
  python3-parsedatetime python3-rfc3339 python3-tz

Dehydrated is a much leaner alternative based on shell scripts—and also works the same on other platforms such as OpenBSD and FreeBSD. Let’s install dehydrated:

$ sudo apt install -y dehydrated

First, a configuration file needs to be created under /etc/dehydrated/config:

BASEDIR="/var/acme"
DEHYDRATED_USER="acme"
DEHYDRATED_GROUP="acme"
DOMAINS_TXT="/etc/dehydrated/domains.txt"
CONTACT_EMAIL="patrick.bucher@mailbox.org"
CHALLENGETYPE="http-01"
CA="letsencrypt-test"
CONFIG_D="/etc/dehydrated/conf.d"
WELLKNOWN="/var/www/acme"

Dehydrated should use /var/acme as the BASEDIR to store all the certificates and related files. A user called acme (with its own group) should run Dehydrated. This user needs to be created, and /var/acme shall be his home directory:

sudo useradd -m -d /var/acme -s /usr/bin/false acme

The login shell is set to /usr/bin/false, so that the user can’t create interactive sessions, which pose a security risk and are simply not needed.

The domains manage certificates for are listed in /etc/dehydrated/domains.txt, which is rather short here:

dadjokes.paedubucher.ch

All subdomains belonging to the same domain go onto the same line; every line contains the entries for a domain.

The CA letsencrypt-test is used for now; if everything works, this setting is changed to letsencrypt later. (Multiple failed attempts will lead to a forced break, so let’s not take chances with the real CA.)

During the HTTP-challenge verification process, dehydrated will create the needed files in /var/www/acme. This folde must be created and handed over to the acme user and group:

$ sudo mkdir /var/www/acme
$ sudo chown -R acme:acme /var/www/acme

No files need to be created manually, but the website’s .well-known/acme-challenge path must point to that directory, so let’s add this configuration just above the existing location rule to /etc/nginx/sites-available/dadjokes.conf:

location /.well-known/acme-challenge {
    alias /var/www/acme;
    try_files $uri =404;
}

Reload the server configuration once more:

$ sudo systemctl reload nginx.service

It’s time to run Dehydrated, but first let’s test the configuration:

sudo -u acme dehydrated -v

If version information is shown but no an error messages, Dehydrated was configured properly. So let’s get some certificates.

HTTP-01 Challenge

The actual verification is run by a so-called hook script. Our setup uses the HTTP-01 challenge, which roughly works as follows:

  1. The website owner claims to own a certain (sub)domain, e.g. dadjokes.paedubucher.ch.
  2. The certificate authority (CA) wants to proof this by asking the user to put a token into a file being served under that particular domain under the path /.well-known/acme-challenge.
  3. If the website owner can serve this token at the indicated URL, he proofed that he controls the web server to which the (sub)domain is pointing.
  4. The CA signs and hands out the certificate, which then can be installed.

For the HTTP-01 challenge, an example hook script is shipped with Dehydrated. Let’s use that script by linking to it from what is the default hook script location:

$ sudo ln -s /usr/share/doc/dehydrated/examples/hook.sh /etc/dehydrated/hook.sh

Now an ACME account can be created using Dehydrated:

$ sudo -u acme dehydrated --register --accept-terms

If that worked without any error messages, let’s create the first certificate using the letsencrypt-test CA, so no worries if this process fails; this CA is intended for debugging purposes:

$ sudo -u acme dehydrated --cron

A plethora of files should have been created under /var/acme/certs/ if everything worked. If so, let’s switch over from the letsencrypt-test CA to letsencrypt in /etc/dehydrated/config:

CA="letsencrypt"

And now create the proper certificates:

$ sudo -u acme dehydrated --cron

Nginx TLS Configuration

Let’s change the Nginx configuration in /etc/nginx/sites-available/dadjokes.conf to actually use the certificates created:

server {
	listen 443 ssl;

	ssl_certificate /var/acme/certs/dadjokes.paedubucher.ch/fullchain.pem;
	ssl_certificate_key /var/acme/certs/dadjokes.paedubucher.ch/privkey.pem;

	root /var/www/dadjokes;
	server_name dadjokes.paedubucher.ch;

	location /.well-known/acme-challenge {
		alias /var/www/acme;
		try_files $uri =404;
	}

	location / {
		try_files $uri $uri/ =404;
	}
}

server {
	listen 80;
	server_name dadjokes.paedubucher.ch;
	rewrite ^ https://$host$request_uri? permanent;
}

There are two server sections now:

  1. The first server now listens on port 443 instead of 80 and is extended by three options (ssl, ssl_certificate, and ssl_certificate_key).
  2. The second server is just a permanent redirect from HTTP to HTTPS.

Let’s reload the server:

$ sudo systemctl reload nginx.service

If everything was done properly, dadjokes.paedubucher.ch is now served with proper TLS encryption. (I took the server down; even a Micro VM costs me 5-10 CHF a month.)

Certificate Renewal and Housekeeping

Certificats issued by Let’s Encrypt or by the ACME CA, respectively, are only valid for three months and must be renewed during that period. This limitation encourages automation, which is best done using a cronjob:

$ sudo apt install -y cron
$ sudo -u acme EDITOR=vi crontab -e

The command dehydrated --cron, as used above, can also be used to renew certificates. The following command makes sure to log its output to the systemd journal using the acme tag, which then can be inspected using sudo journalctl -t acme later on. The job should run at noon:

# m h dom mon dow command
00 12 *   *   *   systemd-cat -t acme dehydrated --cron

Old certificates should be cleaned up periodically:

15 12 *   *   *   systemd-cat -t acme dehydrated --cleanup

Furthermore, there’s a /var/acme/archive folder, which can be cleansed of old files (300 days and older) periodically:

30 12 *   *   *   systemd-cat -t acme find /var/acme/archive -type f -mtime 300 -delete

With this automation in place, I no longer need to worry about TLS encryption and certificate renewal.

Conclusion

A small VM running Debian 12 “Bookwork” was setup as a web server, and a small demo website was created. Dehydrated was installed and configured, and test as well as productive certificates were issued through the Let’s Encrypt CA. The Nginx configuration was then adjusted to make use of those certificates.

The whole process took me roughly one hour. The steps can be automated using Ansible, so that both the website and TLS can be rolled out even quicker. (Ansible needs Python 3 of course, so using Certbot instead of Dehydrated won’t make a big difference in such a setup.)

You might want to use the SSL Configuration Generator to harden your Nginx TLS configuration; the one provided above is, for the sake of clarity, minimalistic and not following those best practices.

For more technical details, check out TLS Mastery by Michael W. Lucas or my summary of it.