To enable HTTPS on your website, you need to get a X.509 certificate from a Certificate Authority (CA). This post will guide you to get a free SSL/TLS X.509 certificate from Let's Encrypt and make it work in Nginx webserver running in a Docker container.

About Let's Encrypt

Let's Encrypt is a CA that provides X.509 certificates allowed by most of the browsers at not charge. In order to issue a certificate, Let's Encrypt needs to verify that you are in control of your domain. This is achieved by using a software client that uses the ACME protocol.

The certificate provided by Let's Encrypt is only valid for 3 months and then it needs to be renewed.

For the issuance and installation of the certificate, Let's Encrypt recommends to use Certbot. Certbot is an ACME client written in Python that is used to fetch and deploy SSL/TLS certificates from Let's Encrypt and any other CAs that support the ACME protocol.

Basically Certbot performs an ACME challenge request to validate that you are in control of your domain. To archive this, the agent requests to Let's Encrypt a token and then it places the token file at an endpoint in your domain. If the challenge request is successful, Certbot agent will fetch a new SSL/TLS certificate.

In this example, Cerbot places the token at an endpoint like this:

http://example.org/.well-known/acme-challenge/{token file}

Then Let's Encrypt must be will try to connect to http://example.org/.well-known/acme-challenge/ and retrieve the token file. If the token placed there by Certbot matches, Let's Encrypt will know that you are in control of your domain.

If you want to know better how it works, please read this article: https://letsencrypt.org/how-it-works

To make all this process works, we need a running web server. This post uses Nginx running in a Docker container.

Get the ball rolling

We are going to work with host volumes. A host volume lives on the Docker host's filesystem and can be accessed from within the container. You only need to use it in this way:

$ docker run -v /path/on/host:/path/in/container ...

So let's start creating the volumen in the host filesystem:

$ sudo mkdir /volumes

I've just used this path as an example to make the guide simpler, but in my opinion it isn't a good path. You should choose a better location in your host's filesystem (maybe under /var/lib). Under this path we're going to create all used volumes.

Put the website under /volumes/usr/share/nginx/html/. We're going to keep the same directory structure in the volume as in the container's filesystem, so it's easier to understand the directories paths. But again, feel free to use another path according to your convince.

Now, it's time to create the Nginx server block configuration file for this site: /volumes/etc/nginx/conf.d/example.conf:

server {
    listen 80;
    listen [::]:80;
    server_name www.example.org example.org;

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

We are going to spin up a Nginx container to ensure that the site will run. In general, I prefer to use the Alpine Linux. Here you have good video that explains good reasons to use Alpine Linux.

$ docker run -it --rm --name nginx-dev -v /volumes/etc/nginx/conf.d:/etc/nginx/conf.d -v /volumes/usr/share/nginx/html:/usr/share/nginx/html -p 80:80 nginx:alpine

If all was set properly, you should get the website without certificate (HTTP). Now you can stop the container (Ctr+C) and change the Nginx server block configuration to run Certbot. Now the file /volume/etc/nginx/conf.d/example.conf should look like this:

server {
    listen 80;
    listen [::]:80;
    server_name www.example.org example.org;

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

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

In this modification, Certbot agent grants access to ./well-known/acme-challenge. We run again a new Ngnix container like we did before, but without --rm option. We want to let this container running.

$ docker run -it --name nginx-dev -v /volume/etc/nginx/conf.d:/etc/nginx/conf.d \
-v /volume/usr/share/nginx/html:/usr/share/nginx/html -p 80:80 nginx:alpine

So with this webserver running, it's time to run a Certbot container to get the SSL/TLS certificate. Take in mind that Let's Encrypt has rate limits. Check the rate limits here. So if you exceeded the limit and you are having issues generating your certificate for whatever reason, you could run into trouble. So, it's always good idea to run the Cerbot agent with the --staging argument which will allow you to test if your commands will execute properly before running Cerbots in the Let's Encrypt production environment. The limit is higher on Let's Encrypt's staging environment, so you can use that environment to debug connectivity problems.

$ docker run -it --rm \
-v /volumes/etc/letsencrypt:/etc/letsencrypt \
-v /volumes/var/lib/letsencrypt:/var/lib/letsencrypt \
-v /volumes/var/log/letsencrypt:/var/log/letsencrypt \
-v /volumes/usr/share/nginx/html:/data/letsencrypt \
certbot/certbot \
certonly --webroot \
--register-unsafely-without-email --agree-tos \
--webroot-path=/data/letsencrypt \
--staging \
-d example.org \
-d www.example.org

Note: if you don't create those volumes, docker will create them automatically

If Cerbot ran successfully in staging, wipe the staging files:

$ sudo rm -rf /volumes/etc/letsencrypt/* && sudo rm -rf /volumes/var/lib/letsencrypt/* && sudo rm -rf /volumes/var/log/letsencrypt/*

and can run a Cerbot container in production with this changes:

$ docker run -it --rm \
-v /volumes/etc/letsencrypt:/etc/letsencrypt \
-v /volumes/var/lib/letsencrypt:/var/lib/letsencrypt \
-v /volumes/var/log/letsencrypt:/var/log/letsencrypt \
-v /volumes/usr/share/nginx/html:/data/letsencrypt \
certbot/certbot \
certonly --webroot \
--email your@email.com --agree-tos --no-eff-email \
--webroot-path=/data/letsencrypt \
-d example.org \
-d www.example.org

The email address is where Let's Encrypt will send you expire notifications. If everything ran successfully, you can stop and remove the Nginx container and make some changes in the webserver configuration file to use the new certificate.

We will need generate a DHE parameter and tell Nginx to use it for DHE key-exchange:

$ sudo mkdir -p /volumes/etc/ssl/certs
$ sudo openssl dhparam -out /volumes/etc/ssl/certs/dhparam-2048.pem 2048

Create a new Nginx configuration file:

# Redirect to HTTPS and Certificate renewal
server {
    listen      80;
    listen [::]:80;
    server_name example.org www.example.org;

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

    # Used by Certbot renewal process
    location ~ /.well-known/acme-challenge {
        allow all;
        root /usr/share/nginx/html;
    }
}

# example.org
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.org;

    # Don't send the nginx version number in error pages and Server header
    server_tokens off;

    ssl on;

    ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;

    ssl_buffer_size 8k;

    # Diffie-Hellman parameter for DHE ciphersuites.
    ssl_dhparam /etc/ssl/certs/dhparam-2048.pem;

    # Disable SSLv3 (enabled by default since nginx 0.8.19). It's less secure then TLS
    ssl_protocols TLSv1.2 TLSv1.1 TLSv1;

    # Specifies that server ciphers should be preferred over client ciphers when using 
    # the SSLv3 and TLS protocols. It's used to protect from BEAST attack
    ssl_prefer_server_ciphers on;

    # Specifies the enabled ciphers
    ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;

    ssl_ecdh_curve secp384r1;

    ssl_session_tickets off;

    # enable ocsp stapling (mechanism by which a site can convey certificate revocation
    # information to visitors in a privacy-preserving, scalable manner)
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4;
    
    # All traffic for https://example.org/* will be redirected to https://www.example.org/*.
    return 301 https://www.example.org$request_uri;
}

# www.example.org
server {
    server_name www.example.org;
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    # Don't send the nginx version number in error pages and Server header
    server_tokens off;

    ssl on;

    ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;

    ssl_buffer_size 8k;

    # Diffie-Hellman parameter for DHE ciphersuites.
    ssl_dhparam /etc/ssl/certs/dhparam-2048.pem;

    # Disable SSLv3 (enabled by default since nginx 0.8.19). It's less secure then TLS
    ssl_protocols TLSv1.2 TLSv1.1 TLSv1;

    # Specifies that server ciphers should be preferred over client ciphers when using 
    # the SSLv3 and TLS protocols. It's used to protect from BEAST attack
    ssl_prefer_server_ciphers on;

    # Specifies the enabled ciphers
    ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;

    ssl_ecdh_curve secp384r1;

    ssl_session_tickets off;

    # Enable ocsp stapling (mechanism by which a site can convey certificate revocation
    # information to visitors in a privacy-preserving, scalable manner)
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4;

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

And finally we can run the Nginx container in production with the new TLS/SSL certificate:

$ docker run -it --name nginx-prod \
-v /volumes/etc/nginx/conf.d:/etc/nginx/conf.d \
-v /volumes/usr/share/nginx/html:/usr/share/nginx/html \
-v /volumes/etc/letsencrypt/live/example.org/fullchain.pem:/etc/letsencrypt/live/example.org/fullchain.pem \
-v /volumes/etc/letsencrypt/live/example.org/privkey.pem:/etc/letsencrypt/live/example.org/privkey.pem \
-v /volumes/etc/ssl/certs/dhparam-2048.pem:/etc/ssl/certs/dhparam-2048.pem \
-p 80:80 -p 443:443 nginx:stable-alpine

Go to your browser and test it. You should see your site with HTTPS.

Certificate renewal

Let's Encrypt certificates are only valid for 90 days and then they need to be renewed. For this task some administrators use the Crontab, but I prefer to use a systemd timer. If you don't know how to use systemd timers, please read my previous post.

Let's create the timer unit /etc/systemd/system/certbot.timer:

[Unit]
Description=Daily renewal of Let's Encrypt's certificates

[Timer]
OnCalendar=*-*-* 00:05:00
Persistent=true

[Install]
WantedBy=timers.target

Create the systemd service unit /etc/systemd/system/certbot.service:

[Unit]
Description=Let's Encrypt renewal

[Service]
Type=oneshot
ExecStart=/usr/bin/docker run --rm --name certbot -v /volumes/etc/letsencrypt:/etc/letsencrypt -v /volumes/var/lib/letsencrypt:/var/lib/letsencrypt -v /volumes/var/log/letsencrypt:/var/log/letsencrypt -v /volumes/usr/share/nginx/html:/data/letsencrypt certbot/certbot renew --webroot -w /data/letsencrypt --quiet
ExecStart=/usr/bin/docker restart nginx-prod

Enable and start the new timer:

$ sudo systemctl daemon-reload
$ sudo systemctl enable certbot.timer
$ sudo systemctl start certbot.timer

The timer will run every night at 00:05. If the certificates are due for renewal, the certificates will renew and Nginx will restart to take the new certificate.


Source: https://www.humankode.com/ssl/how-to-set-up-free-ssl-certificates-from-lets-encrypt-using-docker-and-nginx