TLS & Ingress¶
Source of truth
hns-platform/roles/portal-tls/ (cert collection + template), roles/cert-manager/ (ClusterIssuers), and roles/hns-service/templates/{ingress,ingress-internal,middleware,middleware-basicauth}.yml.j2. Update this page when those change.
Traefik (bundled with k3s) is the cluster edge. Every public host is served over one shared certificate, and the platform offers two ways to restrict a route: internal-only paths and HTTP basic-auth.
One shared multi-SAN certificate¶
After the per-service deploy loop, the portal-tls role reconciles a single Certificate named hns-portal-tls covering every service's ingress host:
portal-tls role (runs after the service loop):
1. for each service in components.yml:
load srv/<name>/.platform.yml
append every deployments[].ingress.host → portal_hosts
2. render one Certificate hns-portal-tls
issuerRef: letsencrypt-prod (ClusterIssuer)
dnsNames: portal_hosts | unique | sort
3. kubectl apply
Every Ingress references secretName: hns-portal-tls,
so Traefik serves them all from the one cert.
cert-manager then issues/renews the cert (HTTP-01 challenge through Traefik) and is idempotent — it re-issues only when the dnsNames set changes or the cert nears expiry (30 days).
Locally the same secret comes from mkcert
On a local k3d cluster there is no cert-manager and no Let's Encrypt — make local-up fills the same hns-portal-tls secret with a per-developer mkcert wildcard cert. The ingress template is unchanged; only the secret's issuer differs. See Local development (k3d).
Why one cert instead of per-service
Per-Ingress certificates meant a full redeploy fired one Let's Encrypt order per subdomain and burned through rate limits. Collapsing to a single multi-SAN cert means one LE order per redeploy, and adding a subdomain is free.
Adding a subdomain¶
Declare ingress.host in the service's .platform.yml and redeploy. The wildcard DNS *.portal.hnst.dev3.wsagency.io already points at app-01, so no DNS record is needed; the portal-tls role picks up the new host, cert-manager adds the SAN, and Traefik serves it. The first request to a brand-new subdomain may take 1–2 minutes while the challenge completes.
cert-manager¶
- Version:
cert_manager_version: v1.20.2(installed byroles/cert-manager/, idempotently — only re-fetched when absent or version-mismatched). - Issuers: two ClusterIssuers exist —
letsencrypt-prod(default) andletsencrypt-staging. Both use the HTTP-01 challenge via the Traefik ingress class. To debug issuance without hitting prod rate limits, temporarily pointcertificate.yml.j2atletsencrypt-staging. - Account email:
cert_emailinmain.yml.
Public vs internal routes¶
The same Ingress template offers three modifiers, all declared under a deployment's ingress:
| Modifier | Behaviour |
|---|---|
host |
Public Traefik route + TLS via the shared cert. |
internal_paths |
Listed prefixes (e.g. /api/v1/internal) are served only in-cluster; Traefik returns 403 for them from outside. Rendered as a separate internal-deny Ingress + Middleware. |
basic_auth |
HTTP basic-auth on the public host (Traefik Secret + Middleware). Empty = open. |
websocket |
Sticky-cookie annotation so WebSocket upgrades stay pinned to one replica. |
In-cluster traffic never traverses Traefik
internal_paths and basic_auth guard only the public ingress. Service-to-service calls go over ClusterIP DNS (see Service inventory) and bypass Traefik entirely — so, for example, the backend publishing to hns-ticketing-ntfy or the eventbus POSTing to /api/v1/internal/* is unaffected by either guard.
This is also why the backend fetches Keycloak JWKS over the in-cluster http://hns-idp:8080 rather than the public idp.{{ domain }}: the internal fetch needs no TLS, so token validation works even before the LE certificate is issued on a fresh composition. See Compositions & environments.
Last Updated: June 2026