docs: add README in English and Russian
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.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# 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
|
||||
- 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 \
|
||||
-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. 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](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.
|
||||
- **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.
|
||||
Reference in New Issue
Block a user