Same structure as the bitmessage and bitcoind repositories. The notes carry what the scripts cannot say for themselves: that the first certificate is a self-signed placeholder and stays one if issuance fails, that a refused installation is reported but cannot be repaired, that the scripts are sourced rather than executed so working directory and set -e are inherited, and that a stop during issuance can outlast docker's grace period.
Intro
Русская версия: README.ru-RU.md
Certbot is the EFF client for Let's Encrypt: it obtains and renews free TLS certificates.
Certbot running in a docker container as a renewal loop, paired with the haproxy container it keeps supplied with a certificate.
This repository covers the docker deployment only.
Usage
The container is not a one-shot command. Its default command is a loop that runs the renewal script, sleeps 12 hours, and repeats — so it obtains the certificate on its first start and keeps it fresh from then on.
Each pass does four things, one script apiece:
| Script | What it does |
|---|---|
entrypoint.sh |
The loop itself, and PID 1 of the container. Runs a pass, sleeps 12 hours, repeats; a failed pass is reported and retried rather than ending the loop |
1-renew-cert.sh |
The start of a pass. Renews an existing certificate, or hands over to 0-create-cert.sh when there is none yet |
0-create-cert.sh |
First run: writes a self-signed placeholder so HAProxy can bind 443, waits for HAProxy, then requests the real certificate |
2-concatenate-cert.sh |
Joins fullchain.pem and privkey.pem into the single site.pem HAProxy expects |
3-update-haproxy-cert.sh |
Installs site.pem into the running HAProxy over its runtime API — no restart, no dropped connections |
Validation is HTTP-01 on port 380. Certbot's own standalone server listens
there inside the container, and HAProxy forwards /.well-known/acme-challenge/
to it from the public port 80. Nothing else may reach 380.
Let's Encrypt requires the domain's public A/AAAA DNS records to point at this machine, and port 80 to be reachable from the internet.
docker-compose
services:
certbot:
build:
context: https://git.bitdeals.org/private/certbot.git
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/certbot
restart: unless-stopped
environment:
- CERTBOT_DOMAIN=example.org
- CERTBOT_EMAIL=admin@example.org # optional, for expiry notices
volumes:
- certificates:/etc/certificates # shared with haproxy
- letsencrypt:/etc/letsencrypt # account key, certificates, renewal config
- letsencrypt_work:/var/lib/letsencrypt
volumes:
certificates:
letsencrypt:
letsencrypt_work:
certificates is the same volume HAProxy mounts read-only; this container is
the one that writes it.
docker cli
docker run -d \
-e CERTBOT_DOMAIN=example.org \
-v certificates:/etc/certificates \
-v letsencrypt:/etc/letsencrypt \
-v letsencrypt_work:/var/lib/letsencrypt \
registry.bitdeals.org/certbot
The renewal loop is the image's CMD, and the base image's certbot entrypoint
is reset, so anything after the image name replaces the loop outright — a one-off
command against the same state needs no --entrypoint:
docker run --rm \
-v letsencrypt:/etc/letsencrypt \
-v letsencrypt_work:/var/lib/letsencrypt \
registry.bitdeals.org/certbot certbot certificates
build and publish
A push to main builds and publishes the image
(.gitea/workflows/build.yaml), tagging it three ways: <version>.<sha7> to
deploy by, <version> to read, and latest for compose and Watchtower. A
weekly cron rebuilds from the same sources. By hand, when the registry
credentials are at hand:
docker build . --file docker/Dockerfile --tag registry.bitdeals.org/certbot
docker push registry.bitdeals.org/certbot
The build context is the repository root, not docker/: the Dockerfile
copies ./docker/scripts/, so a context of ./docker cannot see it and the
build fails on the COPY.
Parameters
Container images are configured using parameters passed at runtime.
| Parameter | Function |
|---|---|
| -e CERTBOT_DOMAIN | The domain to certify. Default: empty — no certificate is requested and the site keeps the self-signed placeholder, silently. One domain only; the scripts pass a single -d |
| -e CERTBOT_EMAIL | Address for Let's Encrypt expiry notices. Default: empty, which registers with --register-unsafely-without-email and leaves you without warnings — see Notes |
| -v /etc/certificates | Shared with HAProxy. Holds site.pem: the concatenated certificate and private key HAProxy binds to |
| -v /etc/letsencrypt | Certbot's config directory: the ACME account key, the issued certificates and the renewal configuration. Losing it means re-registering and re-issuing |
| -v /var/lib/letsencrypt | Certbot's work directory. The base image declares it a VOLUME, so leaving it unnamed creates a fresh anonymous volume on every container creation |
| -p 380 | ACME HTTP-01 challenge port. Internal: HAProxy proxies to it. Publishing it to the host is not needed and not wanted |
Notes
- Without an email address there are no expiry warnings. An empty
CERTBOT_EMAILregisters with--register-unsafely-without-email: if renewal starts failing, nothing tells you until the certificate expires. Monitor the certificate externally, or set the variable. - The first certificate is self-signed, and browsers will say so. HAProxy
cannot start without
site.pem, so0-create-cert.shwrites a placeholder before doing anything else. It is replaced as soon as the real certificate is issued — but if issuance fails, the placeholder is what the site keeps serving, with no error anywhere but the container log. - The private key reaches HAProxy over a unix socket, not the network.
3-update-haproxy-cert.shpipes the whole ofsite.peminto HAProxy's runtime API — an unauthenticatedlevel adminchannel — so the volume holdingadmin.sockmust be shared with haproxy and with nothing else. A TCP port would have been reachable by every container on a shared network, and docker networks have no per-port rules. See the runtime-API note in the haproxy README. - A refused installation is reported, not silently passed over. The runtime
API answers a refusal in the reply text and still closes cleanly, so socat's
exit status says nothing;
3-update-haproxy-cert.shmatches the replies toset ssl certandcommit ssl certinstead, and stops at the first one that is not an acknowledgement. What it cannot do is repair anything —site.pemon the volume is correct either way, so a refusal means the running HAProxy is still on the previous certificate until it restarts. The message says so. - Renewal is pushed on every pass, not on renewal. The script concatenates
and re-installs whether or not
certbot renewactually did anything, twice a day. Harmless, but it means the "certificate updated" path is exercised constantly and a genuine renewal looks like every other pass. Certbot's own--deploy-hookis the mechanism built for this. site.pemis always replaced by an atomic rename, on both the placeholder and the renewal path. HAProxy reads that file at start-up, and a plain redirect into it leaves a window in which the file on the volume is a truncated PEM — which is a certificate HAProxy refuses to start with.- A stop during issuance can outlast docker's grace period. A POSIX shell
runs a trap only once the foreground command returns, so a SIGTERM arriving
while
certbot certonlyis talking to Let's Encrypt is held until that call finishes — past the 10 secondsdocker stopallows by default, after which the container is killed mid-issuance. Nothing is corrupted (the state under/etc/letsencryptsurvives and the next pass finishes the job), but setstop_grace_period: 60son the service if a clean stop matters. The sleep between passes is interruptible and reacts in milliseconds. - The scripts are sourced, not executed (
.rather than a subprocess), so the working directory,set -eand any variable one of them leaves behind is inherited by the next. That is why they address files absolutely and keepcdandumaskinside subshells; keep new ones to the same rule. - The container runs as root, as the base image does — it needs to write
/etc/letsencrypt. Nothing here drops privileges afterwards. - The base image is unpinned.
FROM certbot/certbot:latest, rebuilt weekly by cron, means a new certbot release reaches the registry — and through Watchtower, production — without anyone triggering a build. Pin a version tag for reproducible builds. - Let's Encrypt enforces rate limits. Repeated failed issuance against the
same domain counts against them; test changes against
--server https://acme-staging-v02.api.letsencrypt.org/directorybefore letting a loop retry every 12 hours.