Skip to content

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.

  • The demo app from Deploy an image with kubectl running (namespace demo, Service web on 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.

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.

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. staging issues 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:

Terminal window
kubectl get clusterissuers

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:

Terminal window
dig +short demo.example.com

The cluster’s API name is for kubectl, not for your app: give the app a name of its own.

Save this as ingress.yaml. The annotation names the issuer; ingressClassName names Traefik:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
namespace: demo
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
spec:
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.

Terminal window
kubectl apply -f ingress.yaml
kubectl -n demo get certificate -w

READY turns True once Let’s Encrypt has checked the token. In the lab, a staging certificate was issued over Traefik in 31 seconds.

Terminal window
curl https://demo.example.com
curl -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.

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.

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:

Terminal window
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"=nbg1

Set the location annotation to your cluster’s location (nbg1 here). Wait for the load balancer’s public IP:

Terminal window
kubectl -n ingress-nginx get svc ingress-nginx-controller -w

After 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”
Terminal window
helm upgrade --install cert-manager cert-manager \
--repo https://charts.jetstack.io \
--namespace cert-manager --create-namespace \
--set crds.enabled=true

Then 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/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-key
solvers:
- http01:
ingress:
ingressClassName: nginx
Terminal window
kubectl apply -f cluster-issuer.yaml

This 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.

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.

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:

Terminal window
kubectl apply -f ingress.yaml
kubectl -n demo get certificate -w

READY turns True, usually within a minute or two. Verify with curl https://demo.example.com.

Terminal window
kubectl delete -f ingress.yaml
helm -n cert-manager uninstall cert-manager
helm -n ingress-nginx uninstall ingress-nginx # deletes the Hetzner load balancer
kubectl delete namespace ingress-nginx cert-manager

Remove the DNS record too.

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.