Jens Krämer

Let's Encrypt SSL Certificates With HAProxy and Stable Keys

 |  letsencrypt, haproxy, security, devops, linux, debian

I’ve been a (more or less) happy StartSSL customer for years, but since they are going to lose their status as a trusted CA these days for various reasons, I finally got around to switching to Let’s Encrypt. Here’s what I did.

The Situation

I have HAProxy running in front of several nginx instances on different (virtual) machines. HAProxy handles all the TLS stuff. This is Debian Jessie, so HAProxy is a rather old v1.5.x, but that’s fine with the approach outlined here. No need to install backported / self-compiled newer versions.

I’ve also been using HPKP now for a while, which leads to the following requirements:

  • the keys used for generating certificates should be rather stable, since each change requires changes to the HPKP headers
  • new keys have to be introduced to clients by inclusion in the HPKP header a long time (at least 2 months, in my case) before they are actually used

So I had to find a way to use my existing keys (which my sites are ‘pinned’ to) and generate new Let’s Encrypt certificates from them. Further I wanted to ensure that future (automatic) certificate renewals also reuse the same private keys. Since Let’s Encrypt only issues certificates with 3 months lifetime, automatic renewal and keeping the same keys both really are a must have.

While the official Let’s Encrypt client, Certbot, goes great lengths to automate the whole certificate generation and renewal process, it unfortunately does not provide this comfort in combination with pre-generated, constant keys and CSRs.

What it does, on the other hand, is integrate with webservers to automatically install certificates and also configure the server for using them. Way too much to put into a single tool for my taste. Support for HAProxy is still in its early stages and requires building stuff yourself, so this is not really an option for now, anyway.

In this article, I’ll show how to setup certbot on Debian under a separate user account and use it in standalone mode, together with HAProxy, to generate and renew certificates from given keys / CSRs, with scripts suitable for unattended operation with any number of certificates / sites.

I won’t cover basic sysadmin tasks, HAProxy SSL setup or HPKP configuration.

OpenSSL Preparations

You can (and should) do all of this on your local machine, we’ll copy the relevant files to the server later.

Generate private keys (if you haven’t already)

openssl genrsa -out yoursite.key 4096

Sidenote: If you’re using HPKP, you really should generate at least two backup keys and include their hashes in your headers as well, to avoid bricking your domain through loss of a private key. Print out your backup keys and/or store them (encrypted) in various different locations. You do not want to leave them lying around on your server because that would render them useless exactly when you need them - if your server got hacked any keys that were stored on it are useless.

I use gpg to encrypt a tar archive which I distribute to various places. It’s also no mistake to make an offsite backup of your primary key of course.

The HPKP hash of an RSA private key to put into the HPKP header can be generated like that:

openssl rsa -in yoursite.key -outform der -pubout| openssl dgst -sha256 -binary | openssl enc -base64

Create an openSSL config file for each certificate

This can be a big timesaver later on if you’re using the same certificate for several (sub)domains and want to regenerate the CSR for this certificate. Use the default file from /etc/ssl/openssl.cnf as a template and copy it to yoursite.cnf for example.

In the [ req ] section, uncomment or add the line

req_extensions = v3_req

You should also change the default_bits value to 4096 here.

Then, in the [ v3_req ] section, add this:

subjectAltName = @alt_names

To save further typing later on you should also change or add the commonName_default and emailAddress_default values in the [ req_distinguished_name ] section:

commonName_default =
emailAddress_default =

And finally, at the end of the file, add a new section holding your domain names:

[ alt_names ]

DNS.1 =
DNS.2 =
DNS.3 =
DNS.4 =

Generate a CSR

With the config file you just created this is easy:

openssl req -new -key yoursite.key -out yoursite.csr -config yoursite.cnf

This will ask several questions about country, location and organization. Simply accept the defaults, these values are ignored by Let’s Encrypt anyway. Common name and email address should hold your configured defaults and can thus be accepted by pressing enter as well. Do not enter a passphrase, but just press enter two more times and you’re done.

Whenever you have to regenerate the CSR for this certificate due to a change in the domains it is for, or due to a changed private key, just edit the config file if necessary and re-run the above command.

On the Server

Set Up Certbot and Add a Separate User Account for Running It

Install certbot

The Certbot Site has a nice interactive installation howto. For Debian Jessie the suggestion is to use the certbot package from the jessie-backports repository, which I did.

Create the certbot user

Using a separate account and not having this run as root is just good practice and helps to keep things nicely contained in a dedicated directory. I’ll leave the user creation up to you but in the end you should have a user certbot with /bin/bash as shell and a home of /home/certbot. Note that this user doesn’t need to have a password set at all, for the few times you have to interactively use it (only during setup / while trying things out) you can just use su - certbot as root.

In /home/certbot, create a few directories:

  • certs which will hold one directory per certificate / site
  • logs for certbot logs
  • config for any files certbot creates at runtime

Configure certbot

To simplify certbot usage, it’s a good idea to create a config file in /home/certbot/.config/letsencrypt/cli.ini which holds most of certbots config:

# Use a 4096 bit RSA key instead of 2048
rsa-key-size = 4096

# update to register with the specified e-mail address
email =

# use a text interface instead of ncurses
text = True
non-interactive = True
agree-tos = True

# use the standalone authenticator
authenticator = standalone

preferred-challenges = http-01
# this is the same port as in the haproxy letsencrypt backend:
http-01-port = 54321

work-dir = /home/certbot
config-dir = /home/certbot/config
logs-dir = /home/certbot/logs

With this, you should be able to successfully run certbot register as the certbot user. This should create some files below /home/certbot/config.

Configure HAProxy

I assume you already have SSL/TLS set up for your sites so I’ll show just the relevant parts here for making the Let’s Encrypt domain validation work.

frontend http
  # more config here...

  # direct all letsencrypt requests to a special backend
  acl letsencrypt-acl path_beg /.well-known/acme-challenge/
  use_backend letsencrypt-backend if letsencrypt-acl

  # more config here...

# define the backend referenced above and point it to the certbot validation server
backend letsencrypt-backend
server letsencrypt

This defines an ACL to recognize LetsEncrypt domain validation requests, and points any such requests to a dedicated backend.

The certbot validation server which will be spawned automatically by certbot during the certificate creation / renewal process when it is used in standalone mode listens on port 54321, and that’s exactly where we point our letsencrypt backend to.

Install private key and CSR

Create a /home/certbot/certs/yoursite directory and place yoursite.key and yoursite.csr inside. Ensure proper permissions and file ownerships:

  • the key is to be kept private must be owned by root and also only be readable by root (chmod 0400 yoursite.key)
  • the csr can be world readable, it’s not confidential. At the very least it has to be readable by the certbot user.

Run certbot!

Yay! By now we should be all set and ready to try out the validation process. For testing it is advisable to use certbot with the staging argument to prevent using up your daily quota of Let’s Encrypt certificates per domain.

Run this as the certbot user:

certbot certonly --staging --csr yoursite.csr --cert-path yoursite.crt --chain-path chain.pem --fullchain-path fullchain.pem

You should end up with a certificate and the two chain files as specified in the command line options. These are not really usable as they are only signed by the Let’s Encrypt staging environment, but we now know that our HAProxy setup and certbot config work. The fullchain file is what we’ll use later on with HAProxy, since it includes both the Let’s Encrypt intermediate certificate and the certificate for

Automate All the Things!

With certonly mode and the --csr option it doesn’t make a difference to certbot wether you are creating a new certificate or renewing an existing one. Therefore I also only have one shell script for both tasks. You can have a look at it below. There’s also a Gist



# attempt to renew when less then 30 days remaining

# returns 0 if no renewal needed, 1 otherwise
check_cert_still_valid () {
  local cert_file=$1

  local exp=$(date -d "`openssl x509 -in $cert_file -text -noout|grep "Not After"|cut -c 25-`" +%s)
  local datenow=$(date -d "now" +%s)
  local days_exp=$(echo \( $exp - $datenow \) / 86400 |bc)

  echo "Checking expiration date for $domain..."

  if [ "$days_exp" -gt "$exp_limit" ] ; then
    echo "The certificate is up to date, no need for renewal ($days_exp days left)."
    return 0
    echo "The certificate $cert_file is about to expire soon. Starting renewal..."
    return 1

get_certificate() {
  local domain=$1
  local cert_file=$2
  local key_file="/home/certbot/certs/${domain}/${domain}.key"
  local csr_file="/home/certbot/certs/${domain}/${domain}.csr"
  local chain_file="/home/certbot/certs/$domain/chain.pem"
  local fullchain_file="/home/certbot/certs/$domain/fullchain.pem"

  # Certbot refuses to overwrite existing files, so remove anything that
  # might get in the way.
  # The certificate used by haproxy is kept separately, so no harm is done by
  # deleting these files:
  rm -f $cert_file $chain_file $fullchain_file
  certbot certonly --csr $csr_file --cert-path $cert_file --chain-path $chain_file --fullchain-path $fullchain_file

  if [ $? -eq 0 ]; then
    echo "Creating $combined_file with latest certs..."
    sudo /usr/local/sbin/le-haproxy-bundle $domain
    echo "Renewal process finished for domain $domain"
    echo "certbot failed, not replacing installed certificate for ${domain}"

process_certificate() {
  local domain=$1
  local cert_file="/home/certbot/certs/$domain/${domain}.crt"

  if [ -f $cert_file ]; then
    check_cert_still_valid $cert_file
    if [ ! $? -eq 0 ]; then
      get_certificate $domain $cert_file
    echo "No certificate for domain $domain exists yet. Creating one..."
    get_certificate $domain $cert_file


# loop over all domains unless a domain is given
if [ "${domain}" = "" ]; then
  for i in $( ls /home/certbot/certs ); do
    process_certificate $i
  process_certificate $domain

if [ "$reload_required" = true ]; then
  echo "Reloading haproxy"
  sudo /usr/sbin/service haproxy reload

If called without arguments, this script will loop over the directories in /home/certbot/certs and either create a new certificate or renew an existing one if it’s about to expire in the next 30 days. Since the script only acts on certificates that will expire in the next 30 days, it can be called as often as you want. Once or twice daily through cron should be enough, however. For manual operation you can also call it with one directory name as an argument, i.e. /usr/local/sbin/le-renew-haproxy yoursite.

Before this will work there are two more things to do, however.

Make the certificate usable for HAProxy

The task of taking the generated certificate and combining it with the custom dhparam and private key file to the certificate bundle to be used by HAProxy is carried out by another script, which is called through sudo by le-renew-haproxy:



if [ -f $fullchain_file -a -f $key -a -f $dhparam ]; then
  if [ -f $combined_file ]; then
    cp $combined_file "${combined_file}.old"
  cat $fullchain_file $key $dhparam > $combined_file
  chmod 0400 $combined_file

Setup sudo permissions for the certbot user

For things to work out non-interactively, you have to give the certbot user permission to use passwordless sudo for these two commands:

  • /usr/local/sbin/le-haproxy-bundle yoursite to build and install the certificate bundle to /etc/haproxy/ssl
  • /usr/sbin/service haproxy reload to enable reloading of haproxy


There might be typos in the scripts / I might have entirely forgotten something. This article is written after the fact, and my server is provisioned with chef so I had to reconstruct / amend / generalize things. Please let me know of any oversights in the comments.

Be careful with HPKP. Use it in report-only mode and/or with short lifetimes until you’re sure you got it right.

Prior Art and Further Reading

The Certbot Documentation

The Let’s Encrypt Website

The idea to use standalone mode through a HAProxy backend and the initial version of my renewal shell script stem from this Gist.

Scott Helmes introductory article on Let’s Encrypt suggests to use a customized OpenSSL config file for each certificate, which eases CSR generation a lot.

Check out the haproxy-ocsp-stapling-updater for setting up OCSP stapling with HAProxy.

Another howto on Using haproxy with Let’s Encrypt

My Gist with all config files and scripts

And finally

Consider donating to the EFF to support their awesome work.