Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 30s
certbot's own -d already takes a comma-separated list and puts every name
in the certificate's Subject Alternative Names, so `-d "$CERTBOT_DOMAIN"`
needed no change at all. What assumed a single domain was everything that
treated the variable as a *file name*: --cert-name, and the two scripts
that address the lineage under /etc/letsencrypt/live. Certbot names a
lineage after the first domain of the list, so all three now take
${CERTBOT_DOMAIN%%,*} instead of the whole string -- otherwise a two-domain
value asks for a lineage literally called "a.org,b.org" and the renewal
pass looks for a directory nobody made.
One certificate with several names rather than several lineages: HAProxy
binds a single site.pem, and a second lineage would have nowhere to go.
Adding a domain to CERTBOT_DOMAIN on a machine that already holds a
certificate still needs one manual issuance -- `certbot renew` reads the
names off the certificate it has and never looks at the variable. The
README says so, and gives the command.
174 lines
9.2 KiB
Markdown
174 lines
9.2 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
|
|
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
|
|
|
|
```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 `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](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 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.
|
|
- **Adding a domain to `CERTBOT_DOMAIN` does not reissue by itself.** The loop
|
|
calls `certbot renew`, which takes the names from the certificate it already
|
|
holds and never reads `CERTBOT_DOMAIN`. On a machine that has one, issue the
|
|
new set once by hand and the loop keeps it from then on:
|
|
`certbot certonly --standalone -n --agree-tos --http-01-port=380 --cert-name <first-domain> --expand -d <the,new,list>`.
|
|
- **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.
|