Capture the cert + edge-proxy conventions worked through deploying the helexa-bench UI: - external-tls.md — publicly-trusted certs via Let's Encrypt (certbot, Cloudflare DNS-01, ECDSA, /root/.certbot-internal); the external counterpart to internal-tls.md. Decision rule: public name → LE, *.internal → internal CA. - reverse-proxies.md — names the per-site edge proxies (oolon for kosherinata, hanzalova.internal for the office) and what sits behind each, the public-vs-mesh access paths + the "public names don't hairpin from inside the mesh" gotcha, per-vhost cert choice, nginx conventions, and the bench (bench.helexa.ai + bench.internal) worked example. - readme + generic.md §11 cross-reference both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.8 KiB
External TLS: public certs for WAN-facing vhosts
Extends generic.md §11 (TLS / PKI). That section and internal-tls.md cover the
internal PKI (Smallstep step-ca, *.internal names, mesh-only). This doc covers
the other half: publicly-trusted certs for names served to the public internet at a
site's WAN edge — e.g. bench.helexa.ai, qapish.ai, *.zap.pics.
Decision rule (the whole strategy in one line):
Public, internet-resolvable name → Let's Encrypt. Mesh-only
*.internalname → internal CA (internal-tls.md). A service reached both ways gets one vhost of each (seereverse-proxies.md).
Public certs must chain to a publicly-trusted root (browsers off the mesh don't trust
the lair internal root), so these come from Let's Encrypt — never step-ca.
1. Issuance: certbot + Cloudflare DNS-01, ECDSA
Our public DNS zones are on Cloudflare, so we use the DNS-01 challenge via the
certbot-dns-cloudflare plugin. DNS-01 is deliberate:
- No inbound :80 needed. The challenge is a TXT record, not an HTTP hit — so a cert can be issued (or renewed) even while nginx is stopped or the host isn't yet reachable from the WAN. (This is why a dormant edge proxy doesn't block issuance.)
- Wildcard-capable, if a zone ever wants
*.example.com.
Keys are ECDSA (--key-type ecdsa), matching the rest of the fleet.
sudo certbot certonly \
-m ops@zap.pics --agree-tos --no-eff-email --noninteractive \
--cert-name <domain> \
--key-type ecdsa \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.certbot-internal \
--dns-cloudflare-propagation-seconds 60 \
--keep-until-expiring \
-d <domain>
/root/.certbot-internalholds the Cloudflare API token. One token covers all the zones we manage (helexa.ai,zap.pics, …), so new sub-domains under an existing zone need no new credential — just run the command.--keep-until-expiringmakes scripted/repeated runs idempotent (no-op if the cert is still valid), so this is safe to call unconditionally frominfra-setup.sh.--cert-name <domain>pins the lineage name so the cert lands at a predictable path regardless of-dordering.
2. Paths
certbot's standard layout (do not relocate — the renew timer expects it):
| Path | Contents |
|---|---|
/etc/letsencrypt/live/<domain>/fullchain.pem |
cert + intermediate chain |
/etc/letsencrypt/live/<domain>/privkey.pem |
private key |
These live under root-only /etc/letsencrypt/live (0700). Scripts that check for an
existing cert must sudo test -d /etc/letsencrypt/live/<domain> — an unprivileged
test silently returns false and will wrongly conclude the cert is missing.
3. Renewal
Automatic via certbot's own certbot-renew.timer (systemd) — no per-cert unit,
unlike the internal step@<name> template. certbot renews any lineage within 30 days of
expiry and runs the configured deploy hook. Ensure nginx reloads after renewal with a
deploy hook (once per host):
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
#!/bin/sh
systemctl reload nginx 2>/dev/null || true
4. nginx wiring
server {
listen 443 ssl;
http2 on;
server_name <domain>;
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
}
Keep an :80 server for the same name only if you want an HTTP→HTTPS redirect; the
cert itself needs no :80 (DNS-01). Never reference a cert path before the cert exists —
nginx -t fails on a missing ssl_certificate file and blocks all of nginx from
(re)starting. Issue first, then install the TLS vhost (gate the vhost install on
sudo test -d /etc/letsencrypt/live/<domain>).
5. Gotchas
- SAN, not CN. Modern clients ignore CN; the served name must be in the SAN. certbot
sets SAN from
-d, so this is automatic — but ifcurlreports "no alternative certificate subject name matches target hostname", the listener answering isn't the one holding this cert (see next point). - Wrong cert on the public endpoint = a routing problem, not a cert problem. If a
public name returns something like
CN=opnsense.<site>.internal, the WAN:443forward (or HAProxy SNI route) on OPNsense isn't landing on the site's nginx. Fix the edge route (reverse-proxies.md§2), not the cert.
6. Checklist for a new public vhost
- Add the public DNS record on Cloudflare (unproxied by default —
generic.md§11). - Issue the cert (§1), from
infra-setup.sh, idempotently. - Point the nginx vhost at the
live/<domain>paths (§4);nginx -t&& reload. - Confirm the site's OPNsense forwards WAN
:443to this nginx (reverse-proxies.md).