Image credits: https://www.eff.org/pages/certbot

Folks that know me also know that I'm a big fan of the work of the Electronic Foundier Foundation.

Today I'll give you one more reason to love (or discover) them even if you never cared about their battles on digital privacy, free speech, etc..

If, like me, you have a website and you have been regenerating your SSL certificates by hand every year like a peasant (while also paying an SSL provider for them!) you might enjoy this small quality of life improvement given by certbot.

For me, it's literally been LIFE-CHANGING: it doesn't happen everyday that you discover how to save money and time on what is fundamentally a chore!

Certbot

The core tool is the certbot CLI written by the EFF.

There is a great guide on how to use it at https://eff-certbot.readthedocs.io/en/stable. It's very exhaustive (thus pretty long), so I must thank https://ecostack.dev/posts/nginx-lets-encrypt-certificate-https-docker-compose for covering how to neatly integrate things with a Docker setup. Here I'll most refer to the "Webroot" flow, also covered here: https://eff-certbot.readthedocs.io/en/stable/using.html#webroot.

So what's the value of writing my own article on this? As usual, I mostly take great work done by other folks and tailor it to my needs. Also, just in case other websites go down, I like to have my own self-hosted source of knowledge for things I find useful! :)

Nginx

Let's assume that you're using NGINX in a classic Docker Compose setup, like this:

services:
  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile.prod
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./nginx/var/www:/var/www
      - ./nginx/ssl:/etc/nginx/certs

You can create a new dedicated service that uses certbots official Docker image:

  certbot:
    image: certbot/certbot:latest
    volumes:
      - ./certbot/var/:/var/certbot/:rw
      - ./certbot/etc/:/etc/letsencrypt/:rw
  • /etc/letsencrypt is where the final certs will live (as generated by certbot)
  • /var/certbot contains configuration files used internally by certbot (like the auth challenge)

So we'll add those 2 extra volumes to our Nginx service as well:

      - ./certbot/var/:/var/certbot/:ro
      - ./certbot/etc/:/etc/letsencrypt/:ro

The final Compose file might look like this:

services:
  nginx:
    user: root
    build:
      context: ./nginx
      dockerfile: Dockerfile.prod
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./nginx/var/www:/var/www
      - ./certbot/var/:/var/certbot/:ro
      - ./certbot/etc/:/etc/letsencrypt/:ro

  certbot:
    image: certbot/certbot:latest
    volumes:
      - ./certbot/var/:/var/certbot/:rw
      - ./certbot/etc/:/etc/letsencrypt/:rw

We'll also want to tell Nginx to serve a certain "well known" directory that will be used by certbot for the auth challenge.

For example, this is what I've added to my nginx.conf:

    # Certbot
    location /.well-known/acme-challenge {
        root /var/certbot;
    }

Note: If you keep your website under source control, you'll probably want to run mkdir -p certbot/{etc,www}, touch certbot/{etc,www}/.gitkeep and commit those 2 .gitkeep files so that when you pull the changes on your target host those directories already exist!

Certificate generation

Let's get to the meat of it: generating the certs (and solving the related auth challenge) using just certbot.
For that, you can write a run-certbot.sh bash script with this content:

#!/usr/bin/env bash

dryrun=''
if [ "$1" == '--dry-run' ]; then
    dryrun="$1"
fi

docker compose -f prod.yml \
    run --rm \
    certbot certonly \
    --webroot \
    --webroot-path /var/certbot/ \
    $dryrun -d [your-domain-here]

Note that this script assumes that your compose file is called prod.yml, so re-adapt it as needed.

At this point you'll want to ssh to the host where you have your website running (assuming it's a VPS of sorts), pull these new changes we made to the compose and Nginx files and restart the service.

Note that we'll only need to boot up our Nginx service so that it serves the newly added location, which we'll use to pass the auth challenge.
So, for example, you'd run: docker compose -f prod.yml up --build --detach nginx.

Note: we're not running just docker compose -f prod.yml up --build --detach since we don't need to start the certbot service!

Now we can finally run ./run-certbot.sh, which will generate the final certificates inside the /etc/letsencrypt directory in the container. You can also run it in dry run mode, so without generating any files: ./run-certbot.sh --dry-run.

You'll be prompted to answer a few questions by Certbot: answer as needed.
Mostly, it's important that you'll provide the correct domain name of your website.

If everything ran correctly, at this point you'll have generated all the certs that Nginx needs! For example, in my case:

$ ls -1 certbot/etc/live/[my-domain-name]
cert.pem
chain.pem
fullchain.pem
privkey.pem
README

Et voilá, may certbot be blessed!

Now you just need to update your Nginx conf to point to these new certificates! For example:

    ssl_certificate           /etc/letsencrypt/live/[your-domain-here]/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/[your-domain-here]/privkey.pem;

Renewal

The beauty of certbot is that it completely removes the need to manually reissue certificates. Docs on this aspect are here: https://eff-certbot.readthedocs.io/en/latest/using.html#setting-up-automated-renewal.


For example, to constantly regenerate certs in the background, you could setup something like this:

  certbot-renew:
    build:
      context: ./certbot-renew
      dockerfile: Dockerfile
    volumes:
      - ./certbot/var/:/var/certbot/:rw
      - ./certbot/etc/:/etc/letsencrypt/:rw

and then, as the ./certbot-renew/Dockerfile:

FROM certbot/certbot:latest

COPY ./renew.sh .
ENTRYPOINT ["/bin/sh"]
CMD ["renew.sh"]

and as ./certbot-renew/renew.sh:

#!/usr/bin/env sh
set -x
while true; do
    certbot renew --webroot --webroot-path /var/certbot
    sleep 12h
done

You'd then run this container by itself:

docker compose -f prod.yml up --build --detach certbot-renew

If everything goes well, this would print something like this:

[ ..redacted.. ]
certbot-renew-1  | + true
certbot-renew-1  | + certbot renew --webroot --webroot-path /var/certbot
certbot-renew-1  | Saving debug log to /var/log/letsencrypt/letsencrypt.log
certbot-renew-1  | 
certbot-renew-1  | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
certbot-renew-1  | Processing /etc/letsencrypt/renewal/[your-domain].conf
certbot-renew-1  | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
certbot-renew-1  | Certificate not yet due for renewal
certbot-renew-1  | 
certbot-renew-1  | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
certbot-renew-1  | The following certificates are not due for renewal yet:
certbot-renew-1  |   /etc/letsencrypt/live/[your-domain]/fullchain.pem expires on 2026-05-21 (skipped)
certbot-renew-1  | No renewals were attempted.
certbot-renew-1  | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
certbot-renew-1  | + sleep 12h

Of course you could also use just a cronjob here to run it twice a day instead of a basic while while true, but you get the gist.

Happy encrypting! 🔒