Getting Started With Let's Encrypt

by Jeff Bradberry

Let's Encrypt is a new certificate authority, run in a cooperative effort with the goal of making it easy for everyone to obtain and renew the certificates needed to enable secure encrypted connections for their domain. It's free to use, uses open standards and open source software, and provides certificates via a fully automated process. Major sponsors of Let's Encrypt include the Mozilla Foundation and the EFF.

Let's Encrypt entered public beta on December 3rd, so there was no longer any good reason to hold back from obtaining a certificate for my site. This blog post will go over the steps I took to get set up with a Let's Encrypt certificate, and to get everything working.

The documentation for the Let's Encrypt client can be found here: https://letsencrypt.readthedocs.org/en/latest/intro.html.

I prefer to run my site on Debian, with nginx as the static file server and as a reverse proxy for my Django apps, which run on top of Apache. Since the nginx plugin for Let's Encrypt is currently 'highly experimental', I decided against using it. Additionally, my config files are jinja templates kept in version control, and are populated and pushed by Ansible. I didn't want to choose a plugin that would make in-place changes that would then get clobbered on the next deployment. So, ultimately I decided to go with the webroot plugin.

The documentation's only requirement for webroot is that you make sure that hidden directories can be served by your webserver. However, this is a bit problematic for my nginx Django config because I have everything except for /static and /media proxied to Apache. It would be better to explicitly expose a single directory rather than opening up all hidden directories that happen to exist. The solution is to add an explicit location directive for the hidden directory that the webroot plugin will be using. This information isn't revealed anywhere in the docs at the current time, but some Googling around allowed me to find that it uses WEBROOT/.well-known/. After adding the directive to expose this directory, my nginx template files look like this:

server {
    listen 80;
    listen [::]:80;
    server_name {{ sitename }};

    location /.well-known {
        alias /var/www/{{ sitename }}/.well-known;
    }
    location /static {
        alias /var/www/{{ sitename }}/static;
    }
    location /media {
        alias /var/www/{{ sitename }}/media;
    }
    location / {
        proxy_pass http://127.0.0.1:{{ port }};
        proxy_set_header Host $host;
    }
}

Once this configuration has been deployed, you are ready to run the letsencrypt command on the server.

# git clone https://github.com/letsencrypt/letsencrypt/
# cd letsencrypt

Let's suppose that I want a single certificate that covers both my main blog site at (www.)jeffbradberry.com, and a single subdomain foo.jeffbradberry.com that hosts a Django app. The -w <filepath> flag specifies the path to the directory that is the root for the following subdomains, and -d <subdomain> specifies those subdomains. I'd also like to skip any manual input steps, so I'll add the --email flag to specify my email address, and --agree-tos to automatically agree to the terms of service (which can be found here, under Subscriber Agreement). This command, then, requests one certificate that includes all of the requested domains:

# ./letsencrypt-auto certonly --webroot \
    -w /var/www/jeffbradberry.com/ \
    -d jeffbradberry.com \
    -d www.jeffbradberry.com \
    -w /var/www/foo.jeffbradberry.com/ \
    -d foo.jeffbradberry.com \
    --email=jeffbradberry@example.com \
    --agree-tos

Once the cert is successfully issued, we can configure nginx to actually use it. We just add listen directives for port 443 with ssl, and point to the fullchain.pem and privkey.pem files, which live under the /etc/letsencrypt/live/ directory. After these changes, my config templates now look like this:

server {
    listen 80;
    listen [::]:80;
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name {{ sitename }};

    ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;

    location /.well-known {
        alias /var/www/{{ sitename }}/.well-known;
    }
    location /static {
        alias /var/www/{{ sitename }}/static;
    }
    location /media {
        alias /var/www/{{ sitename }}/media;
    }
    location / {
        proxy_pass http://127.0.0.1:{{ port }};
        proxy_set_header Host $host;
    }
}

I also had to modify a task in my Ansible nginx role, to open up port 443 in addition to port 80 in my firewall (ufw).

- name: allow http(s) through firewall
  ufw: rule=allow port={{ item }}
  with_items:
    - http
    - https

One deployment later, and I was able to confirm that https://jeffbradberry.com was working.

When we are satisfied that the command works and doesn't require user input, we can automate renewal by putting it into a cronjob. Renewal of Let's Encrypt certificates work by running the letsencrypt-auto command with the same parameters that you originally ran it with. These certificates expire after 90 days, so we need to set up a renewal to happen a bit more frequently than that. Here I'm going with a 2 month interval, 5:30am on the first day of each even-numbered month, followed by a reload of nginx:

# /etc/cron.d/letsencrypt
30 5 1 */2 * /root/letsencrypt/letsencrypt-auto certonly --webroot ... && /etc/init.d/nginx reload

So what's next?

I wanted to see how secure my setup was, so I used the Qualsys SSL Server Test to rate my domain. The result was a 'B' grade. The main complaints were that the server was using weak Diffie-Hellman (DH) key exchange parameters, that it was using common DH primes, and that it didn't have session resumption via caching enabled. Additionally, though it didn't trigger warnings, OCSP stapling and String Transport Security (HSTS) were not enabled. For more information about these, see https://wiki.mozilla.org/Security/Server_Side_TLS.

To address these issues, first I generated a strong DH parameter using

# cd /etc/ssl/certs
# openssl dhparam -out dhparam.pem 4096

The full path to this file can then be added to the nginx config under the ssl_dhparam directive.

Next, I used the Mozilla SSL Configuration Generator to construct good settings. After these changes, my templates now look like

server {
    listen 80;
    listen [::]:80;
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name {{ sitename }};

    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;

    ssl_dhparam /etc/ssl/certs/dhparam.pem;

    ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
    ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
    ssl_prefer_server_ciphers on;

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

    ssl_stapling on;
    ssl_stapling_verify on;

    location /.well-known {
        alias /var/www/{{ sitename }}/.well-known;
    }
    location /static {
        alias /var/www/{{ sitename }}/static;
    }
    location /media {
        alias /var/www/{{ sitename }}/media;
    }
    location / {
        proxy_pass http://127.0.0.1:{{ port }};
        proxy_set_header Host $host;
    }
}

This configuration is sufficient to raise my site's grade to an 'A+'.

Everything so far has been a bunch of manual operations, so now I want to automate it. To do so, I created the following letsencrypt.yml playbook, to run as a one-off script against brand-new deployments:

---
- hosts: all
  vars:
    domain: jeffbradberry.com
    email: jbradberry@example.com
    webroots_and_domains: "-w /var/www/jeffbradberry.com/ -d jeffbradberry.com -d www.jeffbradberry.com -w /var/www/foo.jeffbradberry.com/ -d foo.jeffbradberry.com"
  tasks:
  - name: checkout or update the letsencrypt repo
    git: repo=https://github.com/letsencrypt/letsencrypt
         dest=/root/letsencrypt

  - name: check if we already have a letsencrypt cert
    stat: path=/etc/letsencrypt/live/{{ domain }}/fullchain.pem
    register: cert

  - name: obtain initial letsencrypt cert
    command: /root/letsencrypt/letsencrypt-auto certonly --webroot
      {{ webroots_and_domains }}
      --email={{ email }}
      --agree-tos
    when: cert.stat.exists == False

  - name: add renewal cron job
    cron: name="renew cert"
          cron_file=letsencrypt
          user=root
          month="2,4,6,8,10,12" day="28"
          hour="13" minute="40"
          job="/root/letsencrypt/letsencrypt-auto certonly --webroot {{ webroots_and_domains }} --email={{ email }} --agree-tos --expand && /etc/init.d/nginx reload"
          state=present

  - name: create strong DH params if we haven\'t already done so
    command: openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096
             creates=/etc/ssl/certs/dhparam.pem

Note the --expand flag to the letsencrypt-auto command, which automates adding more subdomains to your certificate when you renew. Also, in the final task in this playbook the creates= parameter to command ensures that the task will only fire off if this file does not yet exist.

Now that we have everything working satisfactorily, we can put the finishing touches on the site. All content should be checked for internal links that use a hard-coded http://, and these should be replaced with either // or https://, depending on whether you want to enforce the use of ssl or not. If you do decide that you want to force ssl, you should also move the listen 80 statements into their own server block in the nginx config and redirect:

server {
    listen 80;
    listen [::]:80;

    return 301 https://$server_name$request_uri;
}

UPDATE 2/28: Fixed up the cron job and Ansible playbook.