Published : 2016-09-19

Let's Encrypt with an Nginx jail

Let’s Encrypt is a certificate authority that uses an HTTP-based API and a server-side client which generates tokens validated by Let’s Encrypt’s servers. This guide shows a typical Let’s Encrypt setup where we use the OpenBSD acme-client available in FreeBSD ports and packages.

Installation

First, install the client on your host machine (not inside the web jail):

pkg install acme-client

Nginx configuration

Next we configure nginx so it can serve the ACME challenge files. Inside your jail create the following directory and set the owner to nobody:

mkdir /usr/local/www/.well-known && chown nobody:nobody /usr/local/www/.well-known

Then configure your virtual host(s) to serve requests under /.well-known from that directory:

server {
    listen 80;
    server_name www.unix-experience.fr;
    location /.well-known {
      alias /usr/local/www/.well-known;
    }
    location / {
      return 301 https://www.unix-experience.fr$request_uri;
    }
    add_header X-Frame-Options "DENY";
    add_header Strict-Transport-Security "max-age=86400; preload";
}

Note: the example enables HSTS with a 1‑day TTL to indicate browsers should prefer HTTPS. You can point several virtual hosts to the same /.well-known directory.

Configuring the Let’s Encrypt client

Back on the host, create /usr/local/etc/acme/domains.txt and list one domain per line:

www.unix-experience.fr
ftp.unix-experience.fr

Create the script below (based on the example script) at /usr/local/etc/acme/acme-client.sh:

#!/bin/sh -e
WEBJAIL="web"
BASEDIR="/usr/local/etc/acme"
SSLDIR="/usr/local/etc/ssl/acme"
DOMAINSFILE="${BASEDIR}/domains.txt"
CHALLENGEDIR="/jails/${WEBJAIL}/usr/local/www/.well-known/acme-challenge"

[ ! -d "${SSLDIR}/private" ] && mkdir -pm700 "${SSLDIR}/private"

cat "${DOMAINSFILE}" | while read domain line ; do
   CERTSDIR="${SSLDIR}/${domain}"
   [ ! -d "${CERTSDIR}" ] && mkdir -pm755 "${CERTSDIR}"
   set +e # RC=2 when time to expire > 30 days
   acme-client -b -C "${CHALLENGEDIR}" \
         -k "${SSLDIR}/private/${domain}.pem" \
         -c "${CERTSDIR}" \
         -n -N -v \
         ${domain} ${line}
   RC=$?
   set -e
   [ $RC -ne 0 -a $RC -ne 2 ] && exit $RC
done

This script reads domains from domains.txt, creates the private keys, and requests certificates from Let’s Encrypt using the /.well-known directory served by the jail. If successful, private keys will be stored under /usr/local/etc/ssl/acme/private/<domain>.pem and public/certificate chains under /usr/local/etc/ssl/acme/<domain>/ on the host (not inside the web jail).

Create a deploy script at /usr/local/etc/acme/deploy.sh to copy certificates into the jail and restart services:

#!/bin/sh

set -e

BASEDIR="/usr/local/etc/acme"
DOMAINSFILE="${BASEDIR}/domains.txt"
LEDIR="/usr/local/etc/ssl/acme"
JAILSDIR="/jails"
TARGETS="web"
cat "${DOMAINSFILE}" | while read domain line ; do
  for jail in ${TARGETS}; do
    targetdir="${JAILSDIR}/${jail}/etc/ssl"
    # Check if the certificate has changed
    [[ -z "`diff -rq ${LEDIR}/${domain}/fullchain.pem ${targetdir}/certs/${domain}.pem`" ]] && continue
    cp -L "${LEDIR}/private/${domain}.pem"   "${targetdir}/private/${domain}.pem"
    cp -L "${LEDIR}/${domain}/fullchain.pem" "${targetdir}/certs/${domain}.pem"
    chmod 400 "${targetdir}/private/${domain}.pem"
    chmod 644 "${targetdir}/certs/${domain}.pem"
    # Restart/load relevant services
    [[ "${jail}" = "web" ]] && jexec ${jail} service nginx restart
  done
done

The deploy script copies certificates negotiated by Let’s Encrypt into the web jail and restarts nginx inside the jail.

Automatic renewal

Enable the weekly cron job provided by the acme-client package by adding the following to /usr/local/etc/periodic.conf:

# letsencrypt
weekly_acme_client_enable="YES"
weekly_acme_client_user="nobody"
weekly_acme_client_deployscript="/usr/local/etc/acme/deploy.sh"

This activates the weekly job and runs the deploy script automatically.

Conclusion

You can now request Let’s Encrypt certificates and renew them automatically using the acme-client cron job.