Setting up “Let`s Encrypt” & Nginx on Ubuntu 16.04 with Docker

Lukas Oppermann
9 min readJul 18, 2017

Setting up servers and dealing with certificates often seems scary, but after you did it once it is just another 10 minutes job that you know how to do.

Creating the server

For this article I am creating a digital ocean server, which has a one-click install option for docker 17.05 on Ubuntu 16.04. If you want to try out digital ocean, sign up with this link: https://m.do.co/c/30eae8aadf01 to get a $10 credit.

While I add my ssh key via the GUI, you can easily add your ssh keys manually.

Once the server and docker including docker-compose is set up we can start.

The files we create will need to be placed within a directory on your server, e.g. your users home directory.

Understanding certbot

We will be using a docker container with certbot/cerbot to get our certificates. Certbot is the runs webroot the certification process for let's encrypt. What that means is that the certbot script will create a new folder in the web root directory (the root when accessing the server from the outside/internet) named /.well-known/acme-challenge within this folder a file with a hash as a name will be created. Afterwards a request is made to the let's encrypt service to try an access this file using the specified domain (the domain you want a certificate for). If this is successful a certificate and some additional files will be created.

Setting up the proxy

For the above process to work we need some kind of web server/proxy e.g. nginx (which we will use) or apache. The proxy needs to define the web root and direct incoming requests into the correct directory.

Docker-compose

You will need to destroy and re-create your docker services at some point in the future. You do this a lot during development but also if the server fails and needs to be restarted or if you do blue-green deployment, etc. you will need an easy and fast way to bring your docker services up without a headache. Luckily docker has just such a solution: docker-compose.

With docker-compose you can specify your services, networks and volumes in a YAML file and initialise everything with a simple docker-compose up command.

Our docker-compose.yml file will have 3 main sections:

  1. version specifies the docker-compose version are using
  2. services describes our containers
  3. networks are used for internal communication
# docker-compose version
version: ‘3’
# our containers
services:
# used for communication within docker
networks:

Networks

We simply need one network, which I named appnet. All containers within the same network can talk to each other.

Note: Make sure to use correct indentation and cases, as YAML is indentation sensitive (only spaces are allow, no tabs!) and case sensitive, you can dig into YAML here if you want.

# used for communication within docker
networks:
appnet:
driver: “bridge”

Services

# our containers
services:
# we only have on service we name nginx
nginx:
# it is build from the nginx:alpine image
image: nginx:alpine # using alpine for newer openssl for http2 with ALPAN

container_name: nginx

# these volumes are mounted to the nginx container
volumes:

# mount the folder ../letsencrypt from our server to the folder /etc/letsencrypt on the container
- ../letsencrypt/:/etc/letsencrypt

# mount ./nginx/var/www/html to the /var/www/html on the container
- ./nginx/var/www/html:/var/www/html

# mount ./logs/nginx/ to the /var/log/nginx on the container, rw = read-write
- ./logs/nginx/:/var/log/nginx:rw

# mount ./nginx/includes to the /etc/nginx/includes on the container, ro = read-only
- ./nginx/includes:/etc/nginx/includes:ro

# mount ./nginx/conf.d/default.conf to the /etc/nginx/conf.d/default.conf on the container
- ./nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf

# mount ./sites to the /sites on the container (will hold our node app)
- ../sites:/sites

# ports below are reachable from the outside
ports:
# we need to open port 80 for the verification process before we have an ssl certificate
- "80:80"
# 443 is the ssl port so we need to expose it
- "443:443"

# makes container restart on server restart
restart: always

# used for interal communication within docker
networks:
# list all networks we want to connect to
- appnet

Our entire file now looks like this:

version: ‘3’
services:
nginx:
# using alpine for newer openssl for http2 with ALPAN
image: nginx:alpine
container_name: nginx# these volumes are mounted to the nginx container
volumes:
# mount the folder ../letsencrypt from our server to the folder /etc/letsencrypt on the container
— ../letsencrypt/:/etc/letsencrypt
# mount ./nginx/var/www/html to the /var/www/html on the container
— ./nginx/var/www/html:/var/www/html
# mount ./logs/nginx/ to the /var/log/nginx on the container, rw = read-write
— ./logs/nginx/:/var/log/nginx:rw
# mount ./nginx/includes to the /etc/nginx/includes on the container, ro = read-only
— ./nginx/includes:/etc/nginx/includes:ro
# mount ./nginx/conf.d/default.conf to the /etc/nginx/conf.d/default.conf on the container
— ./nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
# mount ./sites to the /sites on the container (will hold our node app)
— ../sites:/sites
ports:
— “80:80”
— “443:443”
# makes container restart on server restart
restart: always
networks:
— appnet
# used for interal communication within docker
networks:
appnet:
driver: “bridge”

Starting your container

To start your services you need to run docker-compose up -d. The -d flag starts it in demon mode, so you are not stuck listening to the container. If you forget the -d you can exit by pressing ctrl + c this will however kill the services.

When starting the services now you will get an error:

Are you trying to mount a directory onto a file (or vice-versa)? Check if the specified host path exists and is the expected type

This is because docker-compose automatically creates the folders we specify in the volume section of our service, in case they don't exist. However nginx expects /conf.d/default.conf to be a file and not a folder, so we need to create it first.

In the folder where your docker-compose.yml lives create /nginx/conf.d/default.conf:

For requests to this directory the root is /var/www/html, which we mounted into our service, so we will be able to put our websites there from the outside.

default_type text/plain sets the default content-type to text/plain which we need for let's encrypt.

Now that this file exists you can run docker-compose -d and it should run without an error.

Certbot

Now that we have our nginx server ready it is time to create our certificates. Before you can do this you will need to point a domain to your server, I created staging.vea.re. Make sure to substitute it for your domain.

Creating certificates

docker run -it --rm \ 
--volumes-from nginx \
certbot/certbot certonly \
--webroot \
--webroot-path /var/www/html \
--agree-tos \
--staging \
--dry-run \
-m your@email.com \
-d staging.vea.re

I will go through all flags one-by-one, make sure to remove the --dry-run flag once you want to actually create a certificate and the --staging flag if you want a real one.

  • -it makes the container interactive which we want because we may need to interact with questions from verbot
  • --rm removes the container once it exits
  • --volumes-from mounts all volumes that are mounted to a specific container nginx in our case. We want this because certbot must place our certificates here.
  • --webroot tells certbot to run in the webfoot certification mode
  • --webroot-path tells it which path to use as the web root, /var/www/html for us
  • --agree-tos automatically agrees to the terms of services, instead of asking us, this is important for automating it
  • --staging creates a staging certificate. Be careful with non-staging certificates during testing, as there is a limited number of requests you are allowed to make. (Remove the flag to create a production certificate)
  • --dry-run will test everything but not actually create a certificate. Make sure this runs without an error before creating a certificate. (Remove the flag to create a certificate)
  • -m is used to tell certbot which email address to use for registration
  • -d lets you specify which domain you want to create a certificate for

Renewing certificates

At some point you will need to renew your certificates. They are only valid for 90 days after all.

For this we can use certbot as well and run the renew command with it.

docker run -it --rm \ 
--volumes-from nginx \
certbot/certbot renew

Testing your certificate

To make sure your certificate is working you can add the following at the end of your /nginx/conf.d/default.conf.

server { 
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name staging.vea.re;
ssl_certificate /etc/letsencrypt/live/staging.vea.re/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging.vea.re/privkey.pem;
location / {
root /var/www/html;
}
}

You will need to make nginx reload the config file before this change will take effect. The easiest way to do this, is to run the following command.

docker exec -it nginx nginx -t 
docker exec -it nginx nginx -s reload

Now if you place index.html with <h1>Hello world!</h1> into the /var/www/html directory, you should be able to reach it via the browser at https://your-domain.com/index.html.

Additional security measures

You need to make sure that your server, proxy and certificates are secure. The first thing to do is to create strong Diffie-Hellman parameters.

Diffie-Hellman Parameters

The Diffie-Hellman key exchange is a method of exchanging cryptographic keys securely. The Diffie-Hellman parameters are basically used to secure this exchange, so you want strong ones.

Creating them is simple, on your server run the following command:

cd ~/letsencrypt 
sudo openssl dhparam -out dhparam.pem 2048

Now you just need to make sure it is actually used. This can be done by placing one line into your /nginx/conf.d/default.conf file within the server block:

ssl_dhparam /etc/letsencrypt/dhparam.pem;

Since we mapped then ~/letsencrypt to the nginx docker container into /etc/letsencrypt, you should have the file available. Make sure to test ( nginx -t) & reload ( nginx -s reload) your nginx server, like we did before.

Other security settings

# from https://cipherli.st/
# and https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
ssl_ecdh_curve secp384r1;
ssl_session_cache shared:SSL:10m;
ssl_stapling off;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# load the diffie–hellman parameter
ssl_dhparam /etc/letsencrypt/dhparam.pem;

A fair warning, I am by no means an expert on nginx security, so you might want to read up in it somewhere else as well.

Now this file needs to be included within your server block instead of the ssl_dhparam line:

include /etc/nginx/includes/ssl-params.conf;

Automating certificate renewal

Wouldn’t it be ideal if you would not have to worry about your certificates at all? Well, this can be achieved by making certbot renew your certificates automatically, via a simple cron job.

For this I suggest creating a new directory letsencrypt_scripts in which we can create a file named cron_letsencrypt:

#!/bin/sh# the directory to store log files
log_dir=../logs/nginx/letsencrypt/
# run the renew script an store the result in $output
output=$(docker run — rm \
— volumes-from nginx \
certbot/certbot renew \
— text 2>&1)

# Get Date
date=`date +%Y-%m-%d_%H%M%S`
# create log folder if it does not exist
mkdir -p $log_dir
# Write output to log file
cat > ${log_dir}${date}_letsencrypt.txt << EOF
Date: $date
$output
EOF
# send email if mail command is available
# replace server@domain.com & your@email.com
if type “mail” &> /dev/null; then
echo “$output” | mail -s “Daily SSL Revalidation update from ${date}” -A ${log_dir}${date}_letsencrypt.txt -r
server@domain.com your@email.com
fi
# remove log files older than 40 days
find $log_dir -type f -mtime +40 -

The script runs the command, logs the output to a file within /logs/nginx/letsencrypt/ and sends an email to you with the log as long as mailutils is installed.

If you want to install it, you can run the following lines ion ubuntu:

apt-get update 
apt-get install mailutils

Now all that is left to do is to add a line to the crontab. You might want to run this as the root user, otherwise you add something to your currents users crontab which depending on your setup might not work. Make sure to verify it does work.

Now you can add a line at the very bottom, like:

30 18 * * * /home/letsencrypt_scripts/cron_letsencrypt

Cron uses a very specific way of defining time, shown below. You can just google crontab time format to get a better idea of how it works.

* * * * * *
| | | | | |
| | | | | +-- Year (range: 1900-3000)
| | | | +---- Day of the Week (range: 1-7, 1 standing for Monday)
| | | +------ Month of the Year (range: 1-12)
| | +-------- Day of the Month (range: 1-31)
| +---------- Hour (range: 0-23)
+------------ Minute (range: 0-59)

Once you are done save your file and you should be all set. You probably should run the task every day so that you have ample time to deal with any renewal issue if they arrive.

Originally published at https://vea.re on July 18, 2017.

--

--

Lukas Oppermann

Product designer with a love for complex problems & data. Everything I post on Medium is a copy — the originals are on my own website: https://www.vea.re