bitdeals a842e0771e
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 36s
fix: HAProxy could not read the placeholder, so a failed issuance took the site down
The placeholder is the whole reason a first start works at all: HAProxy
resolves `bind ... ssl crt` while parsing its configuration, so it cannot
start without site.pem, and certbot writes a self-signed one to break
that circle. It wrote it 0600 root-only, under the umask the subshell
sets for the private key it builds it from. HAProxy runs unprivileged
(uid 1001 in the bitnami image), so it could not open the file and exited
-- and because it never came up, it never created the runtime API socket
the pass waits for, and the pass hung in that wait instead of reaching
the 12-hour sleep. A node whose first issuance failed -- a typo in
CERTBOT_DOMAIN is enough -- served nothing at all on 80 or 443, retried
nothing, and said so only in two container logs.

The real certificate escapes this by accident: 2-concatenate-cert.sh
writes it with `cat >` under the default umask, so site.pem is 0644 on
every node that ever issued one. That is why nobody hit this -- it needs
a first issuance that fails.

chmod on the temporary file rather than after the rename, so the atomic
rename stays the only thing HAProxy can observe and site.pem never exists
with a mode that stops it. The intermediates keep the tight umask.

Reproduced on a disposable haproxy+certbot stack with empty volumes and
CERTBOT_DOMAIN=testnet2.bitdeals.invalid: before, HAProxy crash-looped on
"cannot open the file" while certbot waited for admin.sock; after, HAProxy
starts on the placeholder, certbot reports the rejected domain, the loop
retries in 12h, and the site answers on 80 and terminates TLS on 443 with
the self-signed certificate.

Claude-Session: https://claude.ai/code/session_01BvgYcYPWd1KGABLKViVPSk
2026-08-24 13:13:23 +00:00

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 a pass, sleeps 12 hours, and repeats — so it obtains the certificate on its first start and keeps it fresh from then on.

Each pass does three 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-ensure-cert.sh The pass itself. On the first run writes a self-signed placeholder so HAProxy can bind 443; then waits for HAProxy and asks certbot for a certificate carrying exactly the names in CERTBOT_DOMAIN — issuing it, reissuing it when that list changed, or leaving it alone
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,www.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,www.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, or several comma-separated (a.org,b.org) — certbot's own -d grammar, giving one certificate that carries every name as a Subject Alternative Name. The first domain names the certificate under /etc/letsencrypt/live. Default: empty — no certificate is requested and the site keeps the self-signed placeholder, silently
-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_EMAIL registers 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, so 1-ensure-cert.sh writes 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.sh pipes the whole of site.pem into HAProxy's runtime API — an unauthenticated level admin channel — so the volume holding admin.sock must 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.sh matches the replies to set ssl cert and commit ssl cert instead, and stops at the first one that is not an acknowledgement. What it cannot do is repair anything — site.pem on 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 certonly actually 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-hook is the mechanism built for this.
  • site.pem is 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 certonly is talking to Let's Encrypt is held until that call finishes — past the 10 seconds docker stop allows by default, after which the container is killed mid-issuance. Nothing is corrupted (the state under /etc/letsencrypt survives and the next pass finishes the job), but set stop_grace_period: 60s on 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 -e and any variable one of them leaves behind is inherited by the next. That is why they address files absolutely and keep cd and umask inside 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.
  • CERTBOT_DOMAIN is read on every pass, and the certificate follows it. The pass runs certbot certonly --cert-name <first-name> --keep-until-expiring -d "$CERTBOT_DOMAIN", so an added name is on the certificate within 12 hours and no manual issuance is needed. It follows the other way too, which is the sharp edge: removing a name reissues without it, and the site loses TLS under that name on the next pass. Changing the first name is a third case — it names the lineage under /etc/letsencrypt/live, so a new certificate is started beside the old one and the old one stops being renewed.
  • 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/directory before letting a loop retry every 12 hours.
S
Description
certbot dockerfile and scripts for haproxy certificate update
Readme
127 KiB
Languages
Shell 92.7%
Dockerfile 7.3%