# Let's Encrypt Certificate Automation for Spring Boot REST Backends **Reference implementation:** beta rest-web (`beta.web.tasking.rpp.cybercrucible.com`) **Status:** Built, tested, and verified — including fail-closed security testing. --- ## 1. Overview ### What this replaces Previously, backend Spring Boot servers used a long-lived self-signed certificate. nginx pinned that exact cert file via `proxy_ssl_trusted_certificate` and used `proxy_ssl_name "Unknown"` to match its CN. Every cert rotation required touching nginx on every front-end machine, so rotation effectively never happened. ### What this system does Each backend machine issues and renews its own Let's Encrypt certificates using certbot (DNS-01 challenges via Route53), running entirely in Docker alongside the Spring app. Certificates hot-reload into the running JVM with no restart. nginx trusts the public CA root and verifies a shared "pool alias" name, so **nginx configuration never changes again** — adding backends, rotating certs, and promoting environments require zero nginx cert changes. ### How it runs (the closed loop) ``` certbot-renew container (12h loop) → no-op until cert has <30 days left → DNS-01 challenge via Route53 (TXT records, auto-cleaned) → new cert + fresh private key → deploy hook publishes flat PEM files (atomic mv) → shared Docker volume → Spring Boot file watcher swaps the cert live (no restart) → nginx: unaffected (trusts root, verifies alias) ``` Human involvement: zero, unless monitoring alerts. ### Key concepts **The pool alias (e.g. `upstream.beta.web.tasking.rpp.cybercrucible.com`)** — an invented DNS name that exists only inside certificates and in nginx's `proxy_ssl_name`. It never gets an A/AAAA record and nothing ever connects to it. Every backend's cert contains two SANs: its own unique name, plus the shared alias. nginx verifies only the alias, which is why one static `proxy_ssl_name` works for every current and future backend in the pool. Required because `proxy_ssl_name` is one static value per location, while an upstream group has many servers. **Alias naming convention:** `upstream.` — e.g. `upstream.beta.web.tasking...`, `upstream.web.tasking...` (prod), `upstream.agent.tasking...`. Keep aliases distinct per environment so a beta cert can never satisfy prod verification. **DNS-01 challenges** — certbot proves domain control by creating a TXT record at `_acme-challenge.` via the Route53 API. Let's Encrypt never connects to any machine, so backends need no inbound exposure, no port 80, and IPv6-only containers work fine. --- ## 2. One-time company setup (already done — reference only) ### 2.1 Route53 hosted zone ID Route 53 → Hosted zones → `cybercrucible.com` (public zone) → copy the Hosted Zone ID (`Z2RF8XABLU7FAI`). ### 2.2 IAM policy: `acme-dns01-cybercrucible` IAM → Policies → Create policy → JSON. This is the **hardened** version (tested: certbot renewals succeed; A-record and non-challenge TXT writes are denied): ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "ListZones", "Effect": "Allow", "Action": "route53:ListHostedZones", "Resource": "*" }, { "Sid": "GetChangeStatus", "Effect": "Allow", "Action": "route53:GetChange", "Resource": "arn:aws:route53:::change/*" }, { "Sid": "AcmeTxtOnly", "Effect": "Allow", "Action": "route53:ChangeResourceRecordSets", "Resource": "arn:aws:route53:::hostedzone/Z2RF8XABLU7FAI", "Condition": { "ForAllValues:StringLike": { "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ "_acme-challenge.*" ] }, "ForAllValues:StringEquals": { "route53:ChangeResourceRecordSetsRecordTypes": ["TXT"], "route53:ChangeResourceRecordSetsActions": ["CREATE", "UPSERT", "DELETE"] } } } ] } ``` The conditions restrict the key to TXT records named `_acme-challenge.*` only. A leaked key cannot touch A/AAAA/MX/CNAME records or move any traffic — worst case is an attacker can pass DNS-01 challenges (visible in public Certificate Transparency logs). ### 2.3 IAM user IAM → Users → Create user (e.g. `acme-certbot`), **no console access**, attach the policy above, create an access key. Copy both values immediately (the secret shows once). This one credential serves all machines; create per-machine users later if individual revocation is wanted. --- ## 3. Spring Boot application requirements (once per app) ### 3.1 Version requirement Spring Boot **3.2+** required for hot reload (`reload-on-update`). Verified working on Boot 4.1 with Netty/WebFlux; works identically on Tomcat. ### 3.2 One-time war/jar change In the application's `application.properties`, **delete** all server-facing keystore lines: ```properties # DELETE these — Boot refuses to combine them with SSL bundles: server.ssl.key-store=... server.ssl.key-store-password=... server.ssl.key-store-type=... ``` Do **not** add bundle properties to the war — they are injected as environment variables from compose (Section 4.4), keeping TLS config deployment-side. **Leave untouched** any keystore/truststore used for *client* connections (e.g. `rest-mongo-keystore.jks` for MongoDB TLS) — that is a separate concern from the server-facing HTTPS cert. Rebuild the war. --- ## 4. Per-machine stack setup One stack directory per machine. One certificate ("machine cert") covers **all** app containers on that machine — each container's domain plus the shared alias as SANs. ### 4.1 Directory layout ``` /var// ├── compose.yml # merged: app services + certbot services ├── certbot-deploy-hook.sh # publishes flat PEMs (invariant) ├── .env # cert identity — the per-deployment file ├── aws.env # AWS credentials — chmod 600, NEVER in git ├── .gitignore # contains: .env aws.env ├── rest-web.war # the rebuilt application ├── # e.g. rest-mongo-keystore.jks └── start.sh # guard + docker compose up -d ``` > **Lesson learned:** create `.env` and `aws.env` by editing directly (`nano`/`vi`), one `NAME=value` per line, no spaces around `=`. Do not paste heredoc blocks into editors — the commands end up inside the file and break compose parsing. ### 4.2 `.env` ``` CERT_NAME=rest-web-beta PRIMARY_DOMAIN=river.beaver.beta.web.tasking.rpp.cybercrucible.com ALIAS_DOMAIN=upstream.beta.web.tasking.rpp.cybercrucible.com EXTRA_DOMAINS= ACME_EMAIL=ops@cybercrucible.com ``` - `CERT_NAME` — certbot's local lineage name. Unique per stack, stable forever. Never appears in the cert itself. - `PRIMARY_DOMAIN` — this machine's first container's own domain. - `ALIAS_DOMAIN` — the shared pool alias. **Same value across every backend behind the same nginx domain family.** - `EXTRA_DOMAINS` — additional `-d` flags for further containers on this machine, e.g. `EXTRA_DOMAINS=-d green.fox.beta.web.tasking.rpp.cybercrucible.com` (the `-d` is part of the value). ### 4.3 `aws.env` (chmod 600) ``` AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... AWS_DEFAULT_REGION=us-east-1 AWS_USE_DUALSTACK_ENDPOINT=true ``` > **IPv6-only hosts (all our current backend machines):** the `AWS_USE_DUALSTACK_ENDPOINT=true` line is **mandatory** — Route53's default endpoint is IPv4-only and issuance fails with `Network unreachable` without it. Let's Encrypt's API is dual-stacked and needs nothing special. ### 4.4 `certbot-deploy-hook.sh` (chmod +x) ```bash #!/bin/sh set -eu LIVE="/etc/letsencrypt/live/${CERT_NAME}" DEST=/etc/letsencrypt/deployed mkdir -p "$DEST" cp -L "$LIVE/privkey.pem" "$DEST/.privkey.tmp" && chmod 600 "$DEST/.privkey.tmp" cp -L "$LIVE/fullchain.pem" "$DEST/.fullchain.tmp" && chmod 644 "$DEST/.fullchain.tmp" mv -f "$DEST/.privkey.tmp" "$DEST/privkey.pem" mv -f "$DEST/.fullchain.tmp" "$DEST/fullchain.pem" echo "$(date -u +%FT%TZ) published ${CERT_NAME} to $DEST" >> /etc/letsencrypt/deploy.log ``` Why it exists: certbot's `live/` files are symlinks; on renewal only the symlink targets change, which the JVM's file watcher does not see. The hook publishes flat files; the temp-file + `mv` pattern is atomic, so the watcher never reads a half-written PEM. ### 4.5 `compose.yml` The pattern: app services via a shared YAML anchor (so all instances inherit the cert plumbing), plus two certbot services. ```yaml x-restServiceTemplate: &restServiceTemplate image: azul/zulu-openjdk:17 working_dir: /var// volumes: - ./rest-web.war:/var//app.war:ro - ./rest-mongo-keystore.jks:/var//rest-mongo-keystore.jks:ro - certs_data:/certs:ro environment: SERVER_SSL_BUNDLE: acme SPRING_SSL_BUNDLE_PEM_ACME_KEYSTORE_CERTIFICATE: "file:/certs/deployed/fullchain.pem" SPRING_SSL_BUNDLE_PEM_ACME_KEYSTORE_PRIVATE_KEY: "file:/certs/deployed/privkey.pem" SPRING_SSL_BUNDLE_PEM_ACME_RELOAD_ON_UPDATE: "true" command: ['java', '-Djava.net.preferIPv6Addresses=true', '-Djava.net.preferIPv4Stack=false', '-Djava.net.preferIPv6Stack=true', '-Xmx30g', '-jar', '/var//app.war'] restart: unless-stopped depends_on: certbot-init: condition: service_completed_successfully deploy: resources: limits: memory: 30G services: : <<: *restServiceTemplate container_name: networks: public_macvlan_network: ipv6_address: "" # certbot-init: image: certbot/dns-route53 network_mode: host # REQUIRED on IPv6-only hosts env_file: ./aws.env environment: CERT_NAME: ${CERT_NAME} volumes: - certs_data:/etc/letsencrypt - ./certbot-deploy-hook.sh:/etc/letsencrypt/renewal-hooks/deploy/publish.sh:ro entrypoint: /bin/sh -c command: > "certbot certonly --dns-route53 --cert-name ${CERT_NAME} -d ${PRIMARY_DOMAIN} -d ${ALIAS_DOMAIN} ${EXTRA_DOMAINS} --key-type ecdsa --non-interactive --agree-tos -m ${ACME_EMAIL} --keep-until-expiring && CERT_NAME=${CERT_NAME} /etc/letsencrypt/renewal-hooks/deploy/publish.sh" certbot-renew: image: certbot/dns-route53 network_mode: host # REQUIRED on IPv6-only hosts env_file: ./aws.env environment: CERT_NAME: ${CERT_NAME} volumes: - certs_data:/etc/letsencrypt - ./certbot-deploy-hook.sh:/etc/letsencrypt/renewal-hooks/deploy/publish.sh:ro entrypoint: /bin/sh -c command: "'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'" depends_on: certbot-init: condition: service_completed_successfully restart: unless-stopped volumes: certs_data: networks: public_macvlan_network: external: true ``` Design notes: - **certbot-init** runs once per `compose up`: issues the cert if missing/expiring (`--keep-until-expiring` makes it a no-op otherwise), publishes the PEMs, exits. App containers wait for it via `depends_on: service_completed_successfully` — they either start with valid cert material guaranteed on disk, or don't start at all (fail-closed startup). It does NOT re-run on `docker compose restart ` — the PEMs persist in the volume, so restarts are fine. - **certbot-renew** is the scheduler: checks every 12h, renews when <30 days remain, and certbot fires the deploy hook automatically on real renewals. - **`network_mode: host` on both certbot services** — the compose default bridge is IPv4-only; on IPv6-only hosts, certbot gets `Errno 101 Network unreachable` without this. The certbot containers are outbound-only, so host networking adds no exposure. - **Never add `--force-renewal` to the compose command** — LE's duplicate-cert limit is 5/week per identical SAN set; `--keep-until-expiring` keeps every `compose up` free. - **Mount the tls directory, never individual files** — a single-file bind/volume mount pins the inode at container start and silently breaks hot reload after the first renewal. - The AWS credentials go only to the certbot services, never the app containers. ### 4.6 `start.sh` ```bash #!/bin/bash [ -s "$(docker volume inspect _certs_data -f '{{.Mountpoint}}' 2>/dev/null)/deployed/privkey.pem" ] 2>/dev/null || true sudo docker compose up -d ``` (The `depends_on` gate makes an explicit PEM check optional — init guarantees the files before apps start. Keep start.sh as simply `sudo docker compose up -d` if preferred.) --- ## 5. First bring-up ### 5.1 DNS records Create AAAA records in Route53 for each container's own domain → its macvlan IPv6. The alias domain gets **no** record, ever. > **Lesson learned:** create the DNS record **before** any nginx configuration references the name (Section 6), and verify it resolves before reloading nginx. ### 5.2 Staging pass (recommended for new stacks) Temporarily append `--staging` to the certbot-init command, then: ```bash sudo ./start.sh sudo docker compose logs -f certbot-init ``` Expected: account registered → cert requested for all SANs → "Successfully received certificate" → hook publish line → init exits 0 → app containers start. Verify, then remove `--staging` and force one production reissue: ```bash sudo docker compose run --rm certbot-init \ "certbot certonly --dns-route53 --cert-name \ -d -d \ --key-type ecdsa --non-interactive --agree-tos -m \ --force-renewal && CERT_NAME= /etc/letsencrypt/renewal-hooks/deploy/publish.sh" ``` > **Note on `docker compose run` with this compose file:** the service's entrypoint is already `/bin/sh -c`, so pass the whole command as ONE quoted string with no extra `sh -c` — double-wrapping drops you into an interactive shell instead of running the command. ### 5.3 Verify ```bash # Cert content, from inside the container: sudo docker exec sh -c \ "openssl x509 -in /certs/deployed/fullchain.pem -noout -issuer -ext subjectAltName" # Want: issuer O=Let's Encrypt (no STAGING), all SANs listed. # App started: sudo docker logs | grep -iE "started|error" ``` Troubleshooting decoder: | Symptom | Cause | |---|---| | `Network unreachable` at LE step | Missing `network_mode: host` on certbot services | | `Network unreachable` at Route53 step | Missing `AWS_USE_DUALSTACK_ENDPOINT=true` in aws.env | | `AccessDenied` | IAM policy / zone ID mismatch | | Zone not found | Domain spelling, or private zone instead of public | | App boot error re: key-store + bundle | A `server.ssl.key-store*` line survived in the war (Section 3.2) | | compose parse error on `.env` | Heredoc text pasted into the file (Section 4.1 note) | --- ## 6. nginx configuration (all front-end machines in the split pair) In the domain's location blocks, replace the self-signed pin: ```nginx # REMOVE: # proxy_ssl_trusted_certificate /etc/nginx/ssl/rest-web.crt; # proxy_ssl_name "Unknown"; # ADD: proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; proxy_ssl_verify_depth 3; proxy_ssl_name ; proxy_ssl_server_name on; proxy_ssl_session_reuse on; proxy_connect_timeout 5s; ``` - `ca-certificates.crt` is Ubuntu's built-in root store (includes ISRG Root X1, LE's root); it updates via `apt`. To pin trust to LE only: copy `/usr/share/ca-certificates/mozilla/ISRG_Root_X1.crt` and point at that instead (pin the **root**, never the intermediate — LE rotates intermediates). - `verify_depth 3` covers leaf → LE intermediate → root. - `proxy_connect_timeout 5s` — stopped macvlan containers **blackhole** (no RST); without this, failover to the surviving upstream waits the 60s default per attempt. 5s is ~1000× the LAN connect time. Keep long `proxy_read_timeout` values (e.g. SSE's 24h) — connect and read timeouts are independent. Upstream block — domain names per existing convention, plus shared LB state: ```nginx upstream { zone 64k; server ":5050"; server ":5050"; } ``` - `zone` shares load-balancer state across worker processes. Without it, each worker keeps its own round-robin counter, and at low traffic (tests!) nearly all requests go to the first-listed server — this is normal nginx behavior, not a fault, but `zone` makes distribution sane at any traffic level and shares upstream health state across workers. - Domain names in upstreams resolve **once, at config load**. Rule: the DNS record must exist and resolve *from inside the nginx container* before the reload, and any later IP change requires another reload. Apply on **both** machines of the split pair, back-to-back: ```bash sudo docker exec nginx -t && sudo docker exec nginx -s reload # MANDATORY verification that the reload took: sudo docker exec nginx -T 2>/dev/null | grep -A8 "upstream " ``` > **Lessons learned (nginx):** > - **Always verify the loaded config with `nginx -T` after every reload.** A rejected reload (e.g. unresolvable upstream name) leaves nginx silently running the OLD config. > - A stale config on one machine of the split pair = ~50% intermittent failures via DNS round-robin, which masquerades as flakiness. > - Cutover timing: the moment a backend serves the LE cert, the old self-signed pin fails — change backend and both nginx machines in the same window. --- ## 7. Verification gauntlet (run for every new stack) **All testing from a third machine (e.g. colo1) — NEVER from a macvlan host toward its own containers.** The kernel drops host↔own-macvlan traffic by design; testing from the host produces phantom hangs. (This caused significant confusion during the reference build.) ```bash # 1. Cert on the wire, per backend: echo | openssl s_client -6 -connect []:5050 \ -servername 2>/dev/null \ | openssl x509 -noout -issuer -dates -ext subjectAltName # Want: LE issuer, all SANs, ~90-day validity. # NOTE: LE backdates notBefore by exactly 1 hour — a cert issued at 19:36 shows notBefore 18:36. # 2. End-to-end through nginx: curl https:///public/health # 3. Renewal machinery: sudo docker compose exec certbot-renew certbot renew --dry-run # 4. Rotation rehearsal (ONCE — consumes a duplicate-cert slot): sudo docker compose exec certbot-renew certbot renew --force-renewal sudo docker compose exec certbot-renew cat /etc/letsencrypt/deploy.log # Then re-run check 1: notBefore ≈ now (minus 1h), container uptime UNCHANGED. # Verify rotation ON THE WIRE — the Boot/Netty combo logs no greppable "reload" line. # 5. Load-balancing check (volume matters — low-rate curls pin to one upstream without `zone`): for i in $(seq 1 200); do curl -sS -m 8 https:///public/health >/dev/null; done # then count per-upstream in nginx access logs — expect a rough split. # 6. Failover: # stop one app container; curls from the third machine should return Healthy via the # survivor (first affected request may pause ~proxy_connect_timeout). Restart it after. ``` ### 7.1 Fail-closed security test (run once per environment) Proves `proxy_ssl_verify` actually rejects forged certs: ```bash # Swap in a self-signed impostor CLAIMING THE CORRECT SANs: sudo docker compose exec certbot-renew sh -c ' openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 1 \ -keyout /etc/letsencrypt/deployed/privkey.pem.evil \ -out /etc/letsencrypt/deployed/fullchain.pem.evil \ -subj "/CN=" \ -addext "subjectAltName=DNS:,DNS:" ; cd /etc/letsencrypt/deployed ; cp privkey.pem privkey.pem.real ; cp fullchain.pem fullchain.pem.real ; mv privkey.pem.evil privkey.pem ; mv fullchain.pem.evil fullchain.pem' # Wait ~30s (hot reload swaps in the impostor), then curl through nginx repeatedly: # EXPECT 502 from BOTH front-end machines, and in nginx error logs: # "upstream SSL certificate verify error: (18:self-signed certificate)" # Restore: sudo docker compose exec certbot-renew sh -c ' cd /etc/letsencrypt/deployed ; mv privkey.pem.real privkey.pem ; mv fullchain.pem.real fullchain.pem' # Wait ~30s, curl → Healthy. ``` Reference build result: 502 on both machines, correct error logged, clean recovery — and the test doubles as two extra hot-reload demonstrations. --- ## 8. Monitoring (required, per stack) Two alerts. **Test-fire both after wiring them** — an untested alert is a hope. ### 8.1 Served-cert expiry (<14 days) — runs on a third machine (colo1) Checks what each backend **serves on the wire** (not certbot's files — the wire check catches every failure stage including hook-succeeded-but-app-didn't-reload). Certbot renews at 30 days out, so an alert means ~2 weeks of silent failures with ~2 weeks of runway. `/opt/monitoring/check-cert-expiry.sh`, cron every 6h: ```bash #!/bin/bash THRESHOLD_SECONDS=$((14 * 24 * 3600)) alert() { curl -sS -X POST -H 'Content-Type: application/json' \ -d "{\"text\":\"$1\"}" "" >/dev/null; } BACKENDS=" |5050| |5050| " echo "$BACKENDS" | while IFS='|' read -r ip port label; do [ -z "$ip" ] && continue cert=$(echo | timeout 10 openssl s_client -6 -connect "[$ip]:$port" \ -servername 2>/dev/null) if [ -z "$cert" ]; then alert ":rotating_light: CERT CHECK: cannot connect to $label at [$ip]:$port" continue fi if ! echo "$cert" | openssl x509 -noout -checkend "$THRESHOLD_SECONDS" >/dev/null 2>&1; then expiry=$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) alert ":rotating_light: CERT EXPIRING: $label expires $expiry (<14 days) — renewals failing" fi done ``` Test-fire: set threshold to 90 days, run by hand, confirm the alert arrives, set back. ### 8.2 Renew-container alive — runs on each backend host The renew container **is** the renewal scheduler; `restart: unless-stopped` covers crashes but not manual downs or daemon issues. `/opt/monitoring/check-renew-container.sh`, cron every 6h: ```bash #!/bin/bash alert() { curl -sS -X POST -H 'Content-Type: application/json' \ -d "{\"text\":\"$1\"}" "" >/dev/null; } CONTAINER="-certbot-renew-1" # verify with: docker ps | grep renew state=$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null) if [ "$state" != "true" ]; then alert ":rotating_light: RENEW CONTAINER DOWN on $(hostname): $CONTAINER (state: ${state:-not found}) — renewals stopped" fi ``` Test-fire: stop the container, run by hand, confirm alert, `docker compose up -d`. --- ## 9. Growth recipes ### 9.1 New app version Replace the war → `sudo docker compose restart `. Certs untouched; init does not re-run (PEMs persist in the volume). ### 9.2 Add another container on the same machine 1. Route53: AAAA record for the new domain → its new macvlan IPv6 2. `.env`: append to `EXTRA_DOMAINS` (e.g. `EXTRA_DOMAINS=-d green.fox.beta.web.tasking.rpp.cybercrucible.com`) 3. `compose.yml`: new service via `<<: *restServiceTemplate` + its own `container_name` and `ipv6_address` — it inherits cert mount, SSL env, and startup ordering from the anchor 4. **Explicit SAN-expansion reissue** (do not trust `compose up` alone — `--keep-until-expiring` may skip despite the changed domain list): ```bash sudo docker compose run --rm certbot-init \ "certbot certonly --dns-route53 --cert-name \ -d -d -d \ --key-type ecdsa --non-interactive --agree-tos -m \ && CERT_NAME= /etc/letsencrypt/renewal-hooks/deploy/publish.sh" ``` Certbot reissues into the same lineage and updates the renewal conf, so future auto-renewals carry all names. Running containers hot-reload the expanded cert. (Not a duplicate-cert rate-limit event — the SAN set changed.) 5. `sudo ./start.sh` (starts the new container) 6. nginx, both machines: one `server` line in the upstream block, reload, `nginx -T` verify. **No `proxy_ssl_*` changes.** 7. Verification gauntlet items 1, 2, 5. ### 9.3 New backend machine (availability pair) 1. Copy the stack directory pattern; adjust `working_dir`/volume paths 2. `.env`: **new** `CERT_NAME`, that machine's own domains, **same** `ALIAS_DOMAIN` 3. `aws.env`: same credential (dualstack line if IPv6-only host) 4. DNS records for its containers → `start.sh` (fresh lineage issues independently — no interaction with other machines' certs) 5. nginx upstream lines ×2, reload, verify 6. Full verification gauntlet Machines' cert lifecycles are fully independent — one machine down does not affect the other's renewals. Wire the Section 8 monitoring for the new machine **the day it exists**. ### 9.4 New domain family (e.g. rest-agent, prod) 1. Pick its alias: `upstream..tasking.rpp.cybercrucible.com` 2. Per backend machine: this guide from Section 4, with all that machine's domains (`PRIMARY_DOMAIN` + `EXTRA_DOMAINS`) + the family alias 3. nginx: the Section 6 block with the new alias in `proxy_ssl_name` 4. Keep aliases distinct per family and per environment (beta vs prod) --- ## 10. Operational reference ### How renewal works | Stage | What runs | On failure | |---|---|---| | Timer | certbot-renew loop, every 12h | Monitoring 8.2 | | Check | no-op unless <30 days left | — | | Renewal | DNS-01 via Route53, new cert + fresh key | Old cert still valid; ~60 retries over 30 days; monitoring 8.1 | | Publish | deploy hook, atomic mv of flat PEMs | Old PEMs still served; monitoring 8.1 (wire check) | | Reload | Spring watcher swaps SSLContext live | Old cert still served; monitoring 8.1; fix = container restart | If a cert somehow fully expires: nginx verification fails closed (502, logged as "certificate has expired") until one successful renewal — recovery needs no restarts and no nginx changes. Reaching that state requires ~60 consecutive renewal failures plus ignoring the <14-day alert for two weeks plus ignoring LE's expiry emails. ### Command quick reference ```bash # Lineage status: sudo docker compose exec certbot-renew certbot certificates # Deploy history: sudo docker compose exec certbot-renew cat /etc/letsencrypt/deploy.log # Cert being served (from a third machine): echo | openssl s_client -6 -connect []:5050 -servername 2>/dev/null \ | openssl x509 -noout -issuer -dates -ext subjectAltName # Loaded nginx config: sudo docker exec nginx -T 2>/dev/null | grep -A8 "upstream " ``` ### Rate limits (Let's Encrypt production) - Duplicate certificates (identical SAN set): **5/week** — why `--force-renewal` is used at most once per stack for the rotation rehearsal, and never in compose - Failed validations: 5/hostname/hour — a retry loop against a broken config can lock the shared alias for every backend; fix the cause, don't hammer - Staging environment (`--staging`) has ~10× limits — use it for all experimentation ### Environment gotchas index (hard-won) 1. **IPv6-only hosts:** `network_mode: host` on certbot services + `AWS_USE_DUALSTACK_ENDPOINT=true` 2. **Test from a third machine only** — host↔own-macvlan is dropped by the kernel; testing from the host produces phantom hangs 3. **`nginx -T` verify after every reload** — rejected reloads silently keep the old config 4. **DNS record live and resolvable before nginx references the name** — upstream names resolve once, at load 5. **`zone` in upstream blocks** — without it, per-worker round-robin pins low-rate traffic to the first-listed server (normal behavior, confusing symptom) 6. **`proxy_connect_timeout 5s`** — stopped macvlan containers blackhole; default 60s makes failover appear broken 7. **LE backdates `notBefore` by 1 hour** — not a stale cert 8. **Verify rotation on the wire, not in app logs** — Boot/Netty logs no greppable reload line 9. **Mount the tls directory, never individual files** — single-file mounts pin the inode and silently kill hot reload 10. **Edit env files directly; never paste heredocs into them** 11. **`docker compose run` against these certbot services: pass the command as ONE quoted string** (entrypoint is already `sh -c`)