Free weekly email: the week's new posts, videos and one tip from the books, every Sunday. Get it free →
Homelab the Right Way

Home › Posts

Real HTTPS for your homelab Kubernetes apps without opening a port: k3s Traefik, cert-manager and a DNS-01 wildcard

Self-signed warnings on every app, or port 80 open to the internet for Let's Encrypt? Neither. A DNS-01 wildcard from cert-manager, served as Traefik's default certificate on k3s, gives every internal app a trusted certificate while nothing on your network is reachable from outside.

· 5 min read · kubernetesk3straefikcert-managerlets encrypthomelabtls

Real HTTPS for your homelab Kubernetes apps without opening a port: k3s Traefik, cert-manager and a DNS-01 wildcard

Every web app in a homelab cluster should answer at a proper hostname with a certificate your browser trusts, and you should not have to forward a single port from the internet to get there. On k3s you already have most of the pieces: the Traefik ingress controller ships with it. Add cert-manager, one DNS-01 issuer and one wildcard certificate, and every app you route gets real HTTPS automatically.

The trap

There are two traps, one on each side.

The first is living with certificate warnings: self-signed certificates on every app, a browser you have trained to click "proceed anyway", and a password manager that refuses to autofill. That habit is exactly what an attacker on your network would rely on.

The second is fixing it the obvious way: opening port 80 to the internet so Let's Encrypt can run an HTTP-01 challenge. That publishes your lab to every scanner on the internet for the sake of a padlock icon.

The DNS-01 challenge avoids both. You prove you control the domain by creating a DNS record through your DNS provider's API. Nothing on your network has to be reachable from outside.

How a request reaches a pod

When something does not load, walk this path in order:

  1. Your browser asks LAN DNS for paperless.home.example.com and gets the private address of Traefik's LoadBalancer Service (for example 192.168.40.200, handed out by MetalLB).
  2. The node that holds that address receives the connection and hands it to a Traefik pod.
  3. Traefik terminates TLS, matches the hostname against its routes, and forwards the request to the app's ClusterIP Service.
  4. The Service sends it to one of the app's ready pods.

Step 1: check the Traefik that k3s installed

k3s installs Traefik from a bundled Helm chart into kube-system and registers an IngressClass called traefik. Because it is the only ingress controller in the cluster, it picks up standard Ingress objects without being told.

kubectl get ingressclass
kubectl -n kube-system get deploy,svc traefik
kubectl -n kube-system get helmchart traefik -o yaml | grep -i version

Change Traefik's settings with a HelmChartConfig named traefik, never by editing the generated chart manifest. One gotcha when you copy older snippets: the chart version k3s ships moved the Service type from service.type to service.spec.type, and the k3s documentation warns that the old key is silently ignored.

While you are there, redirect plain HTTP to HTTPS everywhere:

apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    ports:
      web:
        http:
          redirections:
            entryPoint:
              to: websecure
              scheme: https
              permanent: true
kubectl apply -f traefik-config.yaml
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik -w

k3s re-runs the chart with the merged values and Traefik restarts.

A note if you are following an older tutorial that installs ingress-nginx first: in November 2025 Kubernetes SIG Network and the Security Response Committee announced the retirement of ingress-nginx, with best-effort maintenance until March 2026 and no releases or security fixes after that. k3s already gives you Traefik. Translate those guides' Ingress objects to the traefik class, and review any nginx.ingress.kubernetes.io/* annotations, because Traefik ignores them.

Step 2: install cert-manager

cert-manager watches Certificate objects, gets certificates from an issuer such as Let's Encrypt, stores them in Secrets and renews them before they expire. The project publishes its Helm chart in an OCI registry (v1.21.3 at the time of writing; check for the current release):

helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
  --version v1.21.3 \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true
kubectl -n cert-manager get pods

crds.enabled=true lets the chart install and upgrade cert-manager's CustomResourceDefinitions.

Step 3: a DNS-01 issuer

You need a domain you own, hosted at a DNS provider cert-manager can talk to. It supports several directly (Cloudflare, Route 53, Google Cloud DNS, Azure DNS, DigitalOcean and others) and many more through webhooks. With Cloudflare, follow cert-manager's documentation: create an API token, not the global API key, with Zone - DNS - Edit and Zone - Zone - Read, limited to your zone.

kubectl -n cert-manager create secret generic cloudflare-api-token \
  --from-literal=api-token='PASTE-THE-TOKEN'

Then a ClusterIssuer:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token

Create a second issuer named letsencrypt-staging with the server https://acme-staging-v02.api.letsencrypt.org/directory. Staging has much more generous rate limits, so get everything working there first. The token secret lives in the cert-manager namespace because, for a ClusterIssuer, cert-manager looks for referenced secrets in its own namespace by default.

Step 4: one wildcard for every internal name

You could request one certificate per app. A single wildcard for *.home.example.com is simpler and has a privacy benefit: every certificate Let's Encrypt issues is published in public Certificate Transparency logs, and a wildcard publishes *.home.example.com instead of a list of every app you run. Wildcards can only be issued through DNS-01, which is one more reason to use it.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-home
  namespace: kube-system
spec:
  secretName: wildcard-home-tls
  issuerRef:
    name: letsencrypt-staging        # switch to letsencrypt-prod when it works
    kind: ClusterIssuer
  dnsNames:
    - "home.example.com"
    - "*.home.example.com"
kubectl apply -f wildcard.yaml
kubectl -n kube-system get certificate wildcard-home -w     # READY becomes True
kubectl describe certificaterequest -n kube-system          # if it does not

Once the staging certificate shows Ready, change issuerRef.name to letsencrypt-prod, apply again, and delete the old secret so cert-manager issues a fresh one.

Step 5: make it Traefik's default certificate

The certificate lives in kube-system, but an Ingress can only reference Secrets in its own namespace. Instead of copying the secret into every namespace, make it Traefik's default. Traefik serves the default certificate for any TLS connection that no more specific certificate matches:

apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: kube-system
spec:
  defaultCertificate:
    secretName: wildcard-home-tls

Now any Ingress under home.example.com that asks for TLS gets the wildcard automatically, with no secretName at all:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: whoami
  namespace: whoami
spec:
  tls:
    - hosts: ["whoami.home.example.com"]
  rules:
    - host: whoami.home.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami
                port:
                  number: 80

Plain Ingress is a fine choice here. The k3s documentation states that the traditional Ingress API is still supported and not planned for deprecation, and most Helm charts still generate it.

Keep internal names internal

DNS-01 means the certificate is public but the service is not. Nothing in public DNS points at your lab; the only public record cert-manager creates is a temporary TXT record during issuance. *.home.example.com resolves only through your LAN resolver's override to a private address, so from outside your network the names do not resolve and the address is unreachable.

For access from outside the house, use a VPN instead of a port forward. Once your phone is connected, it uses the LAN resolver and every app works exactly as it does at home.

Checklist

  • kubectl get ingressclass shows traefik, and Traefik settings live in one HelmChartConfig.
  • Plain HTTP redirects to HTTPS.
  • cert-manager is installed with crds.enabled=true.
  • The DNS provider credential is a scoped API token, stored in the cert-manager namespace.
  • Staging and production ClusterIssuers exist, and you proved the setup on staging first.
  • One wildcard Certificate is Ready and set as Traefik's default in a TLSStore.
  • No port is forwarded from the internet; remote access goes through a VPN.

More articles