Files
certbot/README.md
T
bitdeals e8204bbb4d fix: the certificate follows CERTBOT_DOMAIN, on every pass
A pass branched on whether /etc/letsencrypt/live/<first-name> existed:
present, it ran `certbot renew`; absent, it handed over to the creation
script. Only the second path was ever told the names to ask for, so
after the first issuance CERTBOT_DOMAIN stopped reaching certbot
altogether -- renew takes the names off the certificate it already holds.
Editing the variable did nothing, silently, and the README documented the
manual issuance needed to work around it.

The branch is gone. Every pass now runs certonly with the names spelled
out, and certbot decides what that means:

  same names, not due    -> "Certificate not yet due for renewal; no
                            action taken", no connection opened, nothing
                            spent against the rate limits
  name added or removed  -> reissued with exactly the requested set
  due for renewal        -> renewed

--keep-until-expiring is what makes the first line true, and --cert-name
(already passed) is what keeps a changed list updating the existing
lineage instead of starting a second one beside it. No --expand is
needed to add a name once the lineage is named.

All three decisions were checked against the live stands: the no-op one
with a real run on testnet2, the other two as dry runs on testnet1 and
testnet2.

With the branch removed, 1-renew-cert.sh is a pass-through and the
numbering finally matches the order things run in -- 0-create-cert.sh
used to execute after 1-renew-cert.sh. So: 1-renew-cert.sh deleted,
0-create-cert.sh renamed to 1-ensure-cert.sh, which is what it now does.

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

177 lines
9.4 KiB
Markdown

# Intro
> Русская версия: [README.ru-RU.md](README.ru-RU.md)
[Certbot](https://certbot.eff.org/) is the EFF client for [Let's Encrypt](https://letsencrypt.org/): it obtains and renews free TLS certificates.
Certbot running in a docker container as a renewal loop, paired with the [haproxy](https://git.bitdeals.org/private/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
```yaml
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
```sh
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`:
```sh
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:
```sh
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](https://git.bitdeals.org/private/haproxy).
- **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.