Setting Up Free Auto-Renewing TLS/SSL Certificates with Docker, Docker Compose, Certbot, Cron, and Nginx
Setting up verified TLS (transport layer security) certificates for your web services allows them to be trusted by clients connecting over HTTPS. Without a trusted certificate all web browsers will show a warning that discourages users from proceeding to a site. In this post I'm going to break down how you can set up auto renewing TLS certificates for free for any of your domain names. Note: I use the term TLS here as TLS superseded SSL and is therefore technically the correct terminology, though many people still refer to TLS as SSL.
If you want to follow along, all of the code for this project can be found at the Github repo here.
Perquisites
- A basic understanding of Docker, Docker Compose, Linux, and networking concepts
- A Linux server with Docker and Docker Compose installed
- Port 80 and 443 open on your server, with port forwarding set up for them on your router
- A domain name pointed at your server's public IP address. You can get a free subdomain from freedns.afraid.org which will work fine
Intro to Certbot & Let's Encrypt
First, let's look at what Certbot and Let's Encrypt are, and how they work together.
Certbot is a free and open source tool which runs on your server that can be used to provision a TLS certificate by using the free certification service provided by the open certificate authority Let's Encrypt. Certbot and Let's Encrypt work together to achieve this by using the ACME protocol which forms the following challenge process:
- The Certbot instance running on the server you enabled port forwarding to will send a request to Let's Encrypt to initiate verification for a given domain name. As part of this request, Certbot will also send over a public cryptographic key (part of a pair, the private key of which is kept on the server). If you are not familiar with keys and asymmetric encryption, you can learn about it with this excellent primer: Asymmetric Encryption - Simply explained.
- In reply Let's Encrypt will send a challenge to the domain name provided in the form of a file to be placed at a specific path on the web server.
- If your Certbot instance is truly located at the domain provided, it will receive the file, sign it with the private key counterpart to the public key it sent to Let's Encrypt, and then place it at the web server path expected by Let's Encrypt.
- Let's Encrypt will then make another request to the domain provided and attempt to download the signed version of the file it posted there from the expected web server path.
- If Let's Encrypt could successfully download the file, it will use the public key provided by Certbot at the start of the process to verify through a hash that the file was correctly signed, proving that your Certbot instance is in possession of the public key's private counterpart, and it resides at the domain name provided.
- If this is successful, Let's Encrypt will sign a 90 day TLS certificate for your domain name with its own certificate's private key and send it back to your Certbot instance, which will place it in a directory (usually
/etc/letsencrypt/live/
) where it can be accessed by web servers to enable HTTPS transmission.
How does this mean your certificate is trusted? In short: your certificate is now at the end of a certificate chain which can be linked back through signing/hashing to Let's Encrypt's intermediate authority certificate, which can itself be linked back to a root certificate authority's certificate (probably ISRG Root X1). This root certificate is trusted automatically by all web browsers, and can be used to verify transitively that Let's Encrypt's certificate, and therefore your own certificate can be trusted.
Provisioning a TLS Certificate
Let's look at how we can use the Certbot Docker image to request a certificate using the ACME protocol.
Create a new project in your favourite code editor, then create a directory called certbot
, and within this directory create another directory called initial
, and within that directory create a file called docker-compose.yml
. In this file, start with the following configuration:
# ./certbot/initial/docker-compose.yml
version: 3.9
services:
certbot-initial:
# change to fixed version in production
# for raspberry pi (arm) latest does not work at time of writing. Use something like arm64v8-latest
image: certbot/certbot:latest
container_name: certbot-initial
volumes:
# where certbot will place the certificates once the challenge is complete. rw allows read write.
- /etc/letsencrypt:/etc/letsencrypt:rw
ports:
# bind 80 on the host, to 80 on the container. this allows certbot to receive the challenge from Let's Encrypt
- "80:80"
# see explanation below
command: certonly --standalone --non-interactive --email my@email.com --agree-tos --preferred-challenges http --dry-run -d my.domain.com
If you are familiar with Docker and Docker Compose, most of this will be straightforward. For the command
key, all of these arguments will be appended to the certbot
command, which is the entry point of the certbot/certbot:latest
image. From left to right the arguments mean the following:
certonly
is used because we only want to be issued a certificate, and we do not want TLS to be automatically configured for a web server. We will configure it manually later.--standalone
is used because we want Certbot to run as a self contained web server instead of using Nginx or another web server as a reverse proxy, which we will do later.--non-interactive
is used to tell certbot not to expect any user input--email my@email.com
provides an email address to Let's Encrypt, which is required should they ever need to contact you about your certificate. Change it to your email.--agree-tos
is required to use the Let's Encrypt service.--preferred-challenges http
tells Certbot to request that Let's Encrypt used port 80 (the only port linked to the container) for the challenge process.--dry-run
instructs Certbot to use Let's Encrypt's staging server. This allows us to test if the configuration defined is correct. If you omit this flag and spam Let's Encrypt's service by running a Certbot container multiple times, your server may be blocked from making verification requests for a period of time. Read more about rate limits here.-d my.domain.com
tells Certbot the domain name which we want to get a trusted certificate for. Change it the domain name you set up and pointed at the server in the Prerequisites section.
If you have satisfied the requirements in the Perquisites section above, then copy the project onto your server if you're not already on it (I push it to a repo on GitHub and clone it down, by you could also scp
it across). On your server, cd
into the ./certbot/initial/
directory and run docker compose up
and you should see Certbot perform the ACME challenge process with Let's Encrypt, and then report if the dry run was successful. If all goes well, remove the --dry-run
flag from the certbot-initial
's command
key in the docker-compose.yml
file, and re-run docker compose up
. This will cause Certbot to use Let's Encrypt's production servers, which will return a valid TLS certificate if everything checks out. This certificate can be found at /etc/letsencrypt/live/my.domain.com/
on the host machine.
If you got this far, congratulations! You now have a verified TLS certificate for your domain which is valid for 90 days. I recommend you make a backup of this certificate. Do not whatever you do, run the certbot-initial
container many times without the --dry-run
flag, as you will may soon become barred from making more requests to the Let's Encrypt servers for a period of time (I did this once without backing up my certificates and was left without TLS for a week, though luckily not in production).
Using the TLS certificate with Nginx
Next let's look at how we can set up Nginx to use our newly acquired TLS certificate.
Nginx is an event loop based web server that can also be used as a reverse proxy, load balancer, mail proxy and HTTP cache. Once we have a TLS certificate, it is very easy to set up Nginx to enable HTTPS.
In the root of the project directory on your development machine, create another directory called nginx
. Within this directory create another directory called conf.d
and within it, create a file called nginx-test.conf
, and add the following block (this is not fit for production, it is a minimal config). Read the comments to understand the server configuration and what to change.
# ./nginx/conf.d/nginx-test.conf
server {
# this server listens on 443
listen 443 ssl;
# incoming requests must have this hostname. change!
server_name my.domain.com;
# the location of the tls cert. change!
ssl_certificate /etc/letsencrypt/live/my.domain.com/fullchain.pem;
# the location of the tls cert's private key. change!
ssl_certificate_key /etc/letsencrypt/live/wy.domain.com/privkey.pem; # change this!
location / {
# where nginx will serve files from. full path /usr/share/nginx/html
root html;
# the file that is served if a user goes to /
index index.html;
}
}
server {
# this server listens on 80
listen 80;
# incoming requests must have this hostname. change!
server_name my.domain.com;
# the location Let's Encrypt challenge will make requests to
location /.well-known/acme-challenge/ {
# where certbot will create the challenge files
root /var/www/certbot;
}
# redirect anything else to https
location / {
return 301 https://$host$request_uri;
}
}
Make sure you change all instances of my.domain.com
to match the domain name you used when provisioning for the initial certificate or your certificate will not be trusted, and the configuration may not work at all.
Having created the nginx config, go back to the root of your project and create a docker-compose.yml
file, and add the following:
# ./docker-compose.yml
version: "3.9"
services:
nginx:
image: nginx:latest # change this in production for a fixed value
container_name: nginx
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
# where the nginx config is located
- ./nginx/conf.d/:/etc/nginx/conf.d/:ro
# where certbot placed the initial tls cert, and where refreshed certs will be placed
- /etc/letsencrypt:/etc/letsencrypt:rw
# where certbot writes challenge files to when being challenged by a Let's Encrypt server
- /var/www/certbot:/var/www/certbot:ro
This compose config can be used to start an nginx server which will use the config that was defined previously, as well as the TLS certificate obtained from Let's Encrypt.
Run docker compose up -d
, which will pull the nginx image, and then start the server.
If you enter your domain name into your browser prefixed with https://
(i.e. https://my.domain.com
), you should now land at the nginx welcome page with the browser recognising the TLS certificate of the server as trusted (shown with the padlock in the address bar).
Setting Up Certificate Auto-Renewal
We've got half the way there, and we now have a certificate that will be valid for 90 days, but after this period, clients communicating with your server will stop trusting it. Now we'll look at how we can get the certificate to automatically renew before expiry.
To get a new certificate, we could temporarily shut down the nginx container, therefore unbinding from the server's ports 80 and 443, and then run the certbot-initial container again. However this is not ideal as: our site will be unavailable for a period of time, and we have to remember when the certificate is going to expire and intervene. What we really want is a way of automatically renewing the certificate. To achieve this, we're going to make a container which will run a certbot renew
command using cron (a scheduling utility) every 12 hours. This renew command will request a new certificate, with nginx serving the challenge files.
Within the ./certbot/
directory we created earlier, create a directory called renew
, and within this directory create a file called cert-renew-crontab
. Add the following to this file:
# ./certbot/renew/cert-renew-crontab
0 0,12 * * * certbot renew --webroot -w /var/www/certbot > /proc/1/fd/1 2>/proc/1/fd/2
Here is an explanation of the config:
0 0,12 * * *
tells cron to run the command following at the 0th minute of the 0th hour (12am) and 12th hour (12pm), on any day of any month, on any day of the week (every day).certbot renew --webroot -w /var/www/certbot
is the command which will be run, which tells Certbot to initiate a certificate renewal using the webroot plugin which defers challenge file serving to a web server (nginx in our case), with the challenge files being placed in/var/www/certbot
./proc/1/fd/1 2>/proc/1/fd/2
redirects the process'sstdout
andstderr
to the container'sstdout
andstderr
so it can be seen in the container logs.Still in the
./certbot/renew/
directory, create another file calledDockerfile
and add the following to it:
FROM certbot/certbot:latest
COPY cert-renew-crontab /etc/crontabs
# load crontab file
RUN crontab /etc/crontabs/cert-renew-crontab
# run cron in foreground
ENTRYPOINT ["crond", "-f"]
This Dockerfile builds upon the Certbot image and takes advantage of the fact that it includes the cron command. When building an image based on this Dockerfile, docker will copy the cert-renew-crontab
file we wrote previously into a built image's crontab directory, load the file into cron, and then run the cron daemon. When this image is run as a container, the command to renew the certificate will be run by cron at 12am and 12pm every day. You might be concerned that Let's Encrypt will start blocking renewal requests at that frequency, but Certbot will avoid doing this by checking if the certificates at /etc/letsencrypt/live/
are going to expire within 30 days, and only if they are going to expire will it actually attempt a renewal with Let's Encrypt.
To actually build and run an image based on this Dockerfile, let's go back to the project's root and modify the ./docker-compose.yml
file where we defined the nginx service previously. In the file's list of services, add the following:
cert-renew:
container_name: certrenew
build:
context: .
dockerfile: ./certbot/renew/Dockerfile
volumes:
# where the certs are located
- /etc/letsencrypt:/etc/letsencrypt:rw
# where the renew challenge will be written and then served by nginx
- /var/www/certbot:/var/www/certbot:rw
This service will create a container with the image defined by the Dockerfile we defined above. Mounting /etc/letsencrypt
allows Certbot to see the certificates that were put onto the host by the certbot-initial
container. This allows Certbot to tell when they have expired, and subsequently replace them with new certificates.
Running docker compose up -d
should start a new container for the cert-renew service, and as defined in the crontab file, it will renew the certificates 30 days before they expire.
Getting Nginx to Use the Renewed Certificates
There is still a problem: even if the certificate is successfully renewed by the certrenew container, the nginx container will not use them. This is because when nginx starts, it caches the certificates in memory to enable higher performance than if it were reading from the on disk files for every TLS handshake. Now we'll look at how we can get Nginx to automatically reload the certificate cache when the certificates have been renewed.
In the project's ./nginx/conf.d
directory create a file called reload-cert.sh
and add the following code block to it, making sure to change my.domain.com
on line 8 to your certificate's domain.
#!/bin/bash
while :; do
# format: 03/08/22 11:36:26
now=$(date +"%d/%m/%y %T")
logPrefix="$now [CERT RENEW]"
# get the current certificate expiry
expiry=$(openssl x509 -enddate -noout < /etc/letsencrypt/live/my.domian.com/fullchain.pem)
# check if there was a problem in getting the expiry
if [[ $? -ne 0 ]]; then
echo "$logPrefix failed to get certificate expiry: $expiry"
else
# check if previous expiry not empty string, and if different from current expiry
if [[ -n "$prevExpiry" && "$expiry" != "$prevExpiry" ]]; then
# if different then has changed, and should reload nginx
echo "$logPrefix certificate changed. reloading nginx"
# Unlike restart, reload does not drop connections, but still good to keep it to minimum
nginx -s reload
else
echo "$logPrefix certificate not changed"
fi
echo "$logPrefix current expiry: $expiry"
# set the previous expiry to current expiry
prevExpiry="$expiry"
fi
# sleep for 24 hours
sleep 24h
# background the loop command with & so it keeps running
done &
Read the comments to understand each command in the script, but the general idea is to use openssl
to check if the certificate has changed since the script was last run, and if it has changed, send a reload signal to Nginx with nginx -s reload
to get it to load the new certificate into its cache. The script will then sleep for 24 hours, and then repeat.
To run this script we'll mount it into the nginx container's /docker-entrypoint.d/
directory. Any scripts in this directory will automatically be run by the container on start, and it includes the script to start the container's Nginx process. We can use this behaviour to get the container to start a second process which will run the above script, reloading Nginx when the certificate has been renewed.
To mount the script into the nginx container, change the project's ./docker-compose.yml
file by adding the following to the nginx service's volume list: - ./nginx/reload-cert.sh:/docker-entrypoint.d/reload-cert.sh:ro
. Then in the root of the project run docker compose restart nginx
, which should restart the nginx service with the above script running. You can find log messages from the script by searching the container's logs for [CERT RENEW]
.
Conclusion
In this post we've seen how to:
Generate an initial TLS certificate with Certbot in standalone mode
Use the certificate with Nginx
Set up automatic certificate renewal with Certbot in webroot mode
Set up automatic certificate reload with a script running in an Nginx container
You can check out the completed project at the Github repo here.
Once you've set up this basic template, it's very easy to use it in any project whenever you need a TLS certificate. If you're building a website, the next step would be to mount the site's files into the nginx container, and configure Nginx to serve them to users visiting your domain. For a backend, the next step would be to configure Nginx to act as a TLS termination point and then reverse proxy requests to your backend services. I will probably write another post which will explain how this is done at some point.