Skip to content

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:
sudo setcap 'cap_net_bind_service=+ep' $(readlink -f $(which caddy))

(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):

listen: "0.0.0.0"
port: 443
tls-hostname: "klangk.example.com"
acme-email: "ops@example.com"

(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 as https://<fqdn>:<port>, Caddy's automatic HTTPS takes over (certificate issuance, renewal, HTTP→HTTPS redirect on port 80). Unset (the default) keeps plain HTTP with auto_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). 443 is 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 the 127.0.0.1 default: 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.0 binds 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:

  1. Opens the ACME account with the email (Let's Encrypt first, ZeroSSL as fallback issuer).
  2. Obtains a certificate for the hostname via the HTTP-01 challenge (port 80) and/or TLS-ALPN (port 443).
  3. Serves HTTPS on listen:port and redirects HTTP→HTTPS on port 80.
  4. 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 like 10.0.0.5 all 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-email does 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-hostname is 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 from port), and a bad value refuses to boot. URL generation ignores it — that is hosting-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 the Host klangkd 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):

  1. KLANGKD_HOSTING_HOSTNAME (the explicit pin), else
  2. X-Forwarded-Host — trusted only when the immediate peer is in KLANGKD_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-chosen Host for every other peer), else
  3. the Host header, kept only when it names an address klangkd itself serves — loopback, this chapter's tls-hostname, or the KLANGKD_LISTEN IP-literal address on the browser port, else
  4. the floor localhost:<KLANGKD_PORT> (also what an unvalidated Host collapses 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:

KLANGKD_TLS_HOSTNAME=klangk.example.com klangkd doctor

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:

sudo setcap 'cap_net_bind_service=+ep' $(readlink -f $(which caddy))

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-storage prevents.

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_HOSTNAME and 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 — listen is still 127.0.0.1; set it to 0.0.0.0 or a specific interface IP.
  • Certificate not issued — run klangkd doctor (see above) and check the logs for tls.obtain errors. 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>.