Using acme.sh for SSL certificates with Hetzner's DNS, HAProxy and Docker
Renewing certificates for private services gets awkward when HTTP validation is not an option. My HAProxy container fronts a few services on a single Docker host that is only reachable through my VPN, so I do not want to expose each service just so ACME can validate it.
DNS validation fits that setup. acme.sh can prove domain ownership through
Hetzner's DNS API, deploy a HAProxy-compatible .pem file, and reload the proxy
when a certificate changes.
This walkthrough uses Docker to run acme.sh, stores its state under
/var/lib/acme.sh, writes certificates to a host directory used by HAProxy, and
renews everything from cron.
Setting the certificate authority
By default, acme.sh uses ZeroSSL for requesting a certificate. It comes with
more features but, unlike Let's Encrypt, it's a for-profit organization.
I prefer the independence of Let's Encrypt. Using acme.sh with
Let's Encrypt requires either --set-default-ca --server letsencrypt to
configure it permanently, or passing --server letsencrypt whenever interacting
with the issuer.
In the following walkthrough, I set Let's Encrypt as default certificate issuer, but feel free to omit that if you want to use ZeroSSL instead.
Creating the certificate
Let's configure Let's Encrypt as the default certificate authority:
ACMESH_VERSION=3.0.9
docker run --rm -it \
-v /var/lib/acme.sh:/acme.sh \
neilpang/acme.sh:$ACMESH_VERSION --set-default-ca --server letsencrypt
Using some-service.example.com as an example and the automated DNS mode with Hetzner's DNS
to verify our identity, we can create the certificate with the following code:
SERVICE=some-service
DOMAIN=example.com
export HETZNER_Token="<token>"
docker run --rm -it \
-v /var/lib/acme.sh:/acme.sh \
-e HETZNER_Token \
neilpang/acme.sh:$ACMESH_VERSION --issue --dns dns_hetzner -d "$SERVICE.$DOMAIN"
Remember to set the token to the value that you got from Hetzner's admin interface. When completed,
the certificate is managed by acme.sh. All managed certificates can be listed with
docker run --rm -it \
-v /var/lib/acme.sh:/acme.sh \
neilpang/acme.sh:$ACMESH_VERSION --list
Deploying the certificate
The following command not only executes the deployment, which copies a .pem
file with the key to the /etc/docker-compose/ssl/certs folder, it also stores
the reload command for future renewals.
docker run --rm -it \
-v /var/lib/acme.sh:/acme.sh \
-v /etc/docker-compose/ssl/certs:/tmp \
-v /var/run/docker.sock:/var/run/docker.sock \
-e DEPLOY_HAPROXY_PEM_PATH="/tmp" \
-e DEPLOY_HAPROXY_RELOAD="curl --unix-socket /var/run/docker.sock -X POST 'http://localhost/containers/$SERVICE-hap/kill?signal=HUP'" \
neilpang/acme.sh:$ACMESH_VERSION --deploy --deploy-hook haproxy -d "$SERVICE.$DOMAIN"
(I happen to store my SSL certificates in /etc/docker-compose/ssl/certs
because I manage my services on a single machine, orchestrating them with Docker
Compose. The machine is private and experiences limited traffic, so I refrained
from using more sophisticated orchestration tools. I might publish another
article about this set-up soon.)
The first time the command is run, the reload command will throw an error because the container
hasn't been started yet. This is OK because we're only interested in creating the .pem
file for now.
You might have noticed that -v /var/run/docker.sock:/var/run/docker.sock was
added to this command. This allows the Docker container to talk to the host via
the socket. The reload command used for deployment does exactly that: Sending the
HUP signal to HAProxy will cause it to reload without requiring a restart.
Be aware that mounting the Docker socket gives the container root-equivalent
access to the host. Also make sure that $SERVICE-hap matches the actual HAProxy
container name in your setup.
Now, if you want to see more details about a specific SSL certificate/domain, you can use the
--info argument as follows:
docker run --rm -it \
-v /var/lib/acme.sh:/acme.sh \
neilpang/acme.sh:$ACMESH_VERSION --info -d "$SERVICE.$DOMAIN"
As you can see, this also includes information about how to deploy the certificate, including the
reload command. As mentioned previously, this information is kept by acme.sh and will be used
whenever renewing the certificate is necessary.
Renewing the certificate automatically
The easiest way to renew all certificates automatically is regularly calling the following command
in a crontab or in /etc/periodic/daily:
docker run --rm \
-v /var/lib/acme.sh:/acme.sh \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /etc/docker-compose/ssl/certs:/tmp \
neilpang/acme.sh:$ACMESH_VERSION --cron
The command iterates through all managed certificates and only renews the ones
that need renewal. This makes it more convenient than calling --renew -d ...
for each certificate individually.