HTTPS Infrastructure

proxmox-k8s · cert-manager + Let's Encrypt DNS-01 via DuckDNS

TLS Request lifecycle

Browser / App homelab .duckdns.org HTTPS Gateway homelab listener: HTTPS :443 tls.mode: Terminate certificateRef: homelab-tls NGINX data plane TLS termination host/path matching → upstream proxy Immich port 2283 (HTTP) via HTTPRoute HTTP Certificate supply chain — runs automatically, not per-request cert-manager watches Gateway annotations creates Certificate CRs provisions homelab-tls DuckDNS webhook sets TXT record via DuckDNS API DNS-01 Let's Encrypt verifies TXT record issues certificate verify
TLS / encrypted
cert-manager
DNS challenge
Let's Encrypt

TLS terminates at the NGINX data plane pod. Traffic between NGINX and backend services stays plain HTTP inside the cluster network. The certificate is stored as a K8s Secret (homelab-tls) and auto-renewed by cert-manager before expiry.

ACME How the certificate gets issued

The DNS-01 challenge proves domain ownership without opening any inbound ports — critical for a homelab behind NAT. The entire flow is outbound from your cluster.

K8s Cluster cert-manager sees annotation on Gateway "homelab" 1 Creates: Certificate CR → Order → Challenge DuckDNS webhook in cert-manager ns 2 Routes DNS-01 challenge to webhook DuckDNS API duckdns.org/update?txt=<challenge> 3 Sets TXT record _acme-challenge.homelab.duckdns.org TXT "xY9z_random_challenge_token..." Let's Encrypt ACME queries DNS for TXT record ✓ match → issues certificate 4 Verifies ownership K8s Secret name: homelab-tls tls.crt + tls.key 5 Stores cert as Secret 6 NGF reloads with new cert

The whole process takes 1–3 minutes. cert-manager auto-renews 30 days before expiry. No port 80 or 443 needs to be open inbound — the DuckDNS webhook calls the DuckDNS API outbound over HTTPS, and Let's Encrypt checks DNS records from its own infrastructure. The dns01-recursive-nameservers flag on cert-manager ensures it uses public DNS (8.8.8.8, 1.1.1.1) to verify propagation, not your local AdGuard resolver.

Why DNS-01 vs HTTP-01

DNS-01 (what we use)

Proves domain ownership by setting a DNS TXT record. Works behind NAT, no open ports needed. Supports wildcard certs. The DuckDNS webhook automates the TXT record via the DuckDNS API.

HTTP-01 (not viable here)

Proves ownership by serving a file on port 80. Requires the Gateway to be publicly reachable from Let's Encrypt servers. Most residential ISPs block inbound port 80, and NAT makes this unreliable. No wildcard cert support.

Components What lives where

cert-manager cert-manager ns

Controller that watches for Certificate CRs and Gateway annotations. Creates ACME orders, delegates DNS-01 challenges to the webhook, and stores issued certs as K8s Secrets. Deployed via ArgoCD helm chart.

DuckDNS webhook cert-manager ns

Kubernetes API extension server that handles DNS-01 challenges for *.duckdns.org domains. When cert-manager needs to prove domain ownership, it calls this webhook, which calls the DuckDNS API to set the _acme-challenge TXT record. Reads the DuckDNS token from a K8s Secret.

ClusterIssuer cluster-scoped

Named letsencrypt-prod. Configures the ACME server URL, solver type (DNS-01 via DuckDNS webhook), and private key storage. Referenced by the Gateway annotation cert-manager.io/cluster-issuer.

Gateway HTTPS listener default ns

The homelab Gateway's HTTPS listener on port 443. References the homelab-tls Secret for the certificate. NGF provisions an NGINX data plane pod that terminates TLS using this cert and proxies to upstream services.

GitOps File map

FilePurposeComponent
gitops/apps/cert-manager.yaml ArgoCD app — cert-manager helm chart cert-manager
gitops/values/cert-manager-values.yaml Helm values — CRDs, DNS01 recursive nameservers cert-manager
gitops/apps/cert-manager-webhook-duckdns.yaml ArgoCD app — DuckDNS webhook helm chart webhook
gitops/values/cert-manager-webhook-duckdns-values.yaml Helm values — group name, existing secret ref webhook
gitops/manifests/letsencrypt-issuer.yaml ClusterIssuer — ACME server + DuckDNS solver issuer
gitops/manifests/gateway/gateway.yaml Gateway — HTTPS listener + cert-manager annotation gateway
secrets/duckdns-token.txt DuckDNS API token (gitignored) secret

Secrets are never committed. Run make duckdns-token to create the K8s Secret from the local file. ArgoCD syncs everything else automatically when you push.

Deploy Activation sequence

  1. Run make duckdns-token — creates the DuckDNS API token as a K8s Secret in the cert-manager namespace.
  2. Push the webhook ArgoCD app + values — ArgoCD deploys the DuckDNS webhook pod into cert-manager namespace.
  3. Push the updated ClusterIssuer — cert-manager registers the DuckDNS solver. Verify with kubectl get clusterissuer letsencrypt-prod — should show Ready: True.
  4. Push the updated Gateway with HTTPS listener — cert-manager sees the cert-manager.io/cluster-issuer annotation and creates a Certificate CR automatically.
  5. cert-manager creates an ACME Order, calls the webhook, which sets the TXT record on DuckDNS. Let's Encrypt verifies and issues the cert. Takes 1–3 minutes.
  6. The certificate is stored as Secret homelab-tls. NGF picks it up and the HTTPS listener goes live. Verify with kubectl get certificate -n default homelab-tls.