Recently I’ve configured Let’s Encrypt certificates for a staging HTTPS server for one of our clients. That was amazingly easy to do. I remember checking the ACME protocol client tools in the very beginning of Let’s Encrypt initiative. Back then the tools were, of course, very immature: hard to install and configure, and they only worked in interactive mode. It’s all in the past now.

Basically, I took this great blog post and codified it in SaltStack states. SaltStack is an automatic configuration management tool similar to Puppet and Ansible. It’s quite simple and declarative, yet it scales up to larger infrastructure. Our sysadmins love it.

Below is the annotated YAML of my solution.

backports:
  pkgrepo.managed:
    - name: deb http://ftp.de.debian.org/debian jessie-backports main contrib non-free
    - file: /etc/apt/sources.list.d/backports.list

dehydrated:
  pkg.installed:
    - fromrepo: jessie-backports
    - require:
      - pkgrepo: backports

That adds the jessie-backports Debian package repository, and installs the Dehydrated ACME client from it. Starting with Debian Stretch (v9) it’s not necessary to add the backports repo. Then, the section above can be reduced to just this:

dehydrated:
  pkg.installed

The salt://web-server/conf/example.com nginx configuration file:

server {
    listen 80;
    server_name example.com;

    include /etc/nginx/conf.d/*.http.location.inc;

    location / {
        return 301 https://example.com$request_uri;
    }
}

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

    ssl on;
    include /etc/nginx/conf.d/ssl.inc;
    include /etc/nginx/conf.d/*.location.inc;

    # ...
}

Which is then referenced from the salt://web-server/init.sls:

/etc/nginx/sites-enabled/example.com:
  file.managed:
    - source: salt://web-server/conf/example.com
    - require:
      - pkg: nginx
    - watch_in:
      - service: nginx

/etc/nginx/conf.d/acme-challenge.http.location.inc:
  file.managed:
    - source: salt://web-server/conf/acme-challenge.http.location.inc
    - require:
      - pkg: nginx
      - pkg: dehydrated
    - watch_in:
      - service: nginx

The salt://web-server/conf/acme-challenge.http.location.inc file is nothing but:

location ^~ /.well-known/acme-challenge {
    auth_basic "off";
    alias /var/lib/dehydrated/acme-challenges;
}

Now the tricky bit. The /etc/nginx/conf.d/ssl.inc referenced in the salt://web-server/conf/example.com will in the end contain the path to the certificate file, and the path to the secret server key file. We don’t have those initially, before the ACME challenge takes place. Thus, we need some “transitional” certificate and key to make nginx start and serve the ACME challenge Web location over HTTP.

ssl_inc_transitional:
  cmd.run:
    - name: >
        echo 'ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;' > /etc/nginx/conf.d/ssl.inc &&
        echo 'ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;' >> /etc/nginx/conf.d/ssl.inc
    - unless: test -f /etc/nginx/conf.d/ssl.inc
    - require:
      - pkg: nginx
    - require_in:
      - service: nginx

/etc/dehydrated/domains.txt:
  file.managed:
    - contents:
      - example.com
    - require:
      - pkg: dehydrated

initial_lets_encrypt_cert:
  cmd.run:
    - name: /usr/bin/dehydrated --cron
    - unless: test -d /var/lib/dehydrated/certs/example.com
    - require:
      - file: /etc/nginx/conf.d/acme-challenge.http.location.inc
      - file: /etc/dehydrated/domains.txt

ssl_inc:
  cmd.run:
    - name: >
        /bin/systemctl stop nginx &&
        echo 'ssl_certificate /var/lib/dehydrated/certs/example.com/fullchain.pem;' > /etc/nginx/conf.d/ssl.inc &&
        echo 'ssl_certificate_key /var/lib/dehydrated/certs/example.com/privkey.pem;' >> /etc/nginx/conf.d/ssl.inc &&
        /bin/systemctl start nginx
    - onlyif: test -f /etc/nginx/conf.d/ssl.inc && cat /etc/nginx/conf.d/ssl.inc | grep -F ssl-cert-snakeoil
    - require:
      - cmd: initial_lets_encrypt_cert

The final part is the certificate renewal cron job:

root_email_for_cron:
  cron.env_present:
    - user: root
    - name: MAILTO
    - value: webmaster@example.com

lets_encrypt_cert_update:
  cron.present:
    - name: chronic /usr/bin/dehydrated --cron && systemctl reload nginx
    - identifier: lets_encrypt_cert_update
    - user: root
    - dayweek: 0
    - hour: 4
    - minute: 2

The chronic tool is a part of the moreutils Debian package. It runs a command quietly unless it fails. Setting the MAILTO environment variable for cron makes sure all the failures are e-mailed to webmaster@example.com. The cron job configured above will run every Sunday at 04:02.