CertGrid CertGrid
Hands-on Lab·Certified Kubernetes Security Specialist

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

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.

The cka6001 cluster: one control plane and 2 schedulable workers, on Cilium.
Server NameIP AddressOSRolesCPURAMHDD
CKA6001192.168.0.46Ubuntu 26.04 LTSControl Plane Node (tainted NoSchedule)2 Core4 GB50 GB
CKA6001-NODE01192.168.0.47Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB
CKA6001-NODE02192.168.0.48Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB

Before you start

  1. 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 balancer

    Expected resultAn ingressclass.networking.k8s.io/nginx and a validatingwebhookconfiguration.../ingress-nginx-admission created, then the controller Pod Ready and an https NodePort like 32376.

    Success conditionA cluster that will actually act on an Ingress object.

  2. 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 arrangement

    Expected resultpod/shop condition met.

    Success conditionSomething to put behind the certificate.

  3. 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 namespace

    Expected resultsubject=CN=shop.cks.local with DNS:shop.cks.local as a SAN, then a Secret of type kubernetes.io/tls.

    Success conditionThe material the controller will serve.

  4. 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 request

    Expected resultThe Ingress showing ports 80, 443, then SHOP-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.

  5. 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 assume

    Expected resulthttp status 308 -> https://shop.cks.local/.

    Success conditionYou know whether the plain-text door is closed.

Troubleshooting

Official sources