Ingress with TLS
Terminate HTTPS and force redirect at the edge.
Half the clusters I get called in to audit terminate TLS (Transport Layer Security, the encryption under HTTPS) at the Ingress and then leave port 80 answering in plaintext, serving the same application with no encryption at all. The Ingress is the cluster's HTTP front door. One controller (ingress-nginx, Traefik, and the like) watches Ingress objects and turns them into a single reverse proxy that routes by hostname and URL path to the right backend Service. Put TLS at that door and one place holds the certificate, speaks HTTPS to the internet, and hands cleartext to Services only over the trusted network inside the cluster. That beats giving every pod its own certificate and its own way to get the setup wrong.
Think of the Ingress as the receptionist at a building's one public entrance. Visitors show ID and talk to the receptionist; staff inside pass notes in the clear because they already trust the floor they're on. Terminating TLS at that entrance means the certificate and its private key sit in exactly one spot instead of scattered across every workload. For anyone doing security work, the Ingress is the seam where public exposure meets your internal Services, so it's where you enforce two house rules: everybody uses HTTPS, and nobody gets to negotiate crypto that broke years ago.
The certificate and private key travel in a TLS-type Secret, under two fixed keys named tls.crt and tls.key. The Ingress points at that Secret by name, so you create the Secret first, then reference it from the tls block and match the hostname. Get the hostname wrong and the controller quietly falls back to its own self-signed default certificate, which is exactly the kind of stale cert you don't want answering for a payments host.
# a TLS secret is just the cert + key under two fixed keys$ kubectl create secret tls payments-tls \--cert=tls.crt --key=tls.key -n paymentssecret/payments-tls created$ kubectl -n payments get secret payments-tls -o jsonpath='{.type}'kubernetes.io/tls
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: paymentsnamespace: paymentsspec:ingressClassName: nginxtls:- hosts: [pay.acme.internal]secretName: payments-tls # cert served for this hostrules:- host: pay.acme.internalhttp:paths:- path: /pathType: Prefixbackend:service: { name: payments-api, port: { number: 8080 } }
Apply it, then check from outside the cluster that the door presents your certificate for that hostname and speaks a current protocol. curl -v prints the handshake, the negotiated version, and the subject line of the served certificate in one shot, so you can confirm the Ingress is serving the cert you meant and not some stale default. Read the SSL line and the subject together: a completed handshake with the wrong subject means routing works but the wrong certificate is bound to that host, and a browser would throw a name-mismatch warning at your users.
$ kubectl apply -f ingress.yamlingress.networking.k8s.io/payments created$ curl -v https://pay.acme.internal/ 2>&1 | grep -E "SSL connection|subject:"* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384* subject: CN=pay.acme.internal
Force HTTPS and refuse weak TLS
Terminating TLS buys you nothing if the door still answers cleartext on :80. There are two separate jobs. The first is the redirect: any plain HTTP request should get a 308 bounce to the https:// URL before it can send a body. The second is choosing which protocol versions the listener will speak. A bouncer who only knows current languages can't be talked into a conversation in one that's been broken for a decade, and TLS 1.0 and 1.1 are that broken decade. You want the handshake to complete only for 1.2 and 1.3.
The redirect is a per-Ingress annotation, so it goes on the object. The protocol allow-list does not. In ingress-nginx the TLS version is a controller-wide setting that lives in the controller's ConfigMap, applied once for the shared listener.
metadata:annotations:nginx.ingress.kubernetes.io/ssl-redirect: "true" # http -> httpsnginx.ingress.kubernetes.io/force-ssl-redirect: "true" # force even if this host has no TLS block
# the protocol allow-list is controller-wide: set it in the ConfigMap, not on the Ingress$ kubectl -n ingress-nginx patch configmap ingress-nginx-controller --type merge \-p '{"data":{"ssl-protocols":"TLSv1.3 TLSv1.2"}}'configmap/ingress-nginx-controller patched# confirm the running proxy actually reloaded with it$ kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- \grep -m1 ssl_protocols /etc/nginx/nginx.confssl_protocols TLSv1.3 TLSv1.2;
Now prove both controls from a client, because a config that looks right and a config that behaves right are different claims. A plain HTTP request should come back as a redirect, never a 200 with a page. And a client that caps itself at TLS 1.1 should get a handshake failure instead of content.
$ curl -sI http://pay.acme.internal/ # must redirect, never serveHTTP/1.1 308 Permanent RedirectLocation: https://pay.acme.internal/$ curl -v --tls-max 1.1 https://pay.acme.internal/ 2>&1 | grep -iE "alert|refused"* OpenSSL/3.0.13: error:0A00042E:SSL routines::tlsv1 alert protocol version
The redirect handles the server's side of the conversation. It doesn't stop a browser that visited you last week from opening this week's session on http:// first, out of habit, and leaking one request before the 308 bounces it. That first plaintext hop is what HSTS (HTTP Strict Transport Security) closes. It's a response header that tells the browser to go straight to HTTPS for this host for a set time, with no plaintext attempt at all. ingress-nginx adds it on HTTPS responses by default with a one-year max-age, so the real job here is to not switch it off and to think twice before shortening that window.
One honest caveat about terminating at the edge. Once the proxy decrypts, the request rides the pod network to your Service as cleartext. For most clusters that internal network is the trust boundary, and this is a fine, deliberate design. When it isn't (a shared multi-tenant cluster, or a compliance rule like PCI DSS, the payment-card standard, that wants traffic encrypted the whole way) you re-encrypt from the Ingress to the backend with the backend-protocol annotation set to HTTPS, or you push mutual TLS (mTLS) between pods with a service mesh. Same front door, but the last hop stops being plaintext.
Last habit, and it's the one people skip. That private key lives in an ordinary Secret, so treat it like any other credential. Encrypt Secrets at rest in etcd, keep get on it scoped tight with RBAC (Role-Based Access Control), and rotate the certificate before it expires, ideally with something like cert-manager handling renewal so a human forgetting isn't your outage. A leaked ingress key is a leaked identity for the whole hostname; anyone holding it can impersonate your site and read whatever a browser sends. Your application pods serve traffic behind the proxy and never need to read the key, so prove they can't.
# app pods serve behind the proxy and never read the key; prove it$ kubectl auth can-i get secret/payments-tls -n payments \--as=system:serviceaccount:payments:webno
Edge termination means the cert lives on the Ingress controller, not in every pod. That is good for rotation and terrible if the Secret is cluster-readable. Pair Ingress TLS with RBAC that limits who can get secrets in that namespace.
HTTP to HTTPS redirects are not optional cosmetics. Bots and misconfigured clients will hit port 80 forever. Annotations like ssl-redirect exist because the default is often too polite.
If traffic stays plaintext from Ingress to the Service, treat that as an internal trust decision you can defend. Prefer mesh mTLS or a private network path when the packet crosses nodes you do not fully trust.
Ingress controllers are privileged enough to deserve their own namespace, hardened chart values, and tight RBAC. An open dashboard or default backend with directory listing has ruined otherwise careful clusters. Treat controller upgrades like control-plane work: read the CVE notes and stage them. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.
Try this
Create a TLS Secret and an Ingress that forces HTTPS. Curl http and https and confirm the redirect and the certificate name.
$ kubectl -n payments create secret tls payments-tls \--cert=payments.crt --key=payments.keysecret/payments-tls created$ kubectl -n payments apply -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: paymentsannotations:nginx.ingress.kubernetes.io/ssl-redirect: "true"nginx.ingress.kubernetes.io/force-ssl-redirect: "true"spec:ingressClassName: nginxtls:- hosts: [payments.example.com]secretName: payments-tlsrules:- host: payments.example.comhttp:paths:- path: /pathType: Prefixbackend:service: { name: payments-api, port: { number: 80 } }EOFingress.networking.k8s.io/payments created$ curl -sI http://payments.example.com/ | head -3HTTP/1.1 308 Permanent RedirectLocation: https://payments.example.com/
Takeaway
Terminate TLS at Ingress with a Secret you control, force HTTPS, and keep backends off the public internet. Weak ciphers and missing redirects are free findings.