HAProxy and Certbot

Combine HAProxy and Certbot on the same container host allowing Certbot to keep certificates updated and HAProxy to proxy all traffic. HAProxy will redirect insecure requests to secure ports except for Certbot’s proof-of-ownership requests. Those are proxied to Certbot.

This example uses Docker.

Deployment

Test Resources

Let’s create a private docker network for our containers to use. That will let them connect dynamically to each other by container name so we can avoid hard-coding IPs.

docker network create -d bridge proxy-nw

It’s also useful to have a local web server to test against. Throw up a darkhttpd web server (a very simple web server) on the same container host, redirecting a high-level port on the container host to it’s port 80.

mkdir /tmp/www
echo "Look ma, http" > /tmp/www/index.html

docker run --name darkhttpd --net=proxy-nw --detach --volume /tmp/www:/www --publish 8008:80 shelmangroup/darkhttpd

curl localhost:8008

If everything went as planned you should get the expected output and docker logs darkhttpd will show the web request.

HAProxy

The creators of HAProxy suggest avoiding local dependencies by adding config and certificate files to the official image to create a custom image. For our tests however, we’ll start with a local config as we need it to work with a local Certbot instance.

The first step is to create volumes to hold the local configs; one for HAProxy and one for Certbot. The docker image provided by HAProxy defaults to /usr/local/etc/haproxy/haproxy.cfg. Certbot defaults to /etc/letsencrypt. The cert itself is in a subdirectory under ’live’ named for the site, such as live/server.your.org, but you need all of the subdirectories to persist or renewals won’t work right. We’ll present to the containers in the run command below.

docker volume create haproxy
docker volume create certbot

# Check the location just in case
docker volume inspect haproxy

Next, create a config file for HAProxy to specifies the darkhttpd server as a back-end server. Notice that darkhttpd itself is listening on port 80. Port 8008 above is the translated-port we published on the container host for testing and not what you want HAProxy to connect to.

sudo vi /var/lib/docker/volumes/haproxy/_data/haproxy.cfg

global
    maxconn 4000
    tune.ssl.default-dh-param 2048
 
frontend http
    bind *:80
    mode http
 
    default_backend http-server
    timeout client 1h
 
backend http-server
    mode http
    server darkhttpd darkhttpd:80
    timeout connect 1h
    timeout server 1h

Now lets run the HAProxy image.

docker run \
--detach \
--mount type=volume,source=haproxy,target=/usr/local/etc/haproxy,readonly \
--mount type=volume,source=certbot,target=/etc/letsencrypt/,readonly \
--name haproxy \
--net=proxy-nw \
--publish 80:80 \
--publish 443:443 \
--restart always \
haproxy

curl localhost

If all went well, you can now access the host on port 80 and hit the darkhttpd server. Importantly, you must be able to hit this from the Internet, as Let’s Encrypt will be hitting it to prove you own the host in question. Issue a docker ps and docker logs haproxy if something failed. Once you’re done testing, stop the containers and remove the unneeded darkhttpd server.

docker stop haproxy
docker stop darkhttpd; docker rm darkhttpd; docker rmi shelmangroup/darkhttpd

Certbot

The official image is designed to run-and-exit, requiring the container OS to have a cron job to manage renewals. Our goal is for Certbot manage the certificate without external dependencies, and to be protected from general internet traffic by HAProxy. So let’s create our own image based on theirs.

Docker Image Set-Up

# Make a directory
mkdir ~/docker-certbot
cd ~/docker-certbot

# Create the Docker file and a script that will renew the cert
touch Dockerfile
touch cert_request
chmod +x cert_request

Dockerfile

Create the following Docker file. It uses Certbot’s image as a base, places a cert request script and launches the cron daemon in the foreground (crond doesn’t run by default). With crond running this way you can examine the output easily with a docker log command.

vi Dockerfile

FROM certbot/certbot
COPY cert_request /etc/periodic/daily
ENTRYPOINT ["crond", "-f"]

Renewal Script

Below is the content of the script. The COPY above puts it in a location where crond will run it once a day. Let’s Encrypt actually recommends twice a day but this is close enough. We also concatenate the public and private key into a single file as required by HAProxy.

The reason we’re going to all this trouble is that issued certs are valid for 90 days. You have to check often.

TODO We could probably do something slick here and not recreate that file if we spent some time thinking about it. We could also use –post-hook to tell haproxy something has changed or at least see if that’s needed.

#!/bin/sh

HOST=server.your.org 
EMAIL=[email protected]

certbot certonly $1 --standalone -d $HOST --non-interactive --preferred-challenges http --agree-tos --email $EMAIL

cat /etc/letsencrypt/live/$HOST/fullchain.pem /etc/letsencrypt/live/$HOST/privkey.pem > /etc/letsencrypt/live/$HOST/$HOST.pem

Building and Testing the Image

Let’s build the image and publish port 80. We won’t want that published later on but it’s useful for a first test so we know Certbot itself doesn’t have any issues.

docker build -t allen/certbot .

docker run --name certbot --detach --publish 80:80 --mount type=volume,source=certbot,target=/etc/letsencrypt allen/certbot

Let’s run the first request manually against Let’s Encrypt’s test system to make sure it works as expected.

# Attach to the running container
docker exec -it certbot sh

# Execute the request script aginst let's encrypt's test system
/etc/periodic/daily/cert_request --staging

You should see output similar to:

  • Congratulations! Your certificate and chain have been saved at: /etc/letsencrypt/live/server.your.org/fullchain.pem

If not, start digging into if your web server is reachable or the host name is wrong. Importantly, add –staging to the request while you are debugging. The production Let’s Encrypt service is rate-limited.

Integrating HAProxy and Certbot

Now that you know Certbot works, recreate it without an external port (this is easier than a restart). We’ll use HAProxy to route requests to it. Note: now that it has a certificate, certbot will execute a renew operation which no longer requires the verification of accepting port 80 challenges. The private key it already has is used. But you still want to catch requests as any new certs you add later will require the verification.

docker stop certbot
docker rm certbot
docker run --name certbot --detach --net=proxy-nw --mount type=volume,source=certbot,target=/etc/letsencrypt --restart always allen/certbot

The HAProxy config file needs an update, both to handle reqeusts to Certbot and to take advantage of the certificate we got earlier.

sudo vi /var/lib/docker/volumes/haproxy/_data/haproxy.cfg

global
    tune.ssl.default-dh-param 2048

defaults
    timeout client 1h
    timeout connect 1h
    timeout server 1h

frontend http
    bind *:80
    mode http

    acl certbot-acl path_beg /.well-known/acme-challenge/
    http-request redirect scheme https if ! certbot-acl
    use_backend certbot-backend if certbot-acl

frontend https
    mode http
    bind *:443 ssl crt /etc/letsencrypt/live/wiki.your.org/wiki.your.org.pem

backend certbot-backend
    mode http
    server certbot certbot:80




docker stop haproxy

docker start haproxy

Let’s do some basic tests

# Test that non-secure requests are redirected with a 302

curl -D -  http://server.your.org/xyz

  HTTP/1.1 302 Found
  Cache-Control: no-cache
  Content-length: 0
  Location: https://wiki.your.org/xyz

# Test that non-secure requests to acme-challenge are not

curl http://server.your.org/.well-known/acme-challenge/

  <html><body><h1>503 Service Unavailable</h1>
  No server is available to handle this request.
  </body></html>

# Take a look at the certificate we have

openssl s_client -showcerts -connect localhost:443

  ...
  ...
  Verify return code: 20 (unable to get local issuer certificate)

These are as expected. Certbot isn’t isn’t in the middle of a request so nothing is listening. The cert displayed is from the testing server and so untrusted.

Now, let’s try requesting a cert from the production server and assuming it shows you success, restart HAProxy to use the new cert.

docker exec -it certbot sh

/etc/periodic/daily/cert_request --force-renew

exit

docker stop haproxy

docker start haproxy

openssl s_client -showcerts -connect localhost:443

  ...
  ...
  Verify return code: 0 (ok)  

Now that you have a working proxy, you’ll probably want to proxy some things other than certbot. Let’s add an example xwiki running on it’s own network.

docker network connect xwiki-nw haproxy

# To the haproxy conf file, add 

...
...
frontend https
    mode http
    bind *:443 ssl crt /etc/letsencrypt/live/wiki.your.org/wiki.your.org.pem

    acl is_wiki hdr(host) -i wiki.your.org
    use_backend xwiki if is_wiki

backend xwiki
    mode http
    server xwiki xwiki:8080
...
...

docker stop haproxy
docker start haproxy

And enjoy!

Troubleshooting

Container exits immediately

Check the logs with docker logs haproxy to see what’s up.

‘server certbot’ : could not resolve address ‘certbot’…Failed to initialize server(s) addr.

HAProxy wont start if it can’t connect to the back-end server. Adding the init-addr option accomodates that.

cat: can’t open ‘/etc/letsencrypt/live/server.your.org/fullchain.pem**

Certbot can start saving certs under -0001 and such when you start requesting certs that it already knows about but can’t find. You can delete the /etc/letsencrypt/* dir

Moving a Custom Container

docker save allen/certbot >test.tar scp some.server:test.tar . docker load -i test.tar

To Remove old containers and images docker ps -a docker rm … docker images docker rmi … docker system prune –all docker image prune –all


Other Resources

https://www.lab-time.it/2018/09/20/running-haproxy-and-lets-encrypt-on-docker/ https://docs.docker.com/storage/volumes/ https://www.haproxy.com/blog/the-four-essential-sections-of-an-haproxy-configuration/ https://certbot.eff.org/docs/install.html https://wiki.alpinelinux.org/wiki/Alpine_Linux:FAQ#My_cron_jobs_don.27t_run.3F https://gist.github.com/andyshinn/3ae01fa13cb64c9d36e7


Last modified April 14, 2026: Old site imports (677647f)