How to Set Up Free SSL Certificates from Let's Encrypt using Docker and Nginx

The Complete Guide to Automating Certbot using Docker, Nginx and Ubuntu on a Virtual Machine in the Cloud

Published by Carlo Van January 7, 2018

Updates:

19 June 2018: I updated the code and instructions to explain how the certbot renewal process. Specifically, I explain how to use certbot via a cron job to renew Let's Encrypt certificates and to automatically reload the Nginx configuration and certificates.

---

I've been using Free SSL/TLS certificates from Let's Encrypt for about 18 months. Recently, I started combining Docker with Let's Encrypt. The tooling that Let's Encrypt's Certbot provides is extensive, and the whole experience of using Docker with Let's Encrypt is fantastic.

By combining Let's Encrypt with Docker, you get a fully automated environment. SSL/TLS certificates are automatically renewed and software running in Docker containers such as Nginx or the Let's Encrypt Certbot agent are always kept up to date. You never have to worry about updating software again or renewing SSL/TLS certificates. It's quite clear why Docker and Let's Encrypt have become so ubiquitous.

There's a convergence of different technologies that work together in order to dockerize Let's Encrypt with Ubuntu and Nginx. The reality is that it's quite simple to get up and running.

If you've been looking for an easy to read and follow guide that will allow you to dockerize Let's Encrypt on Ubuntu, you've come to the right place. Even if you're not interested in running Let's Encrypt in Docker, this post will still show you how to obtain free SSL/TLS certificates from Let's Encrypt as the certificate acquisition steps are the same.

Youtube Tutorial

I created a Youtube tutorial that shows how to use Docker and Let's Encrypt to issue free SSL certificates.

 

 Before we continue, let's look at what I'll be covering in this post:

  • How to install Docker on Ubuntu
  • How to set up and run Nginx in a Docker container
  • How to set up and run Certbot in a Docker container, and how to get Certbot to issue an SSL/TLS certificate for a new domain
  • How to configure Nginx to use your SSL certificate
  • How to harden security in Nginx to get an A+ score on SSL Labs
  • How to get an A rating on securityheaders.io
  • How to run a cronjob so that Certbot will automatically renew your SSL/TLS certificate

I registered a new domain name, ohhaithere.com for the purposes of this post and will be referring to that domain in all examples.

Running an Ubuntu Server 16.04 VM on AWS or Azure

I'm not going to cover how to set up a virtual machine on AWS or Azure, as that is a separate topic.

To get a VM up and running as a Web Server, at least for the purposes of this post, you need to ensure the following:

  • You have a registered domain
  • You have set up a static public IP address for your server
  • You have pointed your Domain's A and CNAME records to the server's public IP address
  • You have opened HTTP, HTTPS and SSH ports
  • You're running Ubuntu Server
  • Know how to SSH into your server

Install Docker on Ubuntu

SSH into your server and run the following commands to install Docker on Ubuntu.

Add the GPG key and add the Docker repository from APT sources

shell
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
shell
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

 Update the Ubuntu package database

shell
sudo apt-get update

 And finally, install Docker

shell
sudo apt-get install -y docker-ce

Install Docker Compose on Ubuntu

Docker Compose is used to orchestrate and run multiple containers together.

Get the latest version of Docker Compose and install it

shell
sudo curl -L https://github.com/docker/compose/releases/download/1.18.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose

Set execute permissions for Docker Compose

shell
sudo chmod +x /usr/local/bin/docker-compose

As far as the server installation is concerned, that's really all there is to it. Spinning up a new Ubuntu instance and installing docker can literally be done in less than 5 minutes.

A Quick Overview of the Let's Encrypt Certificate Issuance Process

Before I proceed, it's important to step back and understand how the process works on a high level.

Let's Encrypt is an open, not for profit and free Certificate Authority (CA). This means that they issue free SSL/TLS certificates. In addition to this, they provide software that can issue the certificates and renew certificates automatically. The software that manages this process is called Certbot, and usually, you would install this on your server.

The process for obtaining a free Let's Encrypt certificate is a 3-part process:

  1. Install Certbot on your server
  2. Run Certbot with a command to obtain your SSL/TLS certificate and save it on your server. The Certificate is valid for 3 months and thus needs to be renewed every 3 months.
  3. Set up a cron job (scheduler) to run Certbot with a Certbot renew command on a weekly basis. When the cron job runs and there are less than 30 days remaining until your certificate expires, the certificate will renew.

How Certbot Works

Every time you run Certbot, whether it is the first time an SSL/TLS certificate is issued or a certificate renewal, Certbot will perform an ACME challenge request to validate that you are in control of your domain. If the challenge request is successful, the Certbot agent will install a new SSL/TLS certificate on your server. 

This certificate consists of a private key and a public key and both these keys are saved in a Let's Encrypt folder on your server. Your web server, which is Nginx in this instance, loads both the private and public keys in order to configure SSL/TLS for your site.

The public key is not secure and is sent to every browser that makes a request to your site, while the private key is secure and is not shared with clients. I will cover the certificate issuance process in more detail in a subsequent post as it is an in-depth topic that requires a separate post.

The key point with regards to the SSL/TLS certificate is that it consists of 2 keys. Both keys are stored in a Let's Encrypt folder. Your Nginx configuration file needs to reference both keys in order to configure your site to use HTTPS.

A Better Solution: Run Let's Encypt's Certbot in a Docker Container

Run Certbot in Docker

The Certbot software gets updated with new releases often. If you install Certbot on your server, this would involve uninstalling and re-installing Certbot every time you need to update the Certbot agent, which makes it a perfect candidate to run in a Docker container. If Certbot is updated, a new image will automatically be pulled from the Docker registry the next time the agent runs in a docker container.

By dockerizing Certbot, the process for obtaining Let's Encrypt certificates will now only consist of 2 parts:

  1. To obtain the first Let's Encrypt SSL/TLS certificate, simply execute a Docker run script. This script will look very similar to the script that you would run natively on a server through Certbot but instead is passed through Docker. Docker will launch an instance of Certbot in a container and run the script, and if the script is finished, the container will close.
  2. Set up a cron job that will execute another Docker run script on a periodic basis. The script will look almost identical to the one in the first step, give or take a parameter or two.

By running Certbot in a Docker container, we no longer need to be concerned with maintaining the Certbot agent software. If a new version is released, a new image will download and run the next time the Docker container instance launches. Simple and automated.

How to Dockerize Certbot

How to Dockerize Certbot

Before we can execute the Certbot command that installs a new certificate, we need to run a very basic instance of Nginx so that the domain ohhaithere.com is accessible over HTTP.

In order for Let's Encrypt to issue you a certificate, an ACME Challenge Request is performed:

  1. You issue a command to the Certbot agent
  2. Certbot informs Let's Encrypt that you want an SSL/TLS certificate
  3. Let's Encrypt sends the Certbot agent a unique token
  4. The Certbot agent places the token at an endpoint on your domain that looks like http://ohhaithere.com/.well-known/acme-challenge/{token}
  5. If the token at the endpoint matches the token that was sent to the Certbot agent from the Let's Encrypt CA, the challenge request was successful and Let's Encrypt knows that you are in control of the domain.

This basic instance of Nginx will only ever be run for the first time that you request a certificate from Let's Encrypt. It's a basic instance because it doesn't even need to have a default page. It just needs to give write permissions to the Certbot agent so that it can place a token at an endpoint for the challenge request and that's all.

We can't configure a single instance of Nginx because the first instance of Nginx will only be configured for HTTP since we do not have an SSL/TLS certificate yet. Once we have the SSL/TLS certificate, we can configure SSL/TLS on the full production version of the site. If we then need to renew a certificate between 60 and 90 days after the first certificate was issued, the subsequent challenge requests will be performed on the production version of our site running on Nginx, and so we won't ever have to run the basic instance of Nginx again. 

To recap, the very first request for a Let's Encrypt certificate will involve the following:

  1. Configure a basic version of Nginx that only runs on HTTP and gives the Certbot agent write access for the following endpoint: http://ohhaithere.com/.well-known/acme-challenge/{token}
  2. Spin up the basic container of Nginx via Docker Compose
  3. Execute a Docker run command that will spin up the Certbot agent. The Certbot agent will perform the challenge request, and if successful, place your SSL certificates in a Let's Encrypt folder on your server.
  4. Once the Certbot agent process is complete, the container will automatically stop
  5. Issue a Docker Compose down command which will stop and close down your basic version of Nginx container

For all Let's Encrypt renewal requests, the process will involve the following:

  • Ensure that your production version of Nginx is configured and up and running. This container will be launched via Docker Compose as soon as your site is ready to be deployed and will stay up and running.
  • Configure a cron job that will execute a Docker run command that performs a Certbot renew on a weekly or fortnightly basis.

Set up Docker, Nginx and Certbot To Obtain Your First Let's Encrypt SSL/TLS Certificate

Let's Encrypt on Docker with Nginx

On your server, create a new Directory:

shell
sudo mkdir -p /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site

 Then, create a new docker-compose.yml file

shell
sudo nano /docker/letsencrypt-docker-nginx/src/letsencrypt/docker-compose.yml
docker-compose.yml
version: '3.1'

services:

  letsencrypt-nginx-container:
    container_name: 'letsencrypt-nginx-container'
    image: nginx:latest
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./letsencrypt-site:/usr/share/nginx/html
    networks:
      - docker-network

networks:
  docker-network:
    driver: bridge

The docker-compose.yml file does the following:

  • Pulls the latest version of Nginx from the Docker registry
  • Exposes port 80 on the container to port 80 on the host, which means that requests to your domain on port 80 will be forwarded to nginx running in the Docker container
  • Maps the nginx configuration file that we will create in the next step to the configuration location in the Nginx container. When the container starts, it will load our custom configuration
  • Maps the /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site location to the default location of Nginx in the container. In this instance, it's not really necessary, as the site only needs to be used for the purposes of a challenge request, but it's always good to place a default HTML file for troubleshooting purposes.
  • Creates a default Docker network

 Then, create a configuration file for nginx

shell
sudo nano /docker/letsencrypt-docker-nginx/src/letsencrypt/nginx.conf
nginx.conf
server {
    listen 80;
    listen [::]:80;
    server_name ohhaithere.com www.ohhaithere.com;

    location ~ /.well-known/acme-challenge {
        allow all;
        root /usr/share/nginx/html;
    }

    root /usr/share/nginx/html;
    index index.html;
}

The nginx configuration file does the following:

  • Listens for requests on port 80 for URLs ohhaithere.com and www.ohhaithere.com
  • Gives the Certbot agent access to ./well-known/acme-challenge
  • Sets the default root and file

Next, create an index.html file

shell
sudo nano /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site/index.html
index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Let's Encrypt First Time Cert Issue Site</title>
</head>
<body>
    <h1>Oh, hai there!</h1>
    <p>
        This is the temporary site that will only be used for the very first time SSL certificates are issued by Let's Encrypt's
        certbot.
    </p>
</body>
</html>

Before running the Certbot command, spin up a Nginx container in Docker to ensure the temporary Nginx site is up and running

shell
cd /docker/letsencrypt-docker-nginx/src/letsencrypt
sudo docker-compose up -d

Then, open up a browser and visit the domain to ensure that the Docker container is up and running and accessible. As stated earlier, it's not necessary to have a default index.html page for this container, but it makes testing the container a lot easier, so I always create one.

The Site Running in the Nginx Docker Container for Generating the First Let's Encrypt Certificate

We're almost ready to execute the Certbot command. But before we do, you need to be aware that Let's Encrypt has rate limits. Most notably, there's a limit of 20 issued certificates per 7 days. So if you exceeded 20 requests and are having a problem with generating your certificate for whatever reason, you could run into trouble. Therefore, it's always wise to run your commands with a --staging parameter which will allow you to test if your commands will execute properly before running the actual commands.

Run the staging command for issuing a new certificate:

shell
sudo docker run -it --rm \
-v /docker-volumes/etc/letsencrypt:/etc/letsencrypt \
-v /docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt \
-v /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site:/data/letsencrypt \
-v "/docker-volumes/var/log/letsencrypt:/var/log/letsencrypt" \
certbot/certbot \
certonly --webroot \
--register-unsafely-without-email --agree-tos \
--webroot-path=/data/letsencrypt \
--staging \
-d ohhaithere.com -d www.ohhaithere.com

After executing the above command, you should get the following output which should indicate everything ran successfully.

Issue a new Let's Encrypt Certificate with Certbot and Docker in Staging Mode

The command does the following:

  • Run docker in interactive mode so that the output is visible in terminal
  • If the process is finished close, stop and remove the container
  • Map 4 volumes from the server to the Certbot Docker Container:
    • The Let's Encrypt Folder where the certificates will be saved
    • Lib folder
    • Map our html and other pages in our site folder to the data folder that let's encrypt will use for challenges.
    • Map a logging path for possible troubleshooting if needed
  • For staging, we're not specifying an email address
  • We agree to terms of service
  • Specify the webroot path
  • Run as staging
  • Issue the certificate to be valid for the A record and the CNAME record

You can also get some additional information about certificates for your domain by running the Certbot certificates command:

shell
sudo docker run --rm -it --name certbot \
-v /docker-volumes/etc/letsencrypt:/etc/letsencrypt \
-v /docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt \
-v /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site:/data/letsencrypt \
certbot/certbot \
--staging \
certificates
Get Additional Information with the Certbot Certificates Command

If the staging command executed successfully, execute the command to return a live certificate

First, clean up staging artifacts:

shell
sudo rm -rf /docker-volumes/

And then request a production certificate: (note that it's a good idea to supply your email address so that Let's Encrypt can send expiry notifications)

shell
sudo docker run -it --rm \
-v /docker-volumes/etc/letsencrypt:/etc/letsencrypt \
-v /docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt \
-v /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site:/data/letsencrypt \
-v "/docker-volumes/var/log/letsencrypt:/var/log/letsencrypt" \
certbot/certbot \
certonly --webroot \
--email youremail@domain.com --agree-tos --no-eff-email \
--webroot-path=/data/letsencrypt \
-d ohhaithere.com -d www.ohhaithere.com

If everything ran successfully, run a docker-compose down command to stop the temporary Nginx site

shell
cd /docker/letsencrypt-docker-nginx/src/letsencrypt
shell
sudo docker-compose down

Set up Your Production Site to Run in a Nginx Docker Container

Setting Up Docker and Nginx

Create the directories for our production site

shell
sudo mkdir -p /docker/letsencrypt-docker-nginx/src/production/production-site
sudo mkdir -p /docker/letsencrypt-docker-nginx/src/production/dh-param

Let's start with the docker-compose.yml file

shell
sudo nano /docker/letsencrypt-docker-nginx/src/production/docker-compose.yml
docker-compose.yml
version: '3.1'

services:

  production-nginx-container:
    container_name: 'production-nginx-container'
    image: nginx:latest
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./production.conf:/etc/nginx/conf.d/default.conf
      - ./production-site:/usr/share/nginx/html
      - ./dh-param/dhparam-2048.pem:/etc/ssl/certs/dhparam-2048.pem
      - /docker-volumes/etc/letsencrypt/live/ohhaithere.com/fullchain.pem:/etc/letsencrypt/live/ohhaithere.com/fullchain.pem
      - /docker-volumes/etc/letsencrypt/live/ohhaithere.com/privkey.pem:/etc/letsencrypt/live/ohhaithere.com/privkey.pem
    networks:
      - docker-network

networks:
  docker-network:
    driver: bridge

The docker-compose does the following:

  • Allows ports 80 and 443
  • Maps the production Nginx configuration file into the container
  • Maps the production site content into the container
  • Maps a 2048 bit Diffie–Hellman key exchange file into the container
  • Maps the public and private keys into the container
  • Sets up a docker network

Next, create the Nginx configuration file for the production site

shell
sudo nano /docker/letsencrypt-docker-nginx/src/production/production.conf
production.conf
server {
    listen      80;
    listen [::]:80;
    server_name ohhaithere.com www.ohhaithere.com;

    location / {
        rewrite ^ https://$host$request_uri? permanent;
    }

    #for certbot challenges (renewal process)
    location ~ /.well-known/acme-challenge {
        allow all;
        root /data/letsencrypt;
    }
}

#https://ohhaithere.com
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name ohhaithere.com;

    server_tokens off;

    ssl_certificate /etc/letsencrypt/live/ohhaithere.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ohhaithere.com/privkey.pem;

    ssl_buffer_size 8k;

    ssl_dhparam /etc/ssl/certs/dhparam-2048.pem;

    ssl_protocols TLSv1.2 TLSv1.1 TLSv1;
    ssl_prefer_server_ciphers on;

    ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;

    ssl_ecdh_curve secp384r1;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8;

    return 301 https://www.ohhaithere.com$request_uri;
}

#https://www.ohhaithere.com
server {
    server_name www.ohhaithere.com;
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_tokens off;

    ssl on;

    ssl_buffer_size 8k;
    ssl_dhparam /etc/ssl/certs/dhparam-2048.pem;

    ssl_protocols TLSv1.2 TLSv1.1 TLSv1;
    ssl_prefer_server_ciphers on;
    ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;

    ssl_ecdh_curve secp384r1;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4;

    ssl_certificate /etc/letsencrypt/live/ohhaithere.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ohhaithere.com/privkey.pem;

    root /usr/share/nginx/html;
    index index.html;
}

Generate a 2048 bit DH Param file

shell
sudo openssl dhparam -out /docker/letsencrypt-docker-nginx/src/production/dh-param/dhparam-2048.pem 2048

Copy your site content into the mapped directory:

shell
/docker/letsencrypt-docker-nginx/src/production/production-site/

Spin up the production site in a Docker container:

shell
cd /docker/letsencrypt-docker-nginx/src/production
sudo docker-compose up -d

If you open up a browser and point to http://www.ohhaithere.com, you should see that the site loads correctly and will automatically redirect to https://www.ohhaithere.com

The Production Website Running in an Nginx Docker Container with a Let's Encrypt SSL Certificate

How to Renew Let's Encrypt SSL Certificates with Certbot and Docker

Earlier, we placed the following section in the production Nginx configuration file:

location ~ /.well-known/acme-challenge {
    allow all;
    root /usr/share/nginx/html;
}

The production site's docker-compose.yml file then maps a volume into the Nginx container that can be used for challenge requests:

docker-compose.yml
production-nginx-container:
    container_name: 'production-nginx-container'
    image: nginx:latest
    ports:
      - "80:80"
      - "443:443"
    volumes:
      #other mapped volumes...
      #for certbot challenges
      - /docker-volumes/data/letsencrypt:/data/letsencrypt
    networks:
      - docker-network

This effectively allows Certbot to perform a challenge request. It's important to note that certbot challenge requests will be performed using port 80 over HTTP, so ensure that you enable port 80 for your production site.

All that's left to do is to set up a cron job that will execute a certbot command to renew Let's Encrypt SSL certificates.

Set Up a Cron Job to Automatically Renew Let's Encrypt SSL/TLS Certificates

It's a good idea to run a daily cron job that attempts to renew Let's Encrypt SSL certificates. It doesn't matter how many times this command is executed as nothing will happen unless your certificate is due for renewal.

To add a crontab, run the following commands:

shell
sudo crontab -e

Place the following at the end of the file, then close and save it.

shell
0 23 * * * docker run --rm -it --name certbot -v "/docker-volumes/etc/letsencrypt:/etc/letsencrypt" -v "/docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt" -v "/docker-volumes/data/letsencrypt:/data/letsencrypt" -v "/docker-volumes/var/log/letsencrypt:/var/log/letsencrypt" certbot/certbot renew --webroot -w /data/letsencrypt --quiet && docker kill --signal=HUP production-nginx-container

The above command will run every night at 23:00. If the certificates are due for renewal, the certificates will renew. Additionally, the Nginx configuration and renewed certificates will reload by executing the signal command at the end of the cron command.

Get an A+ Score on SSL Labs

The configuration entered into the Nginx configuration file earlier should ensure an A+ score on ssllabs.com. Go to ssllabs.com to confirm.

Get an A+ Rating on SSLLabs

Hardening Your Site's Security and How to Get an A rating on securityheaders.io

The last thing left to do is to harden the site's security and get an A rating on securityheaders.io

production.conf
#https://www.ohhaithere.com
server {
    # ....

    location / {
        #security headers
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
        add_header X-XSS-Protection "1; mode=block" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "DENY" always;
        #CSP
        add_header Content-Security-Policy "frame-src 'self'; default-src 'self'; script-src 'self' 'unsafe-inline' https://maxcdn.bootstrapcdn.com https://ajax.googleapis.com; img-src 'self'; style-src 'self' https://maxcdn.bootstrapcdn.com; font-src 'self' data: https://maxcdn.bootstrapcdn.com; form-action 'self'; upgrade-insecure-requests;" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    }

    # ....
}

 

Get an A Score on securityheaders.io

Closing Thoughts

There's certainly no doubt that HTTPS adoption has reached a tipping point. Let's Encrypt has had a huge hand to play in this by making free SSL/TLS certificates available to everyone, while Google has been placing pressure on site owners to adopt HTTPS. The newest versions of Google Chrome show sites as insecure if they do not communicate over HTTPS.

There is, however, still a barrier to entry when it comes to implementing free SSL/TLS certificates by Let's Encrypt. While some hosting control panels and PaaS solutions in the cloud offer UI solutions that make the process of obtaining Let's Encrypt certificates effortless, it's certainly not an easy or straightforward thing to do on a Virtual Machine. I spent quite some time reading resources on the internet, but have never been able to find a complete guide that not only explains how Let's Encrypt works, but how to automate it with Docker and set it up in a secure way. I found lots of scattered resources and ran into more problems than I can recall. In the end, I was never able to find a proper guide and had to resort to figuring it out on my own through trial, error and persistence.

This is the guide that I wish was available when I started this journey. It's my contribution towards lowering the barrier to entry for HTTPS adoption. If there's any suggestions or improvements to be made, which I'm sure there would be, let me know in the comments and I will update accordingly.

Resources