DB-유

Free SSL with Let's Encrypt and Certbot on nginx

· linux, nginx, ssl, letsencrypt

I used to dread certificate renewals: the CSR, the email dance, pasting files into place, then forgetting until the browser warning showed up. Let's Encrypt is free and Certbot does the issuing and renewing. The 90-day lifetime sounds annoying until you realise it forces you to automate the whole thing.

Here's my setup on Ubuntu 16.04 with nginx, using the webroot method. I prefer it to the nginx plugin because Certbot never edits my nginx config. CentOS 7 notes are included where packages differ. You need nginx already answering on port 80 from the Internet, and DNS A records for example.com and www.example.com pointing at the server, since that's how Let's Encrypt validates.

Install Certbot

The Ubuntu 16.04 package is old. Use the Certbot PPA:

sudo apt-get install software-properties-common
sudo add-apt-repository ppa:certbot/certbot
sudo apt-get update
sudo apt-get install certbot
certbot --version

On CentOS 7, certbot is in EPEL: yum install epel-release && yum install certbot.

A challenge location in nginx

Let's Encrypt checks domain control by fetching http://example.com/.well-known/acme-challenge/<token>. I don't let Certbot write into the site's document root; I point that path at a dedicated directory. In the HTTP server block:

server {
    listen 80;
    server_name example.com www.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
        default_type "text/plain";
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

Leave out the return 301 until the certificate exists. Create the directory and reload:

sudo mkdir -p /var/www/letsencrypt
sudo nginx -t && sudo systemctl reload nginx

Get the certificate

sudo certbot certonly --webroot -w /var/www/letsencrypt \
  -d example.com -d www.example.com \
  --email admin@example.com --agree-tos --no-eff-email

On success Certbot prints the path of the new files:

/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem

These are symlinks into /etc/letsencrypt/archive/ that get updated on renewal, so the nginx config never has to change.

The HTTPS server block

Add a second block for port 443:

server {
    listen 443 ssl;
    server_name example.com www.example.com;

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

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;

    add_header Strict-Transport-Security "max-age=15768000" always;

    root /var/www/example.com/html;
    index index.html;
}

Now enable the return 301 redirect in the port 80 block, open port 443 in the firewall (ufw allow 443/tcp or firewall-cmd --permanent --add-service=https), and reload nginx.

Renewal

The PPA package installs a systemd timer (certbot.timer) that runs certbot renew twice a day. Certbot only renews within 30 days of expiry, so that's fine. What the timer doesn't do is reload nginx, and I learned that by staring at an expired cert on a server that had "renewed" fine. Add a deploy hook:

sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

On CentOS 7 there is no timer by default; add a cron entry in /etc/cron.d/certbot:

0 3,15 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"

Check it

A dry run exercises the whole challenge path against the staging server without issuing anything:

sudo certbot renew --dry-run

Then check what the browser will see:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -issuer -dates

The issuer should be Let's Encrypt Authority X3 and notAfter about 90 days out. systemctl list-timers | grep certbot confirms the timer is scheduled.

Errors I've seen

  • Failed authorization procedure ... Invalid response from http://example.com/.well-known/acme-challenge/...: 404: nginx isn't serving the challenge directory. Put a file at /var/www/letsencrypt/.well-known/acme-challenge/test and fetch it with curl. Another location block may be matching first; ^~ avoids that.
  • Connection refused during validation: port 80 is closed at the firewall or DNS points elsewhere.
  • too many certificates already issued for exact set of domains: rate limit, 5 per week per exact domain set. Use --staging while experimenting.
  • Renewed but browsers show the old cert: the deploy hook is missing or not executable. Run certbot renew --force-renewal once to test it, but don't make a habit of it.

I still keep an expiry check in a monitoring script. Automation fails quietly and 90 days is short.