loading...
Cover image for No-Magic LetsEncrypt/Certbot and nginx Configuration Recipe

No-Magic LetsEncrypt/Certbot and nginx Configuration Recipe

joelaberger profile image Joel Berger ・8 min read

LetsEncrypt is a certificate authority that makes free ssl certificates available to everyone. I've been using it for years, however initially I was not happy with their tooling, so all this time I've been using a client that I wrote (modeled after another home-rolled client from python).

Over the years, their official client, (now) called certbot, has reached a point where it is clearly better designed than my own. I'm still not a huge fan of how you change configuration options by passing them as arguments to a live command, as you'll see later in this article, but c'est la vie.

That said, I'm still hesitant to give it control over my nginx webserver, especially since the details of what the nginx plugin purports to do are scarce. In this article I will show you how I've configured certbot and nginx to work with each other without handing certbot the "keys" to nginx.

nginx certbot siphon and https redirect

The first part of the system is to install an nginx virtual host that handles all the traffic on port 80 and with it does the following two things. If a request is a certbot challenge, then it siphons off that request and sends them to a (currently non-existent) upstream server running on port 8000. Otherwise it redirects all remaining traffic to https on port 443.

upstream certbot {
  server 127.0.0.1:8000;
}
server {
  listen [::]:80;
  listen 80;
  server_name _;

  location /.well-known/acme-challenge {
    proxy_pass http://certbot;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }

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

If you're using a typical setup, this file should live in /etc/nginx/sites-available/. Because nginx loads configuration files in asciibetical I've called mine 01-http. Then the file is symlinked from that location into /etc/nginx/sites-enabled/ to enable it.

Note that the upstream port doesn't matter, it can be any unused port. Also note that in a perfect world the redirect would probably be a 308 (which redirects without changing method) but I'm not confident that all clients handle it correctly so we'll use 301 for now. It is unlikely to matter unless you're hosting an API anyway.

To load the new configuration, first test it with

$ sudo nginx -t

then use your system's init system to reload. If you're on systemd it would be

$ sudo systemctl reload nginx

Issue your certificates

If you haven't already done so, install certbot. You can also register an account, though I believe if you don't it will happen along with issuing the certificate.

In order to actually issue the certificate, create a small shell script containing the following

#!/bin/bash

sudo certbot certonly\
  --standalone\
  --http-01-port 8000\
  --deploy-hook 'systemctl reload nginx'\
  --cert-name MYDOMAIN.TLD\
  -d MYDOMAIN.TLD,www.MYDOMAIN.TLD,...

Since it is only one command, in theory you could just run it directly without storing it as a script first. Unfortunately, as I alluded to earlier, running this command is also configuring the client itself. If you want to change any of the settings, especially the domains, you'll have to rerun all of it exactly, changing only parameters you want to change (usually adding domains). If you don't pass the entire command, settings you thought were set will be UNSET! Therefore having it stored as a script will allow you to make changes and ensure that you have your settings persisted. But what are the settings?

The cert name can in theory be anything you want, but I suggest you use the name of your primary domain. -d is a comma separated list of names to register. Don't forget to register the simple domain and the domain with www. and of course any other hostnames you are going to need. Note that it doesn't matter if the domains have any content yet, all that matters is that requests to them must arrive at your server (i.e. that they're configured with your DNS provider). Importantly make sure that the port you set is the same port that you used in the siphon in your nginx configuration.

Confusingly, the "deploy hook" is also how you set the renewal hook. On our first issue we won't actually have needed to reload nginx again since nothing is configured to use the new certificates yet, and therefore nothing needs to be reloaded to use them. Once again though, remember that you're also configuring the client to behave how you'll want it to when it automatically renews your certificates, at which point you'll need this behavior. If you don't want to set this up yet, that's fine, it can be done later by reiussing the certificates again with this line added or via a config file I'll point out later.

Now run the script. When the it completes, it should output the paths to your new certificates. If you need to see them again, you can run sudo certbot certificates. For me, the files live in /etc/letsencrypt/live/MYDOMAIN.TLD/. You'll need these paths later.

Generate DH Parameters

Before you configure SSL, you'll want to generate Diffie-Hellman Parameters for your server to use. Doing so will generate another file and you'll need a place to put it. I usually make a new directory in the nginx configuration at /etc/nginx/ssl/. If you do that, then you can run this to generate the parameters file:

$ sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 4096

Strictly speaking, root isn't necessary to run that command, but if you don't you'll want to change the ownership of the file anyway.

Configure nginx SSL settings

Once this file has been created, add another new file, this time /etc/nginx/conf.d/ssl.conf. In it, put the contents

ssl_certificate     /etc/letsencrypt/live/MYDOMAIN.TLD/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/MYDOMAIN.TLD/privkey.pem;
ssl_protocols       TLSv1.2 TLSv1.3;
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_dhparam         /etc/nginx/ssl/dhparam.pem;
ssl_session_cache   shared:SSL:10m;
ssl_prefer_server_ciphers off;

I believe that its handy to collect the ssl settings in a separate file like this, for ease of finding/editing later. That said, it is likely that your default nginx config already has some ssl settings set for you. You'll probably need to edit your toplevel nginx config (usually /etc/nginx/nginx.conf) and remove the ssl lines that conflict. Incidentally, my toplevel nginx config is quite short, just setting a few basics then loading the files in the conf.d and sites-enabled folders.

For the specifics, I've used Mozilla's suggestions as a starting point and SSL Labs to check and refine. One annoyance with nginx is that while the ssl parameters inherit to (virtual) server blocks, headers do not, so you can't set security headers here. Again, test and reload your nginx configuration.

Enabling SSL in your virtual hosts

At this point, if you've already configured other virtual hosts on your server, adjust them so that they're served on port 443 with ssl enable. You don't need to include the same ssl details as have already been given in the ssl config. A typical site's server block will usually start with something like

server {
  listen [::]:443 ssl;
  listen 443 ssl;
  server_name MYHOST.MYDOMAIN.TLD;

For a reverse proxy to a Mojolicious application our recommendation (slightly modified from the Cookbook) is

upstream myapp {
  server 127.0.0.1:8080;
}
server {
  listen [::]:443 ssl;
  listen 443 ssl;
  server_name MYHOST.MYDOMAIN.TLD;
  location / {
    proxy_pass http://myapp;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

and be sure that you've started your application on port 8080 (the default for hypnotoad) and that it is in reverse proxy mode. If you've changed anything, then again, test and reload nginx.

Now, simply visit your site to see that your new certificate is in use. In chrome, click the green padlock (it should be green) and in the dropdown click "certificate". It should show you a certificate with 90 days of remaining life.

Examining your setup

Already, certbot will have installed a cron or systemd job that will attempt to renew the certificates 30 days before they expire, or 60 days from now. To be sure of that check for a cron file in /etc/crontab or /etc/cron.*/* or by looking at systemctl list-timers. With this you know that it will attempt to renew your certificates.

You should also verify that the deploy/renew hook is installed. You can see it in the renewal configuration, which for me is at /etc/letsencrypt/renewal/MYDOMAIN.TLD.conf. Check for the line

renew_hook = systemctl reload nginx

under the [renewalparams] block. This file also contains paths to relevant certificates, should you need that list again. It also contains the configuration that I wish you didn't have to continually re-specify in order to not unset it. Grrr.

Verify cron/timer

The cron or timer is set to run twice per day. So after you've waited a day or so if you check your letsencrypt logs (for me, at /var/log/letsencrypt/letsencrypt.log you should see messages telling you that it attempted renewal but the certificates were not old enough yet.

INFO:certbot.renewal:Cert not yet due for renewal

You might want to set a calendar reminder at this point, its worth checking after 60 days have elapsed that a new certificate was issued and that nginx is using it. If something went wrong, you'll have 30 days to fix it which should be plenty. Even if you do, you'll get emails from letsencrypt that old certificate is expiring when it has 10 days remaining, even if you've replaced it with a new cert. That's your last useful reminder to check as any remaining emails will come with little or no time to fix things.

Test against SSL Labs

When everything is ready, you can do a final check against SSL Labs to see that you have everything up to snuff. Currently I get an "A" grade with the settings that I've shown here.

Conclusion

I hope this recipe is a useful no-magic way of incorporating letsencypt into your nginx server without making you feel like you've given up control over your server to a black box script out of your control. Your 01-http virtual host takes care of the challenges and the deploy hook reloads nginx and that's it. Simple and magic-free.

Cover image is licensed CC BY 2.0 by EpicTop10.com

Posted on by:

joelaberger profile

Joel Berger

@joelaberger

Mojolicious core developer, Perl/Python/Modern Javascript, Physicist, Sailor

Discussion

pic
Editor guide