HAProxy and Certbot on Docker

HAProxy and Certbot running in Docker containers to provide TLS secured frontends for your web applications.

A while ago I wrote a post about running HAProxy on Docker, where the goal was to set up HAProxy in a Docker container so that it could provide frontends for requests and use Docker containers as backends.

The goal this time is to involve Let’s Encrypt/Certbot to generate and provide TLS certificates on the fly, so that HAProxy frontends are automatically equipped with corresponding certificates, avoiding separate server configurations for each interested container. The containers themself shall be in a Docker-internal network that is solely accessible through the HAProxy container.

Abstract

The client sends a request to the server where it is handled by an application delivery controller (ADC). The ADC interprets the request and forwards it accordingly. If it is a request with transport layer security (TLS) the ADC handles the protocol as well.

Certificates are needed to handle requests with TLS. These certificates are provided by a certificate authority (CA) and are subject to periodic validity checks and renewals. A certificate tool facilitates validity checks and renewals.

High level view of the solution
  • client – the entity that creates a request (e.g. a browser, API client)
  • request – a request following the hypertext transport protocol (HTTP)
  • server – a computer (e.g. cloud server) that hosts applications and is accessible over a network
  • application delivery controller (ADC) – an application that decides which application is provided after a request (i.e. HAProxy)
  • transport layer security (TLS) – cryptographic protocol providing communication security over a network
  • certificate – a document that verifies your identity
  • certificate authority (CA) – widely recognized and accepted entity that issues certificates (i.e. Let’s Encrypt)
  • certificate tool – obtains new certificates from the CA, checks validity of certificates and renews expired ones (i.e. Let’s Encrypt Certbot)

The plan

Basically, this project will consist and/or make use of the following Docker features:

  • container: HAProxy (Docker image)
  • container: Certbot (Docker image)
  • network: default network (bridge)
  • network: internal network
  • volume: shared volume with certificates
  • internal Docker DNS server

The two containers will share a volume that holds the certificates. Certbot will check the validity of the certificates according to Let’s Encrypt’s guidelines and – if necessary – update the certificates. The certificates, provided by Certbot in the form of .pem files, will then be concatenated and stored on the shared volume. HAProxy then reloads the certificate from the shared volume.
In this setup, the HAProxy and Certbot containers are the only ones publicly accessible (default bridge network). Application containers are bound to the internal network only, and accessible exclusively via the HAProxy container.

First of all, this solution needs to work with docker-compose, not only because it’s cleaner than my usual deploy.sh shell scripts, but to also support my occasional laziness. So I’m setting up a .yml file, according to the documentation, where I define the two services, the shared volume and setup the networks. Everything else shall be done by scripts.

HAProxy

Service one is HAProxy, which is based on the original image. The container exposes three ports – HTTP (:80), HTTPS (:443), and the HAProxy runtime API (:9999) – that can be mapped to any host-side ports, as long as the requests then point to the correct ones (e.g. port-forwarding on a router). This is also useful for cases in which other services are already using said ports, such as a custom DNS (e.g. PiHole). Additionally, the container must be part of two networks:

  • the default bridge network, so that it is accessible from host-side
  • the custom network I called “hapnet”, that was set up in the previous blog post

Furthermore it gets two mounts, one’s the configuration file straight from host (for easy-access), the other one’s the shared volume. Since HAProxy doesn’t need to modify anything in any of those files, they are read-only (:ro).

haproxy:
  image: haproxy:latest
  container_name: "haproxy"
  hostname: "haproxy"
  restart: always
  volumes:
    - ./cfg/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
    - certificates:/usr/local/etc/haproxy/certificates:ro
  networks:
    - default
    - hapnet
  ports:
    - "80:80"
    - "443:443"
    - "9999"

The HAProxy configuration needs to be updated so that the runtime API is accessible (line 4) and so that HAProxy handles the requests for the acme-challenges as well. These are passed along to the Certbot container itself (line 33).

global

	# Enable HAProxy runtime API
	stats socket :9999 level admin expose-fd listeners

frontend http
	bind :80
	http-request redirect scheme https unless { ssl_fc }
	
	# ACL
	acl certbot path_beg /.well-known/acme-challenge/

	# Backends
	use_backend certbot if certbot

# HTTPS frontend
frontend https
	bind :443 ssl crt /usr/local/etc/haproxy/certificates/
	http-request add-header X-Forwarded-Proto https

	# ACL
	acl certbot path_beg /.well-known/acme-challenge/

	# Backends
	use_backend certbot if certbot

# Docker resolver
resolvers docker
	nameserver dns1 127.0.0.11:53

# Certbot backend
backend certbot
	server certbot certbot:380

Certbot

Service two is going to be a slightly customized Certbot. The only changes are the installation of socat and a bunch of additional shell scripts, that are responsible for creating/renewing certificates and generating HAProxy-friendly .pem files. The image exposes port :380 which is used in both the HAProxy configuration as well as in the create and renew scripts.

FROM certbot/certbot

# Install socat
RUN apk update && apk add socat

# Copy scripts
COPY ./scripts/ /etc/scripts/

# Expose port 380
EXPOSE 380
  • create-certificates.sh – requests a new certificate and invokes the certificate concatenation script.
  • renew-certificates.sh – checks whether the certificate exists or not. If it does, certbot is instructed to renew it. That’ll check if it’s still valid and generate a new certificate – in case it’s not – and concatenate the generated files to a single .pem file.
  • concatenate-certificates.sh – the creation of HAProxy’s .pem file is straightforward. Certbot’s fullchain.pem and privkey.pem files are concatenated and saved to the certificate directory in the shared volume.
  • update-haproxy-certificates.sh – triggers HAProxy Dynamic SSL Certificate Storage (since version 2.1).
#!/bin/bash

# Request certificates
certbot certonly --standalone \
  --non-interactive --agree-tos --email info@davole.com --http-01-port=380 \
  --cert-name davole.com \
  -d davole.com

# Concatenate certificates
. /etc/scripts/concatenate-certificates.sh

# Update certificates in HAProxy
. /etc/scripts/update-haproxy-certificates.sh
#!/bin/bash

# Certificates exist
if [ -d /etc/letsencrypt/live/davole.com ]; then
  # Check certificates and renew them
  certbot renew --http-01-port=380

  # Concatenate certificates
  . /etc/scripts/concatenate-certificates.sh

  # Update certificates in HAProxy
  . /etc/scripts/update-haproxy-certificates.sh

# Certificates don't exist
else
  #  Execute certificate creation script
  . /etc/scripts/create-certificates.sh
fi
#!/bin/bash

if [ -f /etc/letsencrypt/live/davole.com/fullchain.pem -a -f /etc/letsencrypt/live/davole.com/privkey.pem ]; then
  cat /etc/letsencrypt/live/davole.com/fullchain.pem /etc/letsencrypt/live/davole.com/privkey.pem > /etc/certificates/davole.com.pem
fi
#!/bin/bash

# Start transaction
echo -e "set ssl cert /usr/local/etc/haproxy/certificates/davole.com.pem <<\n$(cat /etc/certificates/davole.com.pem)\n" | socat tcp-connect:haproxy:9999 -

# Commit transaction
echo "commit ssl cert /usr/local/etc/haproxy/certificates/davole.com.pem" | socat tcp-connect:haproxy:9999 -

# Show certification info (not essential)
echo "show ssl cert /usr/local/etc/haproxy/certificates/davole.com.pem" | socat tcp-connect:haproxy:9999 -

Now that the Certbot image is in place too, it’s time to add the service to the .yml file. This service will build the Docker image ad-hoc when docker-compose is run with the --build option. The entrypoint (line 6) specification will run the renew-certificates.sh script every 12 hours, as specified by Let’s Encrypt

certbot:
  build: .
  image: davole/certbot:latest
  container_name: "certbot"
  hostname: "certbot"
  entrypoint: "/bin/sh -c 'trap exit TERM; while :; do . /etc/scripts/renew-certificates.sh ; sleep 12h & wait $${!}; done;'"
  restart: always
  volumes:
    - certificates:/etc/certificates
  ports:
    - "380:380"
  networks:
    - default
    - hapnet

What remains to be done is to put the two services together, and add network and volume configuration. Since I’ve already set up both the shared volume and the network, I do not need to do that again.

version: "3.7"

services:
  haproxy: [...]
  certbot: [...]

networks:
  hapnet:
    name: hapnet
    external: true

volumes:
  certificates:
    external: true

Conclusion

In this post I demonstrated how it is possible to make use of Let’s Encrypt’s certificates and Certbot in combination with HAProxy on Docker. You saw how it’s possible to configure HAProxy and Certbot to work together in a dockerized environment to serve web-applications through one frontend. All that while TLS is handled automatically without the need to update your certificates every 90 days.

Once again kudos to my HAProxy guy for the occasional nudge in the right direction.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.