--- # ================================================================================================= # PLEX TLS — a Let's Encrypt certificate for the ONE hostname Plex advertises to plex.tv. # ================================================================================================= # HAND-WRITTEN, NOT RENDERED. A non-catalog app-tier unit anchored to the `plex` ServiceDefinition # and placed into wave 0 by `catalog/render.py --emit-waves` via its EXTRA_UNITS table — the same # mechanism gitea-actions, pihole-dns and plex-remote use. It is hand-written for the same reason: # `schemas/servicedefinition.schema.json` is `additionalProperties: false` and has NO field for a # cert-manager Certificate. (`ingressRoutes` gets TLS for free from the Gateway's wildcard; this is # the opposite case — a certificate for a workload that terminates its OWN TLS on a raw L4 port.) # # WHY PLEX NEEDS ITS OWN CERTIFICATE AT ALL. Plex serves tcp/32400 directly (not through the L7 # Gateway — the operator chose the native path 2026-10-03, apps/wave-0/plex-remote.yaml) and it # presents its own `*..plex.direct` certificate there. That name cannot cover # `plex-vip.${BASE_DOMAIN}`, the hostname we advertise, so without this the advertised `https://` # URL fails hostname verification for every client. That is not hypothetical: it is the shape of # the 2026-10-04 outage, where Plex's log carried a continuous storm of # `CERT: incomplete TLS handshake ... sslv3 alert certificate unknown` from real LAN clients while # plex.tv could publish nothing and the server vanished from the account. # # A SAN match is not what makes Plex FUNCTION — `httpsRequired=0` and `secureConnections` default # to Preferred, so clients can fall back to cleartext. It is what keeps the REMOTE leg encrypted # rather than silently downgrading traffic that crosses the internet. # # HOW IT REACHES PLEX. catalog/plex.yaml takes `tls.crt`/`tls.key` from this Secret as env # (`optional: true` — see below), and its `lifecycle.postStart` hook converts the PEM into the # PKCS#12 bundle Plex's `customCertificatePath` requires and sets the three `customCertificate*` # prefs. The conversion is redone on EVERY pod start, so a cert-manager RENEWAL is picked up with # no extra moving part and nothing to remember. # # ORDERING, AND WHY THE ENV REFS ARE OPTIONAL. This Certificate must live in the `plex` namespace # for the Deployment to read its Secret, and that namespace is created by the rendered # apps/wave-0/plex.yaml ITSELF — so this cannot be issued in an earlier tier (platform-config runs # before the namespace exists) and lands in the SAME reconcile as the workload. On a cold bootstrap # the Secret is therefore absent for as long as a DNS01 challenge takes. With hard env refs Plex # would sit in CreateContainerConfigError until then, and because `apps-wave-0` is `wait: true` # with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE APP TIER. Hence # `optional: true` on both refs (schema 1.11.0, added for exactly this): no certificate means Plex # still starts and serves on its own plex.direct cert, the hook logs that TLS was not installed, # and the next start after issuance converges. # # ISSUER: ClusterIssuer `letsencrypt-prod` (platform/cert-manager/clusterissuers.yaml), the same # issuer behind the Gateway's wildcard (platform/gateway/wildcard-certificate.yaml, Ready 28d on # prod as measured 2026-10-04). A SINGLE name is requested rather than reusing the wildcard, # because the wildcard's Secret lives in the `gateway` namespace and a Secret cannot be read across # namespaces; requesting `*.${BASE_DOMAIN}` again here would also burn a duplicate-certificate slot # against Let's Encrypt's weekly limit for no benefit. A single name is the least-privilege shape: # this key signs for `plex-vip.${BASE_DOMAIN}` and nothing else. apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: plex-vip namespace: plex labels: app.kubernetes.io/name: plex spec: # The name Plex ADVERTISES and the name on this certificate must stay identical. They are two # halves of one decision: catalog/plex.yaml's ADVERTISE_IP, the postStart hook's # customCertificateDomain, this dnsNames entry, and catalog/pihole.yaml's split-horizon answer. # Nothing enforces that they agree. dnsNames: - plex-vip.${BASE_DOMAIN} secretName: plex-vip-tls issuerRef: kind: ClusterIssuer name: letsencrypt-prod