Ingress TLS Configuration
A cluster with no ingress controller ignores every Ingress object silently, so this guide installs one, creates a certificate and a TLS Secret, wires them together and then proves the result the only way that counts - by reading the certificate off the wire.
Cluster Setup Guide 6 of 40 Intermediate
- Kubernetesapiserver v1.36.4, kubelet v1.36.3
- Runtimecontainerd 2.2.6
- CNICilium 1.18.1 - tunnel/VXLAN, with Hubble relay and UI
- Host OSUbuntu 26.04 LTS, kernel 7.0.0-29
- Built withkubeadm v1.36.3 - podSubnet 10.244.0.0/16, serviceSubnet 10.96.0.0/12
- TimeAbout 20 min
- Reviewed25 August 2026
Written against the versions above. ingress-nginx controller v1.15.1, installed from the bare-metal manifest so the controller is published as a NodePort rather than waiting for a load balancer that does not exist here. It is uninstalled at the end of the transcript.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| CKA6001 | 192.168.0.46 | Ubuntu 26.04 LTS | Control Plane Node (tainted NoSchedule) | 2 Core | 4 GB | 50 GB |
| CKA6001-NODE01 | 192.168.0.47 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA6001-NODE02 | 192.168.0.48 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
Before you start
- guide 4 - the traffic this exposes.
-
Install a controller, because a bare cluster has none
The manifest, and what it brings with it.
bash Example session kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/baremetal/deploy.yaml 2>&1 | tail -4; echo "--- an Ingress object does nothing at all without a controller watching for it. This is the first thing to check when an Ingress appears to be ignored"job.batch/ingress-nginx-admission-create createdjob.batch/ingress-nginx-admission-patch createdingressclass.networking.k8s.io/nginx createdvalidatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created--- an Ingress object does nothing at all without a controller watching for it. This is the first thing to check when an Ingress appears to be ignoredkubectl -n ingress-nginx wait --for=condition=Ready pod -l app.kubernetes.io/component=controller --timeout=300s; kubectl get ingressclass --no-headers; echo -n "https NodePort: "; kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}'; echo; echo "--- the controller is up and it registered an IngressClass. On bare metal it is published as a NodePort rather than a load balancer"pod/ingress-nginx-controller-744c769855-bc8l6 condition metnginx k8s.io/ingress-nginx <none> 40shttps NodePort: 32376--- the controller is up and it registered an IngressClass. On bare metal it is published as a NodePort rather than a load balancerExpected resultAn
ingressclass.networking.k8s.io/nginxand avalidatingwebhookconfiguration.../ingress-nginx-admissioncreated, then the controller Pod Ready and an https NodePort like32376.Success conditionA cluster that will actually act on an Ingress object.
-
A backend that speaks no TLS at all
The workload being protected.
bash Example session kubectl create ns cks-shop >/dev/null 2>&1; kubectl -n cks-shop run shop --image=docker.io/library/busybox:1.37 --labels=app=shop --restart=Never --command -- sh -c 'echo SHOP-BACKEND-OK > /tmp/index.html; httpd -f -h /tmp -p 8080' >/dev/null; kubectl -n cks-shop expose pod shop --port=8080 --name=shop >/dev/null; kubectl -n cks-shop wait --for=condition=Ready pod/shop --timeout=150s; echo "--- a plain HTTP backend. It speaks no TLS at all, which is the normal arrangement"pod/shop condition met--- a plain HTTP backend. It speaks no TLS at all, which is the normal arrangementExpected result
pod/shop condition met.Success conditionSomething to put behind the certificate.
-
A certificate, and the Secret that carries it
One name, one key, one object.
bash Example session cd /tmp && rm -rf tlsgen && mkdir tlsgen && cd tlsgen && openssl req -x509 -newkey rsa:2048 -nodes -keyout tls.key -out tls.crt -days 30 -subj '/CN=shop.cks.local' -addext 'subjectAltName=DNS:shop.cks.local' 2>/dev/null && openssl x509 -in tls.crt -noout -subject -ext subjectAltName -dates | head -4; echo "--- a self-signed certificate for one name. In production this comes from a CA or from cert-manager; the Ingress does not care which"subject=CN=shop.cks.localX509v3 Subject Alternative Name: DNS:shop.cks.localnotBefore=Aug 25 19:12:48 2026 GMT--- a self-signed certificate for one name. In production this comes from a CA or from cert-manager; the Ingress does not care whichcd /tmp/tlsgen && kubectl -n cks-shop create secret tls shop-tls --cert=tls.crt --key=tls.key 2>&1 | tail -1; kubectl -n cks-shop get secret shop-tls -o custom-columns='NAME:.metadata.name,TYPE:.type,KEYS:.data' --no-headers | cut -c1-72; echo "--- type kubernetes.io/tls, which is what the controller looks for. The private key is now in the cluster, readable by anyone with get secrets in this namespace"secret/shop-tls createdshop-tls kubernetes.io/tls map[tls.crt:LS0tLS1CRUdJTiBDRVJUSUZJQ0FUR--- type kubernetes.io/tls, which is what the controller looks for. The private key is now in the cluster, readable by anyone with get secrets in this namespaceExpected result
subject=CN=shop.cks.localwithDNS:shop.cks.localas a SAN, then a Secret of typekubernetes.io/tls.Success conditionThe material the controller will serve.
-
The Ingress, and the request that proves it
Four lines of
tls:, then read the certificate off the wire.bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: shop\n namespace: cks-shop\nspec:\n ingressClassName: nginx\n tls:\n - hosts: ["shop.cks.local"]\n secretName: shop-tls\n rules:\n - host: shop.cks.local\n http:\n paths:\n - path: /\n pathType: Prefix\n backend:\n service:\n name: shop\n port:\n number: 8080\n' | kubectl apply -f - 2>&1 | tail -1; sleep 20; kubectl -n cks-shop get ingress shop --no-headers; echo "--- the tls block is four lines and it is the whole difference between this and an unencrypted Ingress"ingress.networking.k8s.io/shop createdshop nginx shop.cks.local 80, 443 20s--- the tls block is four lines and it is the whole difference between this and an unencrypted IngressP=$(kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}'); N=$(kubectl get node cka6001-node01 -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'); echo "--- request to https://shop.cks.local:$P resolved to $N"; curl -sk --resolve shop.cks.local:$P:$N https://shop.cks.local:$P/; echo "--- the backend answered, and the connection to it was TLS"--- request to https://shop.cks.local:32376 resolved to 192.168.0.47SHOP-BACKEND-OK--- the backend answered, and the connection to it was TLSP=$(kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}'); N=$(kubectl get node cka6001-node01 -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'); curl -skv --resolve shop.cks.local:$P:$N https://shop.cks.local:$P/ 2>&1 | grep -E 'subject:|issuer:|SSL connection using|ALPN: server accepted' | head -4; echo "--- the certificate the controller served is the one from the Secret, chosen by the SNI name in the request"* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / RSASSA-PSS* ALPN: server accepted h2* subject: CN=shop.cks.local* issuer: CN=shop.cks.local--- the certificate the controller served is the one from the Secret, chosen by the SNI name in the requestExpected resultThe Ingress showing ports
80, 443, thenSHOP-BACKEND-OK, and from the verbose request:SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384,subject: CN=shop.cks.local,issuer: CN=shop.cks.local.Success conditionTLS proved by the certificate served, not by the YAML applied.
-
What the same controller does to plain HTTP
One request on the http port.
bash Example session P=$(kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.ports[?(@.name=="http")].nodePort}'); N=$(kubectl get node cka6001-node01 -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'); curl -s -o /dev/null -w 'http status %{http_code} -> %{redirect_url}\n' --resolve shop.cks.local:$P:$N http://shop.cks.local:$P/; echo "--- 308 to the https URL. ingress-nginx redirects by default once a host has TLS configured, which is the behaviour you want and should still verify rather than assume"http status 308 -> https://shop.cks.local/--- 308 to the https URL. ingress-nginx redirects by default once a host has TLS configured, which is the behaviour you want and should still verify rather than assumeExpected result
http status 308 -> https://shop.cks.local/.Success conditionYou know whether the plain-text door is closed.
Troubleshooting
An Ingress is created and nothing happens.
Why: No controller, or no matching
ingressClassName.Fix:
kubectl get ingressclassand check a controller Pod is running.The controller serves its own default certificate.
Why: The request's SNI name does not match
tls.hostsor a SAN.Fix:Make the rule host,
tls.hostsand the certificate SAN the same name.Certificate verification fails in a browser but the CN is right.
Why: No Subject Alternative Name. The CN is ignored.
Fix:Reissue with
subjectAltName. Always.The TLS Secret exists and is ignored.
Why: Wrong type or wrong keys - it must be
kubernetes.io/tlswith tls.crt/tls.key.Fix:
kubectl create secret tlsrather than a generic Secret.Testing with a Host header does not select the certificate.
Why: SNI happens during the handshake, before the header is sent.
Fix:Use
curl --resolve name:port:ip.