Cron + LetsEncrypt, docker style (Part 2)

Cornfield
Cron is to corn as po-ta-to is to po-tah-toh
Photo by Glenn Carstens-Peters on Unsplash

Part 1: WordPress hosting, docker style
Part 2: Cron + LetsEncrypt, docker style
Part 3: Matching socks: Nginx + php = WordPress

Today, I’m going to talk about running background jobs with docker. On a non-docker system, you can set up a server to do many things at once – for example run nginx AND update your SSL certs periodically. However, with Docker, you have to choose. You either need to run each process as a separate docker container, or you need to use some sort of supervisor process (supervisord, s6, systemd, etc) which will in-turn kick off the other processes you’re interested in.

This post talks about the first method, and is preferable (I think) when possible. The idea is pretty simple: Configure a set of cron tasks, and then run cron as the _foreground_ process of the docker container. This means that docker will make sure the cron daemon stays alive, and that it’s easy to pipe cron output back to docker. The downside is that cron is the only thing this docker container can do. Let’s take a quick look at how this looks like on alpine linux:

FROM alpine:3.13
RUN apk update
RUN apk add --no-cache restic

RUN mkdir -p /root/periodic
ADD ./backup.sh /root/periodic/backup.sh
ADD ./heartbeat.sh /root/periodic/heartbeat.sh
RUN chmod +x /root/periodic/*
RUN echo '16  *  *  *  *  /root/periodic/heartbeat.sh' >> /etc/crontabs/root
RUN echo '0  23  *  *  *  /root/periodic/backup.sh' >> /etc/crontabs/root

CMD crond -l 2 -f

So for this specific example, there’s a lot of flexibility I want to highlight

  • You can run as many scripts as you want, just by having a .sh script and then adding the appropriate entry to /etc/crontabs/root
  • Cron lets you specifiy how often tasks run, whether it’s every minute, on a specific time, etc.
  • Setting the CMD as crond -f runs cron in the foreground, dumping logs to stdout, as well as making sure docker knows if crond ever terminates.
  • In order to connect between different containers, you can connect them in some way. For my needs, I only need access to the files, so I shared a volume to the container for the files I want backed up:
backup:
    build:
      context: ./backup
    restart: always
    env_file:
      - ./secrets/backup.env
    volumes:
      - ./www:/var/www:ro
      - ./secrets/certs:/etc/ssl/certs/private/terminal.space:ro

The :ro attribute enforces that the files are read-only. This helps isolate the backup command so that it can’t affect the rest of the ecosystem as it runs.

Finally, a peek at the actual backup script (fairly simple using restic)

echo "Running restic backup"
restic --verbose=3 backup /var/www/html/wp-content/updraft --exclude="log*"
restic --verbose=3 backup /etc/ssl/certs/private/terminal.space
# Purge anything older than a month
restic --verbose=3 forget --keep-within 1m

LetsEncrypt

LetsEncrypt is a free service which generates SSL certificates for your website. There are only two issues which we need to work through:

  • The certificates expire every 3 months. That’s just short enough that I want an automated way to renew the certificates
  • When creating or renewing the certificates, LetsEncrypt needs some way of verifying that you control the domains you’re creating the certificates for. The two main ways are: setting DNS records, or uploading a special file to /.well-known/acme-challenge. The script needs to sign some data with the private SSL key. (This step isn’t needed for renewal)

For the first bullet point, we’ll use cron (surprise!) to solve the issue. Every few months, we’ll update the SSL files, and save them to disk. These certs are shared with the reverse proxy (from my earlier post) so once nginx is reloaded, it will pick up the new certs.

To actually generate (and renew) the certificates, there’s two main options: certbot and acme.sh. For my case, since I’m using DNS validation, the easiest way to go is with acme.sh. Basically, Certbot stopped including plugins in their core installation, and getting the plugins to work in different distros has been annoying for me. There’s one minor difficulty with acme.sh, however. The installation script, for who knows what reason, requires an email address at build time. Additionally, this email address is needed during renewal and can’t be passed in then. So, I needed to do some extra work to pass in an environment variable from a .env file.

docker-compose:

acme:
    build:
      context: ./acme
      args:
        - LETS_ENCRYPT_EMAIL=${LETS_ENCRYPT_EMAIL}
    restart: always
    env_file:
      - secrets/acme.env
    volumes:
      - ./secrets/certs:/root/.acme.sh/terminal.space

Dockerfile:

FROM alpine:3.13
RUN apk update
ARG LETS_ENCRYPT_EMAIL
ENV LETS_ENCRYPT_EMAIL $LETS_ENCRYPT_EMAIL
RUN apk update
RUN apk add --no-cache openssl socat

# Install acme.sh
RUN wget -O -  https://get.acme.sh | sh -s email=${LETS_ENCRYPT_EMAIL}


RUN mkdir -p /root/periodic
ADD ./renew.sh /root/periodic/renew.sh
ADD ./heartbeat.sh /root/periodic/heartbeat.sh
ADD ./create.sh /create.sh
RUN chmod +x /root/periodic/*
# Hourly heartbeat
RUN echo '16  *  *  *  *  /root/periodic/heartbeat.sh' >> /etc/crontabs/root
# Update the certs monthly on the 8th of the month
RUN echo '18  8  8  *  *  /root/periodic/renew.sh' >> /etc/crontabs/root

CMD crond -l 2 -f

The bash script is actually really simple, just using the built-in gandi API to authorize the domain:

/root/.acme.sh/acme.sh --issue --dns dns_gandi_livedns -d terminal.space -d *.terminal.space