How to Set Up Let's Encrypt HTTPS Certificates in Docker

Connection security information in Web browser

Encryption via HTTPS is something that virtually every website should support, and Let’s Encrypt lets you set this up (mostly) automatically, and for free.

I recently migrated a personal server to use Docker, and this is how I set up HTTPS certificates on it.

Update: I missed a critical step in the renewal process which would cause the renewed certificates to not be deployed. I have corrected it here. If you’re interested, I’ve written a follow-up post with some lessons I learned from this.


Background

I assume you have some knowledge about:

  • HTTPS: What a TLS/SSL certificate is and why you need one
  • Docker and Docker Compose: How to work with containers, volumes, Dockerfiles, and Compose files
  • NGINX: Basic configuration

Overview

Let’s Encrypt is a certificate authority (CA) that issues certificates automatically, but first it needs to check that the entity requesting the certificate actually has control over the domain that will be part of the certificate. The “How It Works” page on their website provides an overview of this process.

The validation method we’re interested in is the HTTP-based one, where the agent on the Web server publishes a challenge from the CA that is accessible over HTTP.

The agent we’ll be using is Certbot. It has different plugins for various Web server programs, but the one we’ll use is the Webroot plugin, which simply writes a file to the directory which contains the files being served.

Step 1: Set Up Docker Volumes

We’ll be running NGINX and Certbot in two separate containers, and volumes are the standard way of sharing data between different containers. We’ll use two volumes:

  • One volume for storing the challenges that Certbot writes, so that NGINX can serve them for verification
  • One volume for storing the certificates, so that NGINX can present them for HTTPS connections

For example, if we have this Docker Compose file:

version: '3'
services:
  nginx:
    build: ./nginx
    ports:
      - "80:80"

We can define the two volumes and mount them in the NGINX container:

version: '3'
services:
  nginx:
    build: ./nginx
    # Mount volumes in Web server container
    volumes:
      - certbot-certs:/etc/letsencrypt:ro
      - certbot-challenges:/srv/certbot-challenges:ro
    ports:
      - "80:80"
# Define the volumes
volumes:
  certbot-certs:
  certbot-challenges:

Step 2: Configure NGINX to Serve Challenges

The challenges are now accessible to NGINX, but they need to be served in order for Let’s Encrypt’s validation to succeed.

We can serve the challenges by setting the root directive within a location block:

server {
	listen 80;

	# ...

	# location block for challenges from Certbot
	location /.well-known/acme-challenge/ {
		root /srv/certbot-challenges;
	}
}

(Note that /.well-known/acme-challenge/ is the standard path for HTTP-based challenges from Let’s Encrypt.)

Step 3: Run Certbot

Now we can run Certbot:

docker run -it --rm \
-v myserver_certbot-certs:/etc/letsencrypt \
-v myserver_certbot-challenges:/srv/certbot-challenges \
certbot/certbot:v2.7.3 certonly \
--webroot --webroot-path /srv/certbot-challenges

Certbot will ask you a few questions and then perform the challenge validation. After that, you’ll have a certificate.

A few notes:

  • The myserver prefix represents the Compose project name. You’ll have to adjust this to match what you’re using for your setup. (Alternatively, you could create a separate Compose file with Certbot as a service, but I think that’s excessive for a one-time command.)
  • I’m using a specific tag for the Docker image here, but you should check for the latest version on Docker Hub.
  • The certonly command tells Certbot to just obtain a certificate and not automatically configure the server. The --webroot option selects the Webroot plugin, and the --webroot-path option specifies the directory to place the challenge in. (The directory needs to be the same one where the volume is mounted.)

Step 4: Set HTTPS Configuration for NGINX

After we get the certificate, we need to configure NGINX to use it. We’re also going to set some other options for security, based on the Mozilla SSL Configuration Generator.

server {
	listen 80;

	# ...

	# Redirect to HTTPS
	location / {
		return 301 https://mydomain.example$request_uri;
	}

	# location block for challenges from Certbot
	location /.well-known/acme-challenge/ {
		root /srv/certbot-challenges;
	}
}

server {
	# Listen on standard HTTPS port with encryption support
	listen 443 ssl;

	# Use certificate and private key from Certbot
	ssl_certificate /etc/letsencrypt/live/.../fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/.../privkey.pem;

	# Additional options from https://ssl-config.mozilla.org/
	ssl_session_timeout 1d;
	ssl_session_cache shared:MozSSL:10m;
	ssl_session_tickets off;
	ssl_dhparam /path/to/dhparam; # Copy the file into the image
	ssl_protocols ...;
	ssl_ciphers ...;
	ssl_prefer_server_ciphers off;
	add_header Strict-Transport-Security "max-age=63072000" always;
}

Also, we need to open port 443 in the Compose file:

version: '3'
services:
  nginx:
    build: ./nginx
    volumes:
      - certbot-certs:/etc/letsencrypt:ro
      - certbot-challenges:/srv/certbot-challenges:ro
    ports:
      - "80:80"
      - "443:443" # Open port 443 for HTTPS
volumes:
  certbot-certs:
  certbot-challenges:

Step 5: Set Up Automatic Renewal

It’s a good idea to set up automatic renewal so that users aren’t accidentally served expired certificates.

Certbot has a renew command which will check if a certificate is expiring soon (30 days by default) and renew it as needed. All we need to do is to run this command on a regular basis using a scheduler.

While researching this I did find some schedulers that run in Docker, such as Ofelia, but I decided to just use the host machine. I used a systemd timer, but you could also use a cron job.

The timer unit is based on the one included in the Debian package for Certbot:

[Unit]
Description=Renew Certbot certificates

[Timer]
OnCalendar=*-*-* 00:00:00
RandomizedDelaySec=86400
Persistent=true

[Install]
WantedBy=timers.target

The timer runs once a day, with a random delay to avoid load spikes on Let’s Encrypt’s servers (recommended by Certbot).

The service unit is:

[Unit]
Description=Renew Certbot certificates

[Service]
Type=oneshot
ExecStart=/usr/bin/docker run \
	--rm --name myserver_certbot \
	-v myserver_certbot-certs:/etc/letsencrypt \
	-v myserver_certbot-challenges:/srv/certbot-challenges \
	certbot/certbot:v2.7.3 -q renew --no-random-sleep-on-renew
ExecStartPost=/usr/bin/docker exec myserver_nginx_1 nginx -s reload

The main command (ExecStart) runs Certbot to perform the actual renewal. A few notes about this:

  • The --no-random-sleep-on-renew option disables Certbot’s built-in random delay, since we’re handling this with the timer unit. If you’re using cron you should leave this option out.
  • The -q option disables output except for errors. This is useful because otherwise the logs will be filled with a bunch of “Certificate not yet due for renewal” lines.
  • Again, myserver represents the Compose project name, and you’ll have to adjust this to match your setup. Also, like before, I’m using a specific tag for the image but you should check for the latest version.
  • The container name isn’t strictly necessary, but I decided to include it because it helps with logging.

The following command (ExecStartPost) reloads NGINX’s configuration, so that it actually uses the new certificates. The container name depends on your Compose project, and the command assumes that there is only one replica (hence the _1 suffix). (If you’re deploying this at scale, you should consider a different method of triggering this reload.)


And there we have it. HTTPS is set up, and the certificates will be renewed as needed to keep it working.

Reply to this post via e-mail or on: Twitter, LinkedIn.
Philip Chung
Philip Chung
Software Developer