bitdeals 9dfa45f901
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 1m1s
ci: pass the registry password on stdin, fail on a failed push
A password in argv is world-readable through /proc/PID/cmdline while the
command runs; docker warns about it for that reason. The two pushes are joined
with && so the step's outcome is explicit rather than resting on whatever -e
flag the runner's shell happens to carry.
2026-08-07 13:39:55 +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 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_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 0-create-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 renew 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.
  • 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
79 KiB
Languages
Shell 91%
Dockerfile 9%