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:
- The website owner claims to own a certain (sub)domain, e.g.
dadjokes.paedubucher.ch
. - 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
. - 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.
- 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:
- The first server now listens on port 443 instead of 80 and is extended by
three options (
ssl
,ssl_certificate
, andssl_certificate_key
). - 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.