HTTPS Hosting¶
This chapter explains how to serve klangkd over HTTPS — and how klangkd builds the public URLs embedded in hosted-app links, login emails, and OIDC callbacks. The main path is running klangkd as the internet-facing server with HTTPS managed end to end: klangkd's built-in Caddy proxy requests a Let's Encrypt certificate itself (ZeroSSL as fallback issuer), serves it, and renews it before it expires — for a public hostname you choose (#3192). HTTPS here means a normal, publicly trusted certificate, and klangkd owns its entire lifecycle itself: it requests, serves, and renews the certificate with no operator tooling.
This is one of four TLS models:
| Model | Who terminates TLS | Chapter |
|---|---|---|
| Automatic TLS (this chapter) | klangkd's built-in Caddy, Let's Encrypt certificate it issues + renews itself | here |
| Behind a proxy + internal TLS hop (this chapter) | klangkd's built-in Caddy, self-generated internal-CA certificate | here |
| Behind a reverse proxy, plain | an outer nginx/Caddy/HAProxy/load balancer | Behind a Reverse Proxy |
| Certificate files you already hold | klangkd's built-in Caddy, serving files from another ACME client or tailscale cert |
planned (#2167) — automatic TLS above usually makes it unnecessary |
Use automatic TLS when klangkd runs on a host with a public DNS name and ports 80/443 reachable from the internet. Use an outer proxy when something else already owns ports 80/443, terminates TLS for several services, or the host has no public name — and add the internal TLS hop (below) when the path from that proxy to klangkd must also be encrypted.
Requirements¶
- A DNS A/AAAA record pointing a public hostname (e.g.
klangk.example.com) at the host. - Ports 80 and 443 reachable from the internet. Port 80 serves the ACME HTTP-01 challenge and the HTTP→HTTPS redirect; port 443 serves HTTPS (and the TLS-ALPN challenge).
- A Caddy binary new enough for klangkd's full global options block (anything from the last few years; klangkd probes the binary at boot and fails with a clear message when it is too old).
- Permission for the caddy binary to bind ports below 1024 when klangkd does not run as root:
(Re-run after every caddy upgrade — a replaced binary loses the capability.)
Configuration¶
Add these to klangkd.yaml (each key also has an env-var equivalent;
env vars override file values):
(KLANGKD_LISTEN, KLANGKD_PORT, KLANGKD_TLS_HOSTNAME,
KLANGKD_ACME_EMAIL)
tls-hostname— the public FQDN. Setting it arms automatic TLS: the browser listener is rendered ashttps://<fqdn>:<port>, Caddy's automatic HTTPS takes over (certificate issuance, renewal, HTTP→HTTPS redirect on port 80). Unset (the default) keeps plain HTTP withauto_https off— byte-identical to the pre-#3192 behavior, so outer-proxy deployments are untouched.acme-email— strongly recommended. The CA sends certificate expiry and renewal-failure notices there, and it registers the ACME account. Must be a plain address (ops@example.com) — a display-name form (Ops <ops@example.com>) is rejected at construction, because the value is passed verbatim into the proxy config.port— required (arming without it refuses to boot).443is the canonical choice for an internet-facing server; any other port works too (the certificate is issued for the hostname, not the port), but then browsers must use the explicit port in the URL.listen— set this past the127.0.0.1default: at loopback the HTTPS listener is unreachable from other hosts and the ACME challenge goes unanswered, so issuance fails (klangkd logs a warning at render time;0.0.0.0binds every interface).
Both tls-hostname and acme-email are reloadable: after editing,
send SIGHUP (see Process Signals) and klangkd pushes the
re-rendered proxy config to the running Caddy — arming or disarming TLS
does not need a process restart (a port change does, as always).
How it works¶
klangkd drops auto_https off from the Caddy global block, adds
email (when set) and an explicit certificate storage path under its
state directory (<state_dir>/caddy-storage), and addresses the
browser site as https://<tls-hostname>:<port>.
On boot, Caddy:
- Opens the ACME account with the email (Let's Encrypt first, ZeroSSL as fallback issuer).
- Obtains a certificate for the hostname via the HTTP-01 challenge (port 80) and/or TLS-ALPN (port 443).
- Serves HTTPS on
listen:portand redirects HTTP→HTTPS on port 80. - Renews the certificate automatically before it expires (~30-day renewal window; no operator action).
Issued certificates, private keys, and ACME account state persist in
<state_dir>/caddy-storage, so restarts reuse the existing certificate
instead of re-issuing (which would walk into CA rate limits). The
storage directory is klangkd-owned runtime state: include it in backups
the same way as the database directory if you want instant-restore of
the TLS identity.
Everything inside the site block — workspace-token gates, container
ACLs, the hosted-apps proxy, WebSocket upgrades, the frontend hardening
headers — is identical over HTTPS and plain HTTP; only the listener
scheme changes. The container-egress listener
(KLANGKD_EGRESS_LISTEN:KLANGKD_EGRESS_PORT) stays plain HTTP in all
modes: it is internal container wiring, never exposed to the internet.
Browser secure-context APIs (the terminal clipboard, for example) work once the site is served over HTTPS.
Internal TLS: the hop behind an outer proxy¶
Many deployments terminate TLS at an outer proxy (the real public HTTPS
endpoint) but also require encryption on the hop between that proxy and
klangkd — internal policy, compliance scans, or defense in depth.
tls-issuer: internal serves that case: the built-in Caddy runs its
own internal certificate authority, generates the key and certificate
for the armed name itself, and renews the short-lived certificate
continuously.
listen: "0.0.0.0" # or the interface the proxy reaches
port: 8997
tls-hostname: "klangkd.internal" # any host name or IPv4 literal
tls-issuer: "internal"
trusted-proxy-cidrs: "127.0.0.1,::1,10.0.0.0/24"
(KLANGKD_LISTEN, KLANGKD_PORT, KLANGKD_TLS_HOSTNAME,
KLANGKD_TLS_ISSUER, KLANGKD_TRUSTED_PROXY_CIDRS)
How this differs from automatic (ACME) TLS:
- The host name can be anything.
klangkd.internal,localhost, or an IP address like10.0.0.5all work. ACME mode rejects names like these because public certificate authorities only issue for registered public domain names; the internal CA issues a certificate for whatever name you configure. - The certificate is generated on this host. The internal CA lives
inside the proxy process, so issuance involves zero network traffic —
this mode works with a private DNS name and a listener reachable only
by the outer proxy. (
acme-emaildoes nothing here; klangkd logs a warning if you set it.) - The HTTPS listener binds
listen:port, and that is the only port involved. The automatic HTTP→HTTPS redirect stays off: your outer proxy already sends browsers to HTTPS, and an enabled redirect would try to bind port 80 — a bind that fails when klangkd runs as an unprivileged user. - Certificates are stored in
<state_dir>/caddy-storage. That directory holds the internal CA's root key and every certificate it issues. If the directory is lost, caddy creates a new CA on the next start, and the outer proxy rejects klangkd's certificate until you export the new root and install it there (see below). Back the directory up.
Trust the hop, don't just encrypt it. With no verification on the outer proxy this is encryption-in-transit only — a root whose private key lives on the same host cannot defend against that host. Configure the outer proxy to verify klangkd's certificate against the internal root CA. Fetch the root certificate from the admin endpoint (owner-only Unix socket):
The endpoint answers with a JSON object; extract the embedded root
certificate (PEM) from its root_certificate field:
sudo -u <klangkd-user> curl --unix-socket <state_dir>/caddy-admin.sock \
http://localhost/pki/ca/local | jq -r .root_certificate > klangkd-root.crt
Then, for an nginx outer proxy:
proxy_pass https://klangkd.internal:8997;
proxy_ssl_trusted_certificate /etc/nginx/klangkd-root.crt;
proxy_ssl_verify on;
(Mutual TLS — the proxy also presenting a client certificate — is future work under #2167.)
Everything else behaves as in Behind a Reverse
Proxy: set trusted-proxy-cidrs so forwarded
headers are honored, and pin hosting-hostname if the proxy mangles
Host. See the URL derivation order in the next section.
Public URLs: tls-hostname vs hosting-hostname¶
Two settings sound alike and do different jobs. Keeping them straight is the whole game:
tls-hostnameis listener identity. It names the DNS name the certificate is issued for and the HTTPS listener serves. It changes what the proxy binds, it is a bare FQDN (the port comes fromport), and a bad value refuses to boot. URL generation ignores it — that ishosting-hostname's job, below.hosting-hostname(KLANGKD_HOSTING_HOSTNAME) is a URL override. It leaves every listener untouched; its whole job is to pin the authority —host[:port], port allowed — that klangkd writes into generated URLs (hosted-app links, login emails, OIDC callbacks). Its documented job is the behind-a-proxy model, where theHostklangkd sees is not the public name.
Most deployments set neither. With the pin unset, klangkd derives every public URL from operator configuration first and the request second, in this order (#3276):
KLANGKD_HOSTING_HOSTNAME(the explicit pin), elseX-Forwarded-Host— trusted only when the immediate peer is inKLANGKD_TRUSTED_PROXY_CIDRS(the managed Caddy passes a trusted outer proxy's value through and drops the header it would otherwise derive from a client-chosenHostfor every other peer), else- the
Hostheader, kept only when it names an address klangkd itself serves — loopback, this chapter'stls-hostname, or theKLANGKD_LISTENIP-literal address on the browser port, else - the floor
localhost:<KLANGKD_PORT>(also what an unvalidatedHostcollapses to, and the no-request value, e.g. a CLI handshake).
A client chooses the Host it sends, so klangkd never lets an
unvalidated value become the authority of a password-reset link, a
verification link, an invite, or the OIDC redirect. A deployment whose
browsers reach klangkd by an identity that appears nowhere in its own
configuration (for example KLANGKD_LISTEN=0.0.0.0 reached by DNS
name, or a hostname listen value) pins hosting-hostname so those
URLs name the real public address.
The scheme and subpath follow the same shape: KLANGKD_HOSTING_PROTO
over a trusted X-Forwarded-Proto, and KLANGKD_HOSTING_BASE_PATH
over a trusted X-Forwarded-Prefix (subpath deployments — see
Behind a Reverse Proxy).
In the automatic-TLS model this needs zero extra configuration:
the built-in Caddy terminates TLS and forwards the real Host with
X-Forwarded-Proto: https, and its loopback peer is trusted by
default — so URLs derive as https://<your-fqdn>[:<port>] on their
own. tls-hostname arms the listener; the Host that names it
validates (#3276) and X-Forwarded-Proto carries the scheme into
URLs.
Which to set, by deployment:
| Deployment | Hostname settings to set |
|---|---|
| Internet-facing, automatic TLS (this chapter) | tls-hostname only — URLs derive from the TLS name in Host |
| Behind an outer proxy that forwards truthful headers | nothing (still set trusted-proxy-cidrs) |
Behind an outer proxy that mangles Host/forwarded |
hosting-hostname as the URL pin |
| Behind an outer proxy, encrypted hop wanted | tls-hostname + tls-issuer: internal |
| Plain HTTP, direct browser access by loopback | nothing — a loopback Host on the browser port validates |
Plain HTTP, direct access by the listen IP literal |
nothing — that Host names the listener |
| Plain HTTP, direct access by name, or wildcard bind | hosting-hostname — a name/wildcard never validates |
| URLs come out wrong despite correct headers | hosting-hostname as an explicit override |
Checking the setup¶
Before the first boot, run the pre-flight checker with the arming var exported:
When automatic TLS is armed, doctor checks that ports 80/443 are bindable, at error grade: with TLS armed those ports are part of the proxy config, and an unbindable one stops the klangkd proxy entirely (browser and container-egress listeners) — caddy refuses to load the config, so the proxy stays down until the port is free. The fix hint grants the caddy binary permission to bind privileged ports:
After boot, certificate trouble surfaces in the logs at ERROR level
(lines from Caddy's tls.obtain logger, e.g.
could not get certificate from issuer). Common causes:
- The DNS record does not point at this host yet (or has not propagated).
- Port 80 and/or 443 is firewalled or already bound by another server.
- The caddy binary lacks
cap_net_bind_service(see above). - Too many certificates issued for this hostname recently (CA rate
limit) — usually a symptom of a non-persistent storage dir, which the
explicit
<state_dir>/caddy-storageprevents.
Troubleshooting¶
- Boot fails with "automatic TLS ... requires a newer caddy" — the
detected caddy binary cannot load the global options automatic TLS
needs. Upgrade caddy (e.g. the official caddy repository package), or
unset
KLANGKD_TLS_HOSTNAMEand use the outer-proxy model instead. - Boot fails with "KLANGKD_TLS_HOSTNAME requires KLANGKD_PORT" —
arming needs the browser listener; set
port(443 is conventional). - HTTPS listener unreachable from outside —
listenis still127.0.0.1; set it to0.0.0.0or a specific interface IP. - Certificate not issued — run
klangkd doctor(see above) and check the logs fortls.obtainerrors. An unbindable port 80/443 stops the whole proxy in this mode — caddy refuses the config outright (see the doctor section above).
Notes¶
- Response headers are identical in armed and unarmed mode. HTTPS
changes the listener scheme and the certificate; the header set
stays the same, so HSTS (
Strict-Transport-Security) remains an outer-proxy concern when you want it. #2167 tracks TLS-header options. - The HTTP→HTTPS redirect on port 80 is installed by caddy's automatic
HTTPS and redirects to the armed
https://<hostname>:<port>.