TLS / Certificates: Understand & Debug
You open /certificates in the admin UI and see a table of your
domains with status tags. One domain shows "HTTP without TLS" where
you actually expected HTTPS. Or HTTPS works even though you set
https_enabled=false. Or a cert for a fresh route just doesn't arrive.
This guide explains what happens under the hood, how to read the certificates page correctly, and what you can do about the typical problems.
How TLS really works in GateControl
Caddy is the cert manager
Every HTTP route is passed as a domain to Caddy. Caddy uses its built-in Automatic HTTPS and automatically fetches a Let's Encrypt certificate for every domain it serves — HTTP-01 challenge over port 80.
The relevant environment hooks:
GC_CADDY_EMAIL— contact email for Let's Encrypt. Without an email you get no expiry warnings from LE.GC_CADDY_ACME_CA— optional custom ACME endpoint URL. Default is LE Production. For dev/test can be set to LE Staging (https://acme-staging-v02.api.letsencrypt.org/directory) or to a private CA (Step-CA, Smallstep, Vault). This is theacme_custom_cafeature.
All HTTP routes share one server block
Important implementation detail: src/services/caddyConfig.js merges
all domains with HTTP routes into a single Caddy server srv0
(see code around lines 626–635). This one server listens on
:443 and :80. Per-route listen configuration is discarded in the process.
The https_enabled column in the routes table still exists, but has
no effect on cert issuance and listener binding. Historically it
was meant as a per-route listener toggle, but was superseded by the
"merge-all-into-srv0" refactor. In the code flow the field is still
set, but the only place that used to honour it (listen: https_enabled ? [':443'] : [':80'] in individual
server blocks) is replaced by the subsequent merge into srv0.
Practical consequence: every HTTP route with a domain automatically
gets an Auto-TLS cert — regardless of what https_enabled says. If you
really want to prevent HTTPS, you would have to patch the Caddy template
code; we advise against that.
L4 routes — three TLS modes
L4 routes do not run through Caddy's HTTP stack, but through Caddy-L4. That's why different rules apply:
tls_mode=none— pure TCP/UDP proxy. No cert. For SSH, Minecraft, DNS, MQTT-plain.tls_mode=passthrough— Caddy-L4 only looks at SNI, passes the TLS session 1:1 on to the backend. Cert must sit at the backend.tls_mode=terminate— Caddy-L4 terminates TLS with a Let's Encrypt cert on the server side, then speaks plain TCP to the backend.
Default mode is none, because L4 is mostly used for protocols that
have no TLS.
TLS tax: port 80 is mandatory
ACME HTTP-01 needs a reachable port 80. The cert refresh (automatically every ~60 days) likewise. If port 80 is blocked or sits behind a third-party firewall: cert doesn't come and expires.
Reading the certificates page
/certificates lists the current cert status for every route with a
domain. The status tags:
| Tag | Meaning | When? |
|---|---|---|
| Auto-TLS (green) | Let's Encrypt cert is actively managed | Normal case, HTTP route with domain |
| HTTP without TLS (amber) | Legacy status, should no longer occur after v1.47.3 | Historical routes with https_enabled=0 — are now treated as Auto-TLS |
| TLS passthrough (grey) | L4 route with tls_mode=passthrough |
Cert lives at the backend, server does not terminate |
| L4 without TLS (grey) | L4 route with tls_mode=none |
Pure TCP/UDP proxy |
| Caddy offline (red) | Caddy process not running | Supervisord lost Caddy → admin action required |
The tags are computed from the interplay of route type,
https_enabled/l4_tls_mode and Caddy runtime status. The
code lives in src/services/caddyConfig.js + the routes service.
Troubleshooting
"My cert is not being issued"
By far the most common case. Checklist:
-
Check DNS. The A record must point to the public IPv4 of the GateControl server.
dig +short cloud.example.com # must be your server IPOr in the admin UI: in the route wizard there is a DNS check button,
POST /api/v1/routes/check-dns. -
Port 80 reachable from outside. Test from an external machine:
curl -I http://cloud.example.com/must at least bring up a TCP connect. If
Connection refusedor timeout: router firewall, server firewall or occupied by another process. -
Port 443 reachable from outside. Same test with
https://. On "unable to verify the first certificate" that's OK — the connect is up, just the cert is not valid yet. -
Cloudflare proxy off. If CF sits as a proxy in front (orange cloud), HTTP-01 fails: LE sees the CF IP instead of our IP and gets the wrong response when fetching the challenge.
-
Let's Encrypt rate limit. 50 certs per registered domain per week, 5 duplicate certs per week, 5 failed attempts per hour. If you experiment a lot, you'll hit that. Remedy: switch to LE Staging during tests:
# in the server .env GC_CADDY_ACME_CA=https://acme-staging-v02.api.letsencrypt.org/directoryStaging certs are not browser-trusted, but for checking the flow it's enough. Afterwards switch back to production.
-
Inspect Caddy logs:
docker logs gatecontrol 2>&1 | grep -i "acme\|tls.obtain\|certificate"Typical error messages:
authorization failed: Invalid response from …→ port 80 not reachable, or challenge path not routable.too many registrations for this IP→ LE limit.timeout waiting for response from DNS→ DNS slow, often gone after propagation.
"Cert was issued, but the browser still warns"
-
Cert for the wrong domain? Caddy fetches the cert for the domain that's in the host matcher. If you call
www.example.combut the route is namedexample.com, Caddy only fetches the cert forexample.com. Solution: either a second route forwww., or add multiple domains in the route wizard. -
Intermediate chain incomplete?
openssl s_client -servername cloud.example.com -connect cloud.example.com:443 -showcerts < /dev/nullLook for "verify return code: 0 (ok)". With LE certs the E5/E6 intermediate should be delivered as well.
-
Wrong cert via SNI: query the Caddy admin API:
curl -s http://127.0.0.1:2019/pki/ca/local | jq .or via the internal admin endpoint to see what Caddy caches for which domain.
"HTTPS works even though I turned off https_enabled"
Expected behaviour since the srv0 merge refactor. The flag is
currently without effect. See section "All HTTP routes share
one server block" above.
If you really want HTTPS off for a specific route (for whatever
reason — there's rarely a good one): create the route as L4 with
tls_mode=none and choose an HTTP custom listen port. Or
patch the Caddy template code.
"L4 route with TLS terminate doesn't get a cert"
Caddy-L4 handles TLS differently from Caddy-HTTP. Without an explicit SNI matcher in the L4 config Caddy doesn't know for which domain the cert applies.
Workaround: set the domain in the L4 route and maintain TLS policies
manually. The GateControl admin UI currently doesn't expose a
convenient switch for this — if you really need L4 terminate, the
recommended path is a separate HTTP route on a custom port. For the
majority of L4 use cases, none or passthrough is more appropriate anyway.
"Caddy offline" — red tag
Caddy process is not running. The supervisord watchdog should restart it automatically, but occasionally doesn't (e.g. after OOM kill or with a broken config reload cycle).
Fix:
docker exec gatecontrol supervisorctl status caddy
docker exec gatecontrol supervisorctl restart caddy
Afterwards click Caddy reload in the admin UI (Settings → Caddy). The server throws its in-memory config afresh into the Caddy admin API.
"Cert has expired and is not renewing"
Caddy renews automatically ~30 days before expiry. If that doesn't work:
- Port 80 must be reachable (also for renewals).
- Caddy data volume persistent? If
/datais not mounted, all certs are gone at every container restart. Check:docker inspect gatecontrol | grep -A 5 Mounts - Manual renewal kick:
(Or in the GateControl case: admin UI → Caddy reload.)docker exec gatecontrol caddy reload --config /etc/caddy/Caddyfile
Further reading
- ../FORCE-HTTPS.md — HTTP-to-HTTPS redirect subtleties.
- ../BACKEND-HTTPS.md — HTTPS to the backend (separate from the cert at the frontend).
- ../concepts/routing.md — deep dive HTTP routing pipeline (host matcher, subroute, L4 layer).
- first-gateway-setup.md — gateway routes
are simpler regarding TLS, because they all run through
srv0with Auto-TLS. - adding-a-route.md — wizard walkthrough incl. DNS check step.