Manage Letsenrypt HTTPS/SSL certificates with a Docker container using Cron, Nginx, and Certbot

; Date: Mon Jun 29 2020

Tags: Docker »»»» Letsencrypt »»»» HTTPS »»»» Nginx

Modern websites must have HTTPS support for security reasons. As a result web browsers and search engines have begun downgrading sites that do not support HTTPS. That means we all must have a simple, low cost, way to set up HTTPS support on our websites. The Letsencrypt project offers free SSL certificates for HTTPS. In this project we will create a Docker container for handling HTTPS via Nginx, and automated SSL certificate renewal using the Letsencrypt command-line tools (Certbot).

Letsencrypt is a very good service, offering free SSL/HTTPS certificates unlike the commercial SSL/HTTPS certificates costing a large sum. SSL/HTTPS certificates are what puts the lock icon in the URL bar of a web browser. This assures your visitors that the website is what it claims to be, and their access is secured. The practical benefit is encrypting HTTPS traffic, making it harder for others to spy on the web pages we all look at.

The main issue with Letsencrypt is that certificates expire after 90 days. This means a site administrator must be on top of renewing the certificates, and of course it's best to automate the renewal process. We will show one way to automate Letsencrypt SSL certificate renewal in an Nginx Docker container.

Letsencrypt is used with any of several client programs. Using the command-line tools an administrator can provision, revoke, and otherwise manage SSL certificates.

The primary recommended tool is Certbot, a CLI tool available for pretty much every operating system. With it, automating Letsencrypt certificate renewal can be as easy as a cron job running certbot renew. The Letsencrypt team recommends renewal attempts every couple days to ensure yours are promptly renewed when needed.

For this project we will set up a Docker container containing Nginx, Cron, and Certbot. We will experiment with provisioning and renewing Letsencrypt HTTPS/SSL certificates inside that container. The ideas should be transferrable to other web servers as well.

  • Nginx is a leading web server. It is demonstrated here serving a simple HTML static website, but its capabilities go way beyond that by supporting HTTP and HTTPS proxying, load balancing and more.
  • Cron is a standard tool for managing recurring processes. In this case it is used to record a schedule for running certbot renew to refresh the SSL certificates.
  • Certbot is a leading client program for Letsencrypt. It is a command-line tool for provisioning SSL certificates, revoking them, and generally managing SSL certificates.

The result is a Docker container named Cronginx. It is available on Github at: (github.com) https://github.com/robogeek/cronginx The repository contents is a recipe for constructing such a container, and this article is the documentation for that container.

There are two stages to setting up a domain for HTTPS with Letsencrypt.

  1. The domain is not yet registered with Letsencrypt. In this stage we perform the registration, and Nginx is configured to serve HTTP traffic.
  2. The domain is registered and we have the SSL certificates. In this stage we reconfigure Nginx to use the certificates, redirect HTTP traffic to the HTTPS server, and configure the HTTPS server to use the SSL certificates. In this stage we also occasionally run Certbot to renew the certificates.

In this specific project we don't have any service to integrate with Nginx. Instead we'll use the built-in tiny website inside the Nginx container. I do use this technique on several websites - for example at home I have an Intel NUC on which I've installed Gogs (and am converting over to Gitea), Jenkins, and NextCloud. I use this exact technique to make these services available on the public Internet as well as my home network, and all the services are HTTPS-protected.

Shell script for registering domains with Letsencrypt

Let's start with register.sh in the workspace:

#!/bin/sh

mkdir -p /webroots/$1/.well-known/acme-challenge

certbot certonly --webroot -w /webroots/$1 -d $1

There are several commands in the Certbot tool. With the certonly command the certificates are simply downloaded and are not installed anywhere. The certificates land in a directory hierarchy, /etc/letsencrypt. Later we will show how to configure Nginx to use the certificates.

This script takes a domain name on the command-line as the first argument. The first step is creating a directory hierarchy in /webroots that will be used by Certbot during the domain validation step. The next step is running certbot to validate your ownership of the domain, and to register it with Letsencrypt.

Letsencrypt, like all other SSL providers, must have certainty that you own the domain for which you're claiming an SSL certificate. One method to verify ownership is the HTTP-01 Challenge. The challenge involves placing a specially named file, with specific contents, into a location such that a request to this URL retrieves that file:

http://your-domain.com/.well-known/acme-challenge/CODED-FILE-NAME

The directory, /webroots/$1/.well-known/acme-challenge, is to correspond to that URL. The .well-known URL is an Internet Standard meant for services like this.

When we run certbot certonly, Letsencrypt tells the file name and contents to use. That file is written to the directory structure named in the -w option. The domain for which the SSL certificate is requested is named in the -d option. Once Certbot writes this file to the directory, the Letsencrypt checks the URL. If it can successfully retrieve that URL, then Letsencrypt has verified domain ownership, and it issues the SSL certificate.

We haven't discussed the Docker container yet, but this tells us about two directory hierarchies that must be exported from the container:

  • /etc/letsencrypt -- This holds status files, and certificates, which must be tracked beyond the routine destruction and recreation of containers. Letsencrypt SSL certificates have a 90 day lifespan, and successful certificate renewal requires tracking which certificates are how old, and also preserving the certificates for the 90 day lifespan.
  • /webroots/DOMAIN-NAME -- This directory holds the validation files. It may be useful to preserve the contents. The Nginx configuration must allow access to this directory.

Another requirement is for certbot renew to execute on a regular basis to assist with automating certificate renewal.

Dockerfile for the Cron+NGinx+Certbot container (cronginx)

We've discussed how to provision certificates from Letsencrypt. Let's start to look at how to assemble a running service. Defining the Docker container of course requires building a Dockerfile to describe the image.

FROM nginx:stable

# Inspiration:
# https://hub.docker.com/r/gaafar/cron/

# Install cron, certbot, bash, plus any other dependencies

RUN apt-get update \
    && apt-get install -y cron bash wget certbot \
    && apt-get update -y \
    && mkdir -p /webroots /scripts \
    && rm -f /etc/nginx/conf.d/default.conf \
    && rm -f /etc/cron.d/certbot

COPY *.sh /scripts/
RUN chmod +x /scripts/*.sh

# /webroots/DOMAIN.TLD/.well-known/... files go here
VOLUME /webroots
# This handles book-keeping files for Letsencrypt
VOLUME /etc/letsencrypt
# This lets folks inject Nginx config files
VOLUME /etc/nginx/conf.d

WORKDIR /scripts

# This installs a Crontab entry which 
# runs "certbot renew" on several days a week at 03:22 AM

RUN echo "22 03 * * 2,7 root /scripts/renew.sh" >/etc/cron.d/certbot-renew

# Run both nginx and cron together
CMD [ "sh", "-c", "cron && nginx -g 'daemon off;'" ]

We start by installing various tools, and setting up some necessary directories.

Three directories, /webroots, /etc/letsencrypt and /etc/nginx/conf.d, are declared as VOLUME's. That way we can inject those directories and persist the content when destroying and rebuilding containers. We mentioned the first two directoris earlier, and the third is for storing Nginx configuration files. It's useful to follow a convention of having one config file per domain name.

Then we create a Cron job to run /scripts/renew.sh every few days. It is easy to set up Cron jobs simply by dropping files into /etc/cron.d this way. There is an existing cron job, installed by Certbot, that we are supplanting with our own, which was deleted earlier in the Dockerfile.

The final line starts up both Cron and Nginx. We need both running for this container to perform its purpose. Cron is executed so that it makes itself into a background process. By contrast, Nginx with these arguments will stay in the foreground. It must stay in the foreground for Docker to recognize that the container is still running, otherwise Docker will think the container has crashed.

The result is that Cronginx has both Cron and Nginx running in the background, and Certbot is installed and is being used by a couple shell scripts stored in /scripts.

Cron is there so we can run a script every day or so to attempt to renew Letsencrypt certificates. This is to automate Letsencrypt certificate renewal so that the site administrator has one less detail to chase after.

Cron jobs for automating Letsencrypt SSL certificate renewal

The Dockerfile inserts a Cron job to run /scripts/renew.sh on a regular basis. This script contains:

#!/bin/sh
/usr/bin/certbot renew
kill -HUP `cat /var/run/nginx.pid`

This script runs certbot renew, which scans data in the /etc/letsencrypt directory and from that determines which certificates need renewal. If any need renewal, it will initiate the process and write new certificates.

The script ends by sending a SIGHUP signal to the Nginx process. This will tell Nginx to reread its configuration files, and therefore start using them and any newly downloaded certificates.

Initial setup for Cronginx

For the initial setup for Letsencrypt on a specific domain we need to run register. But there is some setup required to initialize the directories required for successfully using Cronginx. Primarily we need the directories mentioned earlier, and the initial Nginx config file for at least one domain. Once that's ready we can launch the Cronginx container.

On your Docker host create at least two directories:

  • nginx-conf-d - This will hold Nginx configuration files for any sites you want to configure
  • etc-letsencrypt - This will hold the Letsencrypt data files
  • webroots - This will map to /webroots and contain the DOMAIN.NAME/.well-known directory for each domain you register.

For the purpose of our discussion -- we will do setup for test.geekwisdom.net. Therefore that will require adding file: nginx-conf-d/test.geekwisdom.net.conf -- The Nginx config file for the domain.

When we finally launch the Nginx container, we'll learn the supplied config file, /etc/nginx/nginx.conf, contains this line:

include /etc/nginx/conf.d/*.conf;

This means any file in /etc/nginx/conf.d/ will be loaded as an Nginx configuration file. The context is an http block, so every such file must contain declarations suitable for an Nginx http block. The nginx-conf-d directory will be used to hold those files.

In nginx-conf-d add a file named YOUR.DOMAIN.conf containing the following:

# HTTP — redirect all traffic to HTTPS
server {
    listen 80;
    # listen [::]:80 default_server ipv6only=on;

    # Here put the domain name the server is to be known as.
    server_name  YOUR.DOMAIN www.YOUR.DOMAIN;
    access_log   /var/log/YOUR.DOMAIN.access.log  main;
    error_log   /var/log/YOUR.DOMAIN.net.error.log  debug;

    # This is for handling the ACME challenge.  Substitute the
    # domain name here.
    location /.well-known/ {
        root /webroots/YOUR.DOMAIN/;
    }

    # Use this for a static HTML site, specifically the default
    # site supplied with the default Nginx container
    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    # Use this to proxy for another service
    # location / {
    #    proxy_pass      http://back-end-service:3080/;
    # }

    # Use this to force a redirect to the SSL/HTTPS site
    # return 301 https://$host$request_uri;
}

This server definition will be used for the initial setup. It supplies only an HTTP listener (port 80). It is configured to listen for the specified domain and you must substitute your own domain. Likewise logging is setup and you must substitute your own domain.

The location /.well-known/ block tells Nginx how to handle the .well-known URL. We are telling it to serve up any files from the named directory. Again, substitute your own domain here.

The next question is how to handle all other URL's for the domain. For demonstration purposes we are showing the HTML files in the default HTML site supplied in the Nginx container. Hence the location / block references the directory containing the default site. You could easily modify this to show your own site, or to proxy to a background service. There is a commented-out example of proxying a background service.

Starting the Cronginx container for initial setup

We have created a Dockerfile with which to build the image, have set up a couple directories to hold important files, one of which is the initial Nginx configuration for the domain. In this section we'll start the Cronginx container.

First - we must build the Docker image:

$ docker build -t cronginx:latest .

You must then propagate this Docker image to your Docker host somehow. That's up to you. For example: Using SSH to remotely control a Docker Engine or Docker Swarm in two easy steps

Once you have the image available on the Docker host, you can run this:

$ docker run --name cronginx -p 80:80 -p 443:443 \
    -v "/home/ubuntu/webroots:/webroots" \
    -v "/home/ubuntu/etc-letsencrypt:/etc/letsencrypt" \
    -v "/home/ubuntu/nginx-conf-d:/etc/nginx/conf.d"  \
    cronginx

The Docker host in this case is an EC2 instance with an Ubuntu AMI installed. Therefore the home directory is /home/ubuntu and we can make our directories there.

We created /home/ubuntu/webroots to mount as /webroots inside the container.

We created /home/ubuntu/nginx-conf-d to mount as /etc/nginx/conf.d inside the container. This is the directory where config files goes.

We created /home/ubuntu/etc-letsencrypt to mount as /etc/letsencrypt inside the container.

We're publishing both port 80 and port 443, the latter because our goal is implementing HTTPS support.

At this point you should be able to retrieve the HTTP version of your site in a browser. The HTTPS version will return a failure.

Provisioning HTTPS/SSL certificates using Letsencrypt

Our next step is to provision the SSL certificates. To do that we start a shell inside the Cronginx container, and run the register shell script.

$ docker ps
...  verify the container exists, and its name
$ docker exec -it cronginx bash

We use docker ps to first be certain that the container is running. If you've deployed this in a Docker Swarm, the container name will be different than just cronginx. Having verified the container name, we can execute the second command. This starts a Bash shell inside the container, and we can poke around, run commands, etc.

In particular, we can run this:

root@f6fa12d79613:/scripts# ./register.sh YOUR.DOMAIN

The register script makes it this easy to request an SSL certificate. Remember that it creates a directory in /webroots then runs certbot certonly.

The first time you run Certbot, it will ask you a few questions, which are primarily about your identity and e-mail address.

It will then run the HTTP-01 challenge, meaning it tries to retrieve this:

http://your-domain.com/.well-known/acme-challenge/CODED-FILE-NAME

Hence, Nginx needs to be configured to handle that directory, which we did earlier.

If successful you'll get output like this:

Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator webroot, Installer None
Obtaining a new certificate

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/YOUR.DOMAIN/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/YOUR.DOMAIN/privkey.pem
   Your cert will expire on 2020-09-19. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
...

Additionally the /etc/letsencrypt directory will contain a bunch of files. The primary goal is the two files it mentions here. These are the SSL certificate files.

If instead you get failures, here's a couple ideas to try:

  • Check the log files in /var/log for interesting messages
  • On another computer somewhere: curl -f -v http://DOMAIN.com/.well-known/acme-challenge/ For example, create a blank file in that directory, and add its name to the URL, to give you a known file to retrieve. The result of this command will inform you about any errors that might have happened.

Updating the Nginx configuration to support HTTPS

If the work in the previous section was successful, we will have the SSL certificates we must update the Nginx configuration to handle both HTTP and HTTPS traffic. To support HTTPS it needs to reference these SSL certificates.

# HTTP — redirect all traffic to HTTPS
server {
    listen 80;
    # listen [::]:80 default_server ipv6only=on;

    # Here put the domain name the server is to be known as.
    server_name  YOUR.DOMAIN www.YOUR.DOMAIN;
    access_log   /var/log/YOUR.DOMAIN.access.log  main;
    error_log   /var/log/YOUR.DOMAIN.error.log  debug;

    # This is for handling the ACME challenge.  Substitute the
    # domain name here.
    location /.well-known/ {
        root /webroots/YOUR.DOMAIN/;
    }

    # Use this to force a redirect to the SSL/HTTPS site
    return 301 https://$host$request_uri;
}

# HTTPS service
server { # simple reverse-proxy
    # Enable HTTP/2
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    # Substitute here the domain name for the site
    server_name  YOUR.DOMAIN www.YOUR.DOMAIN;
    access_log   /var/log/YOUR.DOMAIN.access.log  main;
    error_log   /var/log/YOUR.DOMAIN.error.log  debug;

    # Use the Let’s Encrypt certificates
    # Substitute in the domain name
    ssl_certificate /etc/letsencrypt/live/YOUR.DOMAIN/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/YOUR.DOMAINt/privkey.pem;

    # Replication of the ACME challenge handler.  Substitute
    # the domain name.
    location /.well-known/ {
        root /webroots/YOUR.DOMAIN/;
    }

    # Use this for a static HTML site, specifically the default
    # site supplied with the default Nginx container
    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    # Use this to proxy to Socket.IO service on the back-end service
    # The reason is that Socket.IO requires upgrading the
    # HTTP/1.1 connection to WebSocket.
    # See:
    # https://stackoverflow.com/questions/29043879/socket-io-with-nginx
#    location ^~ /socket.io/ {
#
#        proxy_set_header X-Real-IP $remote_addr;
#        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#        proxy_set_header Host $http_host;
#        proxy_set_header X-NginX-Proxy false;
#
#        proxy_pass http://svc-notes:3000;
#        proxy_redirect off;
#
#        proxy_http_version 1.1;
#        proxy_set_header Upgrade $http_upgrade;
#        proxy_set_header Connection "upgrade";
#    }

    # Use this for proxying to a backend service
    # The HTTPS session is terminated at this Proxy.
    # The back end service will see a simple HTTP service.
#    location / {
#        proxy_set_header X-Real-IP $remote_addr;
#        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#        proxy_set_header X-NginX-Proxy true;
#        proxy_pass http://back-end-service:4080/;
#        proxy_ssl_session_reuse off;
#        proxy_set_header Host $http_host;
#        proxy_cache_bypass $http_upgrade;
#        proxy_redirect off;
#    }

}

We now have two servers, one for HTTP and the other for HTTPS. The HTTP server handles the .well-known requests, and for any other URL it sends a redirect to the HTTPS version of the URL. This way visitors will be automatically sent to the HTTPS site.

The second server handles HTTPS traffic.

The two ssl_certificate lines refer to the SSL certificate files given to us by Letsencrypt.

We continue to handle .well-known here just in case Letsencrypt tries to validate using the HTTPS URL.

For this example, we continue serving the HTML files because we don't have any website to show. In commented-out code we show a pair of useful recipes for background services. In the one case, (socket.io) Socket.IO needs to upgrade the HTTP/1.1 connection to WebSocket, and this listener does so. On the other case, we have a reverse proxy that takes the incoming HTTPS traffic, proxying it to an HTTP backend service.

I have successfully used this setup for services like Gogs/Gitea, Nextcloud, and Jenkins.

Once you install this new configuration file, it's necessary to restart the Docker container:

$ docker restart cronginx

That's one way to handle it, simply restarting the container. Another way is this:

$ docker exec -it cronginx bash
root@f6fa12d79613:/scripts# kill -HUP `cat /var/run/nginx.pid`

This sends a SIGHUP signal to the Nginx process. When Nginx receives that signal it reloads its configuration files.

Once you do so, visiting your website on the HTTP URL will automatically redirect over to the HTTPS URL.

Automated renewal of Letsencrypt SSL certificates

Remember that in the container we set up Cron and gave it a script to run every few days. This script runs certbot renew then sends SIGHUP to the Nginx process.

Summary

In this project you've learned how to automate Letsencrypt SSL certificate renewal in a Docker container. We used a Cron job to automate running certbot renew as needed, and we created a shell script to assist with registering domains with the Letsencrypt service.

This technique can be used in many settings, and it could be used with other webservers. Because Nginx is so flexible, it can be used with pretty much any back end service, or this could be used for static HTML website hosting.

I use this technique to host a few services on my home network. My ISP provides a fixed IP address, and I configured the DSL router to send ports 22, 80 and 443 to an Intel NUC. The NUC is running Docker, and includes an earlier implementation of this idea. I have several domains pointed at my home IP address, and the Nginx configuration has server objects configured for each. For example git.DOMAIN.EXT is proxied to a Gogs instance (soon to be replaced with Gitea), build.DOMAIN.EXT is proxied to a Jenkins instance, and cloud.DOMAIN.EXT is proxied to a NextCloud instance. Each is in their own Docker container. The result is a Git and Build server for my private projects. The Cronginx container makes it possible to access these services from anywhere on the Internet - for example a couple years ago whole traveling in Europe, I was routinely using this server which is sitting in my house in California.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.