Setting up HTTPS with Let's Encrypt

Chrome 68, due in July 2018, will mark all non-HTTPS sites as insecure. Instead of buying a certificate it seemed like an apt opportunity to use Let's Encrypt, a free and automated Certificate Authority. It's easy enough to get started with Certbot supporting most platforms.

Getting started

By far the easiest way of getting started is if you have access to the web content directory. Certbot will generate a file in a well known path and handle the handshaking process between Let's Encrypt and the website to verify the websites identity. Google Analytics has a similar approach to prove site ownership and the process is well documented as the ACME protocol.

I'm using an Alpine Linux image for my blog container and a package for Certbot is readily available in the standard repositories. This installs other dependencies (notably Python) which bloats the image a bit but it's a small price to pay in this instance. Here's the amended Dockerfile:

FROM nginx:alpine

# Copy content
COPY ./_site /usr/share/nginx/html

# Copy config
COPY nginx.conf /etc/nginx/nginx.conf
COPY default.conf /etc/nginx/conf.d/default.conf

VOLUME /etc/letsencrypt
RUN apk add --no-cache bash certbot

EXPOSE 80
EXPOSE 443

First we create a volume for /etc/letsencrypt where Certbot will save the certificates. This will allow other containers to use the same certificate by mounting the same volume. On the server I've manually created a volume with the following command:

docker volume create --name certificates

We add the certbot package using apk for later use and of course expose port 443 for SSL. When we run the container we need to mount the newly created volume at the location specified in the Dockerfile:

docker run -d --name blog -p 80:80 -p 443:443 \
  -v certificates:/etc/letsencrypt blog:latest

With this container built and running, we need to get the initial certificates manually. Inside the container we ask Certbot to generate a certificate for us. I've included both yourdomain.com and www.yourdomain.com as I have a redirect from www to non-www. The --webroot directive makes Certbot use the file system for certificate generation and validation and not integrate directly with the web server through a plugin - I prefer to edit the website configuration by hand.

The --agree-tos and --email flags are necessary to avoid interactive prompts for this information on first registration.

certbot --webroot -d yourdomain.com -d www.yourdomain.com \
  --webroot-path /usr/share/nginx/html \
  --email youemail@domain.com --agree-tos

You should see the output shown below. Take note of the renewal instructions - we'll get back to that.

Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/yourdomain.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/yourdomain.com/privkey.pem
   Your cert will expire on xxxx-xx-xx. 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"
 - Your account credentials have been saved in your Certbot
   configuration directory at /etc/letsencrypt. You should make a
   secure backup of this folder now. This configuration directory will
   also contain certificates and private keys obtained by Certbot so
   making regular backups of this folder is ideal.
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

You can find the generated certificates inside the /etc/letsencrypt/live folder.

NGINX setup

Now that we have the certificates we can set up the web server (NGINX in my case) to use them. First, let's redirect all traffic from port 80 to port 443:

server {
    listen       80;
    server_name  www.yourdomain.com;
    return 301 https://yourdomain.com$request_uri;
}

server {
    listen       80;
    server_name  yourdomain.com;
    return 301 https://yourdomain.com$request_uri;
}

This will return a 301 moved status code and keep the original sub-URI for redirection. Now to set up HTTPS :

server {
    listen 443 ssl default deferred;
    server_name  yourdomain.com www.yourdomain.com;

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

    # Session resumption
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 5m;

    # Specify types of TLS, specifically avoiding SSL3
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

    # Disable insecure ciphers
    ssl_prefer_server_ciphers on;
    ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS";


    # HTTP Strict Transport Security
    add_header Strict-Transport-Security "max-age=63072000; includeSubdomains";

    # ....
}

It's important to disable SSLv3 as the protocol is vulnerable to attacks so we specify TLSv1, TLSv1.1, and TLSv1.2 explicitly as the allowed versions.

Furthermore we add HSTS as another layer of defence to avoid access of the website over HTTP by default:

The HTTP Strict Transport Security header informs the browser that it should never load a site using HTTP and should automatically convert all attempts to access the site using HTTP to HTTPS requests instead.

In other words, once the browser spots this header and once it confirms that the site has HTTPS enabled it will always use HTTPS for future requests. We can set the max-age parameter as high as it can go as I'm not planning on rolling this back to HTTP any time soon.

We also need to disable insecure ciphers to avoid downgrading of the connection to a less secure cipher. SSLlabs have a great article that they frequently update with best practices in this regard.

We enable session resumption for performance reasons. The server will store a session id of the client so that future connections with the same session id (in a short time frame) the client can resume the session instead of going through the whole negotiation process again.

Pro tip: The SSLlabs TLS testing tool is by far the best tool to test your SSL implementation and will highlight any potential issues. While using it read the notes about compatibility - it's essential to get the balance right between security and compatibility.

Renewing certificates

Let's Encrypt certificates are valid for three months so we need to run the renew process frequently to avoid sitting with an expired certificate. Certbot has this built in with the certbot renew command, mentioned in the initial output after first creation of the certificates. There are a couple of different ways to do this with containers:

  1. Running a sidekick container to renew the certificates
  2. Installing the dependencies on the host and running the command directly on the volume
  3. Running crontab on the host machine and exec'ing into the container

(2) is way too dirty and spoils all the isolation benefits we have with containerisation and (1) is the way I would do it with serious production applications abiding to "the Docker way". In the spirit of doing the simplest thing possible I opted for (3). On the host machine you can edit the crontab file via the command

sudo crontab -e

We can create a simple entry to run the renew command in the container on a schedule:

43 6 * * * docker exec blog certbot renew --post-hook "nginx -s reload"

This will run the renew process and if the script renewed the certificate execute the post hook, which restarts the web server and let the change take effect.

Note

Let's Encrypt applies rate limiting on requests to their services. While you are testing you can use their staging area to avoid hitting the request ceiling.

Photo by James Sutton on Unsplash