Ingress with TLS

Terminate HTTPS and force redirect at the edge.

Intermediate12 min · lesson 4 of 24

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.

terminal
# 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 payments
secret/payments-tls created
$ kubectl -n payments get secret payments-tls -o jsonpath='{.type}'
kubernetes.io/tls
ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: payments
namespace: payments
spec:
ingressClassName: nginx
tls:
- hosts: [pay.acme.internal]
secretName: payments-tls # cert served for this host
rules:
- host: pay.acme.internal
http:
paths:
- path: /
pathType: Prefix
backend:
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.

terminal
$ kubectl apply -f ingress.yaml
ingress.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.

ingress.yaml (annotations)
metadata:
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true" # http -> https
nginx.ingress.kubernetes.io/force-ssl-redirect: "true" # force even if this host has no TLS block
terminal
# 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.conf
ssl_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.

terminal
$ curl -sI http://pay.acme.internal/ # must redirect, never serve
HTTP/1.1 308 Permanent Redirect
Location: 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.

What happens to each request at the door
Request hits the Ingress listener
TLS terminated here; one cert for the host
http:// on :80
308 redirect to https://
force-ssl-redirect; no plaintext reaches a pod
https:// TLS 1.2 / 1.3
Handshake completes, routed by host + path
forwarded to the payments-api Service
https:// TLS 1.0 / 1.1
Handshake refused
ConfigMap ssl-protocols allow-list rejects it
The redirect closes the plaintext door; the allow-list closes the weak-crypto door. Both are enforced at the edge, before anything reaches your pods.
Setting ssl-protocols per-Ingress silently does nothing
It's tempting to drop an ssl-protocols annotation onto a single Ingress next to the redirect, and it looks fine because nothing errors. It has no effect. There's no per-Ingress ssl-protocols annotation in ingress-nginx, and even at the nginx level the shared TLS listener settles the protocol version during the handshake, before SNI (Server Name Indication) tells nginx which Ingress you're hitting. So the version can only be set once for the whole listener, in the controller ConfigMap. Put it on the object and old clients keep connecting with no warning in any log. Confirm with a real TLS 1.1 client, not by reading the annotation back.

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.

terminal
# 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:web
no

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.

terminal
$ kubectl -n payments create secret tls payments-tls \
--cert=payments.crt --key=payments.key
secret/payments-tls created
$ kubectl -n payments apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: payments
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts: [payments.example.com]
secretName: payments-tls
rules:
- host: payments.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: payments-api, port: { number: 80 } }
EOF
ingress.networking.k8s.io/payments created
$ curl -sI http://payments.example.com/ | head -3
HTTP/1.1 308 Permanent Redirect
Location: 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.

Quick check
01You add nginx.ingress.kubernetes.io/ssl-protocols: 'TLSv1.3 TLSv1.2' to one Ingress, but curl --tls-max 1.1 still completes the handshake. Why?
Correct — There's no per-Ingress ssl-protocols annotation, and the handshake picks a version before SNI selects your Ingress, so the allow-list only takes effect from the controller ConfigMap. Whatever that ConfigMap currently permits still governs.
Incorrect — No. --tls-max caps the client at 1.1; against a server that refused 1.1 the handshake would fail. The client cap is doing its job.
Incorrect — No. 1.1 and 1.2 are distinct protocol versions with separate records; OpenSSL never merges them.
Incorrect — No. A Subject Alternative Name binds the cert to hostnames and has nothing to do with which protocol versions are allowed.
02Your Ingress force-redirects any plain HTTP request with a 308 to the https:// URL. What gap does that redirect leave that HTTP Strict Transport Security (HSTS) is meant to close?
Incorrect — Blocking weak protocols is the ssl-protocols allow-list's job; HSTS does not control TLS versions.
Correct — The redirect only reacts after a plaintext request arrives; HSTS keeps that first plaintext attempt from ever leaving the browser.
Incorrect — Key access is an RBAC and Secret concern; HSTS is a browser directive with nothing to do with the key.
Incorrect — Port 6443 is the API server, unrelated to the Ingress data path or to HSTS.
03curl -v https://pay.acme.internal/ completes a TLS 1.3 handshake, but the served certificate's subject is the ingress controller's own default 'fake certificate,' not CN=pay.acme.internal. What is the most likely cause?
Incorrect — The handshake completed on TLS 1.3; the protocol version has nothing to do with which certificate is served.
Incorrect — App pods serve behind the proxy and never present the certificate; the controller terminates TLS.
Correct — A hostname or secret mismatch makes the controller quietly serve its default cert; routing still works but the wrong cert is bound to the host.
Incorrect — HSTS is a browser policy about using HTTPS; it does not select or revert the server's certificate.

Related