HTTPS / TLS certificates
Diátaxis quadrant: How-to. Audience: operators and integrators giving humanymous Gate (“Gate” after first mention) a real, browser-trusted certificate on a public domain.
Out of the box Gate terminates TLS with an in-memory self-signed certificate (CN humanymous.local), which is fine for local development but makes every browser warn. This page shows how to give the public edge a real certificate. Concepts and the full flag table are linked, not repeated: for every flag see the CLI, config & per-route policy reference, and for where each detection layer is active in your topology see Supported topologies.
Pick your certificate source
Gate’s edge listener chooses its certificate from exactly one of three sources (buildEdgeTLS in cmd/gate/tls.go):
| Your situation | Use | Section |
|---|---|---|
Public domain, Gate terminates TLS itself, nothing else on :443 |
Let’s Encrypt (-acme-domain) — automatic issue + renew |
Let’s Encrypt |
| You already have a certificate (corporate CA, wildcard, purchased) | Bring-your-own (-tls-cert / -tls-key) |
Bring-your-own |
| A CDN / nginx / Caddy / Traefik / L7 LB already terminates TLS in front of Gate | Self-signed / internal cert behind the terminator + -trusted-proxies |
Behind a terminator |
| Local development only | Do nothing — the self-signed dev cert is the default | — |
Note: These sources apply to the public edge only. The admin listener always stays self-signed — it is a management plane, hardened with client-cert mTLS (
-admin-mtls-ca), not ACME. See Keep the admin plane on mTLS.
Let’s Encrypt (recommended)
-acme-domain requests a free, browser-trusted Let’s Encrypt certificate and renews it automatically. Gate answers the ACME challenge with TLS-ALPN-01, which is answered inline during the TLS handshake on :443 — there is no port 80 and no HTTP-01.
Prerequisites
- DNS. An
A(and/orAAAA) record for your domain points at the host’s public IP. - Inbound
:443open from anywhere. Let’s Encrypt validation servers connect from varying IPs, so:443must be reachable from the public internet, not just your office. - Nothing else terminates TLS for that domain on
:443. TLS-ALPN-01 is answered in the handshake, so no CDN, WAF, or L7 load balancer may re-terminate TLS in front of Gate. (If something must, you are in the behind-a-terminator topology, not this one.) - A persistent volume for the certificate cache (see the caution below).
Run it (single container)
Map host :443 to the container’s edge port (:8444) and mount a named volume for the ACME cache:
docker run -d \
-p 443:8444 -p 127.0.0.1:8445:8445 \
-v hmn-acme:/acme-cache \
ghcr.io/modootoday/humanymous-gate:latest \
-addr :8444 -admin-addr :8445 \
-upstream http://YOUR-ORIGIN:PORT \
-acme-domain your.domain \
-acme-cache /acme-cache \
-acme-email [email protected] \
-monitor
:latesttracks the newest release.-monitorscores and logs without enforcing — the safe first contact with real traffic. Drop it to enforce.-acme-emailis optional but recommended: Let’s Encrypt uses it for expiry and policy notices.
Caution — the cache MUST be an absolute path on a persistent, writable volume. The default
-acme-cachevalue is relative (acme-cache→/app/acme-cache), which lands on the container’s read-only root filesystem and is not persisted. That breaks issuance outright, and even if it did not, it would re-issue on every restart and quickly exhaust Let’s Encrypt’s rate limits. Always pass an absolute path (-acme-cache /acme-cache) mounted on a named volume (-v hmn-acme:/acme-cache). The published gate image pre-creates/acme-cacheand/datawritable by the non-root uid.
Or use the pull-only Compose file
deployments/compose.release.yaml already wires this correctly — -acme-domain ${HMN_DOMAIN}, -acme-cache /acme-cache onto the hmn-acme named volume, and 443:8444 published:
cd deployments
cp .env.example .env # set HMN_UPSTREAM, HMN_DOMAIN, HMN_UNSEAL, HMN_ADMIN_TOKENS
cp routes.conf.example routes.conf
docker compose -f compose.release.yaml up -d
Set HMN_DOMAIN to your public domain. For multiple domains on one certificate, set a comma-separated SAN list (see Multi-domain). See the full runbook in Deployment & policy operations.
Verify
Issuance happens on the first inbound TLS handshake for the domain, so hit the edge once, then inspect the presented certificate:
openssl s_client -connect your.domain:443 -servername your.domain </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
Expected: an issuer= line naming Let’s Encrypt (R3 / E1 or the current intermediate), subject=CN=your.domain, and valid notBefore/notAfter dates. In a browser, the padlock shows no warning. If you still see CN humanymous.local, ACME has not completed — see Troubleshooting.
Staging dry-run (get DNS and firewall right without burning rate limits)
Before your first production issuance, dry-run against the Let’s Encrypt staging directory with -acme-directory. Staging has far looser rate limits, so you can iterate on DNS and firewall without risking a production lockout:
docker run -d \
-p 443:8444 -p 127.0.0.1:8445:8445 \
-v hmn-acme-staging:/acme-cache \
ghcr.io/modootoday/humanymous-gate:latest \
-addr :8444 -admin-addr :8445 \
-upstream http://YOUR-ORIGIN:PORT \
-acme-domain your.domain \
-acme-cache /acme-cache \
-acme-directory https://acme-staging-v02.api.letsencrypt.org/directory \
-monitor
Staging certificates are signed by an untrusted root, so browsers will still warn — that is expected; you are validating the issuance flow, not the trust chain. openssl above will show a (STAGING) issuer. Once staging succeeds, switch back to production by dropping -acme-directory (empty = production Let’s Encrypt) and use a fresh cache volume (e.g. the hmn-acme volume, not hmn-acme-staging) so a staging cert is not reused. -acme-directory also accepts a ZeroSSL directory URL or an internal step-ca URL.
Automatic renewal
Renewal is automatic — autocert renews roughly 30 days before expiry, in-process, as long as :443 stays reachable and the cache volume persists. There is no cron and no certbot to run. Keep the cache volume mounted across restarts and upgrades so renewed certs (and account keys) survive.
Rate limits (production Let’s Encrypt)
Production Let’s Encrypt enforces (approximately): ~50 certificates per registered domain per week, 5 duplicate certificates per week, and per-account/hostname failed-validation limits per hour. This is why the persistent cache and the staging dry-run matter — a misconfigured cache that re-issues on every restart will hit these limits fast. Use staging while getting DNS and firewall right.
Multi-domain (SAN) certificates
Pass a comma-separated list to put several names on one certificate (autocert HostWhitelist):
-acme-domain www.example.com,example.com,api.example.com
Every listed name must have DNS pointing at the same host and resolve to a reachable :443.
Bring-your-own certificate
If you already hold a certificate — a wildcard, a purchased cert, or one from a corporate/internal CA — hand Gate the PEM files with -tls-cert and -tls-key. No ACME, no :443 challenge requirement.
Mount the PEMs read-only and point the flags at them:
docker run -d \
-p 443:8444 -p 127.0.0.1:8445:8445 \
-v /etc/ssl/your-cert.pem:/certs/tls.crt:ro \
-v /etc/ssl/your-key.pem:/certs/tls.key:ro \
ghcr.io/modootoday/humanymous-gate:latest \
-addr :8444 -admin-addr :8445 \
-upstream http://YOUR-ORIGIN:PORT \
-tls-cert /certs/tls.crt \
-tls-key /certs/tls.key \
-monitor
Both flags are required together; the certificate PEM should include any intermediate chain. The negotiated TLS floor is version 1.2.
Renewal is your responsibility: replace the PEM files and restart Gate. Restart is safe — on SIGINT/SIGTERM Gate drains in-flight requests within -shutdown-grace (default 25s) before exiting, so run the restart within your orchestrator’s termination grace. Certificates are read at boot, so a rotated file only takes effect after a restart.
Behind an existing TLS terminator / CDN
If a CDN, nginx, Caddy, Traefik, or an L7 load balancer already terminates TLS in front of Gate, do not run ACME on Gate — the terminator, not Gate, owns the public certificate. Run Gate with its default self-signed dev certificate (or your own internal cert via -tls-cert/-tls-key) behind the terminator, and tell Gate which balancers to trust so it can recover the real client IP:
-trusted-proxies <cidr-of-your-terminator>
Set -trusted-proxies to your terminator’s addresses only — never a broad range like 0.0.0.0/0, or any client could spoof its source IP. With it set, Gate reads the real client IP from the PROXY-protocol-v2 header the L4 balancer sends, keeping IP-keyed state (bans, rate limits, correlation) correct.
Caveat — the TLS fingerprint plane goes inert behind a re-terminator. When anything re-terminates TLS in front of Gate, the ClientHello Gate sees is the terminator’s, not the browser’s, so the JA3/JA4/HTTP-2 fingerprint plane (HR-2/HR-5/HR-11/HR-14) is inactive and silent — detection collapses to the client, header, behavior, and IP-intel planes. For TLS fingerprinting you must terminate raw TLS at the Core engine with nothing in front. This is a placement decision, not a tunable; read Supported topologies before you choose this shape.
Enable HSTS after the cert is stable
Once a real certificate is issued and stable, add Strict-Transport-Security with -hsts:
-hsts
Enable it only after the real cert is confirmed working. HSTS pins the browser to HTTPS for the max-age window, so a bad, expired, or accidentally-self-signed certificate then hard-blocks browsers with no click-through. Leave -hsts off while you are still on the dev cert or dialing in ACME.
Keep the admin plane on mTLS (not ACME)
The -acme-domain / -tls-cert sources configure the public edge only. The admin listener (-admin-addr, default 127.0.0.1:8445) always keeps its own self-signed certificate — it is a management plane, not a public surface. Harden it with client-cert mTLS using -admin-mtls-ca (a PEM of client-cert CA(s)); when set, the admin listener requires a client certificate signed by that CA in addition to the bearer token. Do not point ACME at the admin listener. See Deployment & policy operations.
Troubleshooting
Common ACME (Let’s Encrypt) failures and what they mean:
| Symptom | Likely cause | Fix |
|---|---|---|
Cert never issues; edge still presents CN humanymous.local |
:443 is not reachable from the public internet |
Open inbound :443 in the host/cloud firewall and security groups; TLS-ALPN-01 uses only :443 (no port 80). |
| Issuance fails; validation errors | DNS A/AAAA record does not point at this host’s public IP |
Fix the DNS record and let it propagate, then retry. |
| Cert issues but as the terminator’s, or handshake fails | Something else terminates TLS for the domain on :443 in front of Gate |
Remove the re-terminating CDN/LB, or switch to the behind-a-terminator topology (no ACME on Gate). |
| Issuance blocked / “too many certificates” | Hit a production Let’s Encrypt rate limit | Switch to the staging directory with -acme-directory until DNS/firewall are correct, then go back to production with a fresh cache. |
| New cert on every restart / rate-limited quickly | -acme-cache is not persisted (relative default on read-only rootfs) |
Pass an absolute -acme-cache /acme-cache on a named volume (-v hmn-acme:/acme-cache); confirm the volume survives restarts. |
| Browser hard-blocks after a cert problem | -hsts was enabled before the cert was stable |
HSTS pins HTTPS for its max-age; fix the certificate. Only enable -hsts after the cert is confirmed. |
Related pages
- Deployment & policy operations — the full deploy runbook, including moving from the dev cert to production TLS.
- Supported topologies — which detection layers are active where, and why a re-terminator makes the TLS plane inert.
- CLI, config & per-route policy reference — every TLS/ACME flag,
-trusted-proxies,-hsts, and-admin-mtls-ca. - Install requirements — ports, the one dependency, and running from the published image.