Expose an app with Ingress and TLS
Deploy an image with kubectl left the demo app reachable only from inside the cluster. This guide puts it on the internet under your own domain, with a valid HTTPS certificate: an ingress controller in front, cert-manager fetching certificates from Let’s Encrypt, and one Ingress object per app.
A PaaSbox cluster has the ingress controller already and offers cert-manager as an add-on: follow On a PaaSbox cluster. On any other cluster, follow On any other cluster.
What you need
Section titled “What you need”- The demo app from Deploy an image with kubectl running (namespace
demo, Servicewebon port 80), or any Service you want to expose. - A domain name you control, with access to its DNS records. This guide uses
demo.example.com; put your own in its place throughout.
On a PaaSbox cluster
Section titled “On a PaaSbox cluster”Traefik, the ingress controller k3s ships, runs on every PaaSbox cluster and answers on the server’s
ports 80 and 443; the cluster’s firewall lets both in from everywhere. Its IngressClass is traefik.
Do not install a second ingress controller such as ingress-nginx: Traefik holds both ports, and
the cluster creates no Hetzner load balancers, so a second controller gets neither.
If the cluster runs the PaaSbox Platform add-on (built in a lab, not published yet), it brings its own cert-manager, and the
cert-manager add-on cannot be on next to it. There, an app’s SaaSApplication asks for its
certificate itself: see Learn step 2, Deploy your first app.
1. Switch on the cert-manager add-on
Section titled “1. Switch on the cert-manager add-on”On the cluster’s Add-ons tab, in the cert-manager section, tick On and fill in:
- ACME e-mail: Let’s Encrypt registers the account under it and sends expiry warnings to it.
- Default issuer: keep
production.stagingissues certificates browsers do not trust, without production’s rate limits; use it to try a setup out. - Challenge: keep
http01. Let’s Encrypt fetches a token from your domain over port 80, through Traefik, and nothing else is needed.
Choose Save. The add-on installs cert-manager and two ClusterIssuers, letsencrypt-staging and
letsencrypt-production. In a lab on 2026-10-10 they were ready 21 seconds later:
kubectl get clusterissuers2. Point DNS at the server
Section titled “2. Point DNS at the server”At your DNS provider, create an A record for your host name that points at the server’s public IPv4 address. It is the address the cluster’s API name resolves to, and the Hetzner console shows it on the server:
demo.example.com. 300 IN A <the server's IPv4 address>Check that it resolves before you go on; the certificate cannot be issued before it does:
dig +short demo.example.comThe cluster’s API name is for kubectl, not for your app: give the app a name of its own.
3. Create the Ingress
Section titled “3. Create the Ingress”Save this as ingress.yaml. The annotation names the issuer; ingressClassName names Traefik:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: web namespace: demo annotations: cert-manager.io/cluster-issuer: letsencrypt-productionspec: ingressClassName: traefik tls: - hosts: [demo.example.com] secretName: web-tls rules: - host: demo.example.com http: paths: - path: / pathType: Prefix backend: service: name: web port: { number: 80 }Instead of naming an issuer, the annotation kubernetes.io/tls-acme: "true" takes the add-on’s
Default issuer.
kubectl apply -f ingress.yamlkubectl -n demo get certificate -wREADY turns True once Let’s Encrypt has checked the token. In the lab, a staging certificate was
issued over Traefik in 31 seconds.
4. Verify
Section titled “4. Verify”curl https://demo.example.comcurl -vI https://demo.example.com 2>&1 | grep -E 'subject|issuer'Every further app is one more Ingress: the same Traefik, the same issuer, one manifest per host name.
To clean up, delete the Ingress and the DNS record. Switching the add-on off removes cert-manager and the ClusterIssuers; the certificates and their Secrets stay.
On any other cluster
Section titled “On any other cluster”These steps install an ingress controller and cert-manager with Helm. Step 1 assumes a cluster at
Hetzner whose cloud controller creates load balancers for Services of type LoadBalancer. You also
need helm next to kubectl.
1. Install ingress-nginx
Section titled “1. Install ingress-nginx”The ingress controller receives all HTTP(S) traffic into the cluster and routes it to your Services.
Its Service has type LoadBalancer, so the cloud controller creates a Hetzner load balancer in
your project when you install it:
helm upgrade --install ingress-nginx ingress-nginx \ --repo https://kubernetes.github.io/ingress-nginx \ --namespace ingress-nginx --create-namespace \ --set controller.service.annotations."load-balancer\.hetzner\.cloud/location"=nbg1Set the location annotation to your cluster’s location (nbg1 here). Wait for the load balancer’s
public IP:
kubectl -n ingress-nginx get svc ingress-nginx-controller -wAfter a minute or so the EXTERNAL-IP column fills in. Your DNS record points at it.
The load balancer is on your Hetzner bill. It lives in your Hetzner project, and Hetzner bills you for it directly. One load balancer serves all the apps behind the ingress controller. Uninstalling the controller (see the cleanup) deletes it and stops the charge.
2. Install cert-manager and a Let’s Encrypt issuer
Section titled “2. Install cert-manager and a Let’s Encrypt issuer”helm upgrade --install cert-manager cert-manager \ --repo https://charts.jetstack.io \ --namespace cert-manager --create-namespace \ --set crds.enabled=trueThen give it an account with Let’s Encrypt. Save this as cluster-issuer.yaml and replace the
e-mail with your own:
apiVersion: cert-manager.io/v1kind: ClusterIssuermetadata: name: letsencrypt-prodspec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: you@example.com privateKeySecretRef: name: letsencrypt-prod-account-key solvers: - http01: ingress: ingressClassName: nginxkubectl apply -f cluster-issuer.yamlThis uses the HTTP-01 challenge: Let’s Encrypt fetches a token over plain HTTP through your ingress. No DNS credentials are needed, but the domain must resolve to the load balancer first.
3. Point DNS at the load balancer
Section titled “3. Point DNS at the load balancer”Create an A record for your host name that points at the EXTERNAL-IP from step 1, and check it
with dig +short demo.example.com.
4. Create the Ingress
Section titled “4. Create the Ingress”Use the manifest from step 3 of the PaaSbox section with two changes: the
annotation cert-manager.io/cluster-issuer: letsencrypt-prod and ingressClassName: nginx. Apply it
and watch the certificate:
kubectl apply -f ingress.yamlkubectl -n demo get certificate -wREADY turns True, usually within a minute or two. Verify with curl https://demo.example.com.
Clean up
Section titled “Clean up”kubectl delete -f ingress.yamlhelm -n cert-manager uninstall cert-managerhelm -n ingress-nginx uninstall ingress-nginx # deletes the Hetzner load balancerkubectl delete namespace ingress-nginx cert-managerRemove the DNS record too.
When the certificate stays pending
Section titled “When the certificate stays pending”Almost always DNS: the record does not resolve publicly yet (fresh records and negative caching can
take a few minutes), or it points at the wrong address. kubectl -n demo describe certificaterequest
and cert-manager’s logs say which check failed. Fix the record, and the issuance goes on by itself.
Next steps
Section titled “Next steps”- Run Postgres with CloudNativePG: give the app a database.
- Deploy a Django SaaS: a whole application behind this setup.