Comprehensive guide for hardening TLS configuration in Kubernetes using Traefik or Nginx Ingress Controller
Before you start (updated July 2026): two things in this guide have changed since it was first published.
- Ingress NGINX is retired. The Kubernetes project ended maintenance of
ingress-nginx in March 2026 — no more releases, bug fixes, or security patches, and the repository is now read-only. The Nginx section below is still accurate for hardening an existing deployment, but you should be planning a migration. See Ingress NGINX is retired. - Do not pin
curvePreferences to P-521/P-384. It looks like a hardening win but it costs you a round trip on almost every handshake and silently disables post-quantum key exchange. See A note on elliptic curves.
Introduction
The Security Problem
TLS 1.0 and TLS 1.1 are deprecated and insecure protocols that have been superseded by TLS 1.2 and TLS 1.3. Despite this, many web servers and load balancers continue to support these older protocols by default, creating security vulnerabilities.
Common Pentesting Findings
During penetration testing and security audits, assessors commonly flag:
Weak TLS Protocol Support
- TLS 1.0 (released 1999) and TLS 1.1 (released 2006)
- Major browsers dropped both in 2020, and RFC 8996 formally deprecated them in March 2021
- Vulnerable to attacks like BEAST, POODLE, and Lucky13
Weak Cipher Suites
- Non-AEAD (Authenticated Encryption with Associated Data) ciphers
- CBC-mode ciphers vulnerable to padding oracle attacks
- Export-grade ciphers with weak key lengths
- RC4-based ciphers
- 3DES ciphers
Permissive TLS Handshake Settings
- Lack of strict SNI (Server Name Indication) validation
Why This Matters
Compliance Requirements
- PCI DSS v4.0.1: Requires TLS 1.2 or higher. (v4.0.1 is the current standard — v3.2.1 retired on 31 March 2024, so any assessment today is against v4.0.1.)
- NIST SP 800-52 Rev. 2: Recommends disabling TLS 1.0/1.1
- HIPAA: Requires “strong cryptography”
- GDPR: Requires state-of-the-art security measures
- ISO 27001: Mandates secure communication protocols
Real-World Impact
- Data Interception: Weak protocols can be exploited to decrypt traffic
- Man-in-the-Middle Attacks: Downgrade attacks force clients to use weaker protocols
- Compliance Penalties: Non-compliance can result in fines and audit failures
- Reputation Damage: Security breaches erode customer trust
- Browser Warnings: Modern browsers show warnings for sites using deprecated TLS
What We’re Going to Fix
This guide will show you how to:
- ✅ Disable TLS 1.0 and TLS 1.1 completely
- ✅ Enable only TLS 1.2 and TLS 1.3 (if supported)
- ✅ Use only modern AEAD cipher suites (GCM, ChaCha20-Poly1305)
- ✅ Enable strict SNI validation
- ✅ Leave the elliptic curve defaults alone — or, if you must pin them, put X25519 first (see the note on curves)
- ✅ Make these settings cluster-wide for all services
Prerequisites
Required Components
Before starting, ensure you have:
| Component | Version | Purpose |
|---|
| Kubernetes | 1.19+ | networking.k8s.io/v1 Ingress |
| Traefik OR Nginx Ingress | 2.10+ / 1.0+ | Ingress controller (choose one) |
| kubectl | Latest | Kubernetes CLI tool |
| cert-manager | 1.0+ (optional) | Automatic TLS certificate management |
| OpenSSL | 1.1.1+ | For testing and validation |
Traefik version matters. Every TLSOption in this guide uses the traefik.io/v1alpha1 API group. That group was introduced in Traefik 2.10 and is the only one supported in Traefik v3, which is what you should be running today. If you are still on Traefik 2.9 or older, the same resources exist under the old traefik.containo.us/v1alpha1 group — swap the apiVersion and everything else below applies unchanged. Update the CRDs and RBAC before upgrading Traefik, not after.
Note: This guide covers both Traefik and Nginx Ingress Controller. Choose the section that matches your setup:
Required Permissions
You need the following Kubernetes permissions:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Minimum RBAC permissions required
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
rules:
- apiGroups: ["traefik.io"]
resources: ["tlsoptions"]
verbs: ["create", "get", "list", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "update", "patch"]
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list"]
|
Verify Traefik Installation
1
2
3
4
5
6
7
8
| # Check Traefik is installed
kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
# Verify TLSOption CRD is available
kubectl get crd tlsoptions.traefik.io
# Check Traefik version
kubectl get deployment -n kube-system traefik -o jsonpath='{.spec.template.spec.containers[0].image}'
|
Expected output:
1
2
| NAME READY STATUS RESTARTS AGE
traefik-5d4f5c7d9-abcde 1/1 Running 0 10d
|
Choosing Your Ingress Controller
This guide covers TLS hardening for both Traefik and Nginx Ingress Controller. If you’re unsure which one you have, run:
1
2
3
4
5
6
7
8
| # Check for Traefik
kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
kubectl get crd tlsoptions.traefik.io 2>/dev/null
# Check for Nginx Ingress
kubectl get pods -n ingress-nginx \
-l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller
kubectl get configmap -n ingress-nginx ingress-nginx-controller 2>/dev/null
|
Important: Ingress NGINX is retired
If the second command finds pods, read this before you go any further.
The Kubernetes project retired ingress-nginx in March 2026. Concretely:
- No further releases. No new features, no bug fixes, and — most importantly — no patches for security vulnerabilities discovered from now on.
- The repositories are read-only, kept available for reference only.
- InGate, the project that was supposed to replace it, never matured and was retired alongside it.
- Existing deployments keep running, and the installation artifacts stay available. Nothing breaks the day you upgrade your cluster — the risk is slower and worse than that, which is that the next CVE in this controller never gets fixed.
The Kubernetes maintainers were blunt about why: the project ran for years on one or two people’s evenings and weekends, and features like arbitrary-configuration snippets annotations turned into serious security flaws that could not be walked back.
What this means for TLS hardening. Hardening an unmaintained controller is still worth doing — an unpatched controller with TLS 1.0 enabled is strictly worse than an unpatched controller without it — so the Nginx section below remains accurate and you should absolutely apply it. But treat it as care and maintenance for a system on its way out, not as a long-term configuration. Your real project is the migration.
Where to go instead: the Kubernetes project points to the Gateway API as the modern replacement for Ingress. If you need to stay on the Ingress API, Traefik (covered here) is a well-maintained option, as are the other controllers listed in the Kubernetes ingress-controllers docs. See the official announcement, Ingress NGINX Retirement: What You Need to Know.
Quick Comparison
| Feature | Traefik | Nginx Ingress |
|---|
| Configuration Method | Custom Resource (TLSOption CRD) | ConfigMap + Annotations |
| Scope | Namespace-scoped | Cluster-wide (ConfigMap) |
| Granularity | Per-namespace TLS policies | Global with per-ingress overrides |
| Complexity | More Kubernetes-native | More traditional nginx config |
| Dynamic Updates | Automatic | Requires controller reload |
| Best For | Cloud-native, multiple teams | Traditional ops, single config |
Which Section Should You Follow?
Implementation with Traefik
Note: If you’re using Nginx Ingress Controller instead, skip to Implementation with Nginx Ingress
Understanding Traefik Components
Traefik TLSOption CRD
Traefik uses a Custom Resource Definition (CRD) called TLSOption to configure TLS settings. This resource allows you to define:
- Minimum and maximum TLS versions
- Allowed cipher suites
- Elliptic curve preferences
- Client authentication settings
- SNI strictness
Key Concept: TLSOption is a namespaced resource, and a router can only reference a TLSOption by an explicit <namespace>-<name>@kubernetescrd string. So if you want per-namespace TLS policies, yes — each namespace gets its own TLSOption and each Ingress gets an annotation pointing at it. That is the approach the rest of this section walks through.
But there is a shortcut most people miss, and it is almost certainly the one you want:
A TLSOption named default applies to every router that doesn’t specify one. Create a single TLSOption called default, and every Ingress in the cluster inherits it — no annotations, no per-namespace copies, no bulk-patching scripts. Only one TLSOption named default may exist across all namespaces; if you create two, Traefik drops them.
1
2
3
4
5
6
7
8
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: default # The magic name — applies cluster-wide
namespace: kube-system # Wherever Traefik itself runs
spec:
minVersion: VersionTLS12
sniStrict: true
|
That one resource does the job of Step 2 for the entire cluster, and it fails safe: an Ingress someone adds next month is hardened by default instead of quietly inheriting Go’s defaults. Use the explicit per-namespace TLSOptions below when you genuinely need different policies in different namespaces — a TLS 1.3-only API alongside a TLS 1.2 web app, say — and use default for the baseline everything else should get.
How Traefik Processes TLS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| ┌─────────────┐
│ Client │
│ (Browser) │
└──────┬──────┘
│ HTTPS Request
│
▼
┌─────────────────────────────────────┐
│ Traefik Ingress Controller │
│ │
│ 1. Receives connection │
│ 2. Checks Ingress annotations │
│ 3. Loads TLSOption CRD │
│ 4. Negotiates TLS handshake │
│ 5. Applies cipher/version rules │
└──────┬──────────────────────────────┘
│ HTTP (unencrypted)
│
▼
┌─────────────────┐
│ Your Service │
│ (Backend) │
└─────────────────┘
|
Flow Explanation:
- Client initiates HTTPS connection to your domain
- Traefik receives the connection at the edge
- Traefik reads the Ingress resource to find TLS configuration
- The annotation
traefik.ingress.kubernetes.io/router.tls.options points to a TLSOption - Traefik applies the TLSOption rules during TLS handshake
- If negotiation succeeds, traffic is decrypted and forwarded to backend
The annotation uses this format:
1
| traefik.ingress.kubernetes.io/router.tls.options: <namespace>-<name>@kubernetescrd
|
Breaking it down:
<namespace>: The Kubernetes namespace where TLSOption is deployed<name>: The name of the TLSOption resource@kubernetescrd: Tells Traefik to look for a Kubernetes CRD (not a file-based config)
Example:
1
| traefik.ingress.kubernetes.io/router.tls.options: prod-secure-tls@kubernetescrd
|
This references a TLSOption named secure-tls in the prod namespace.
Implementation Steps
Step 1: Create TLSOption Resources
Understanding the TLSOption Specification
Here’s a fully documented TLSOption resource:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: secure-tls
namespace: your-namespace # Must match your application namespace
labels:
security.policy: strict-tls
managed-by: platform-team
spec:
# Minimum TLS version allowed (TLS 1.2)
minVersion: VersionTLS12
# Maximum TLS version (optional, defaults to highest available)
# Don't set this to VersionTLS12 to "avoid" TLS 1.3 — TLS 1.3 is the good one.
# maxVersion: VersionTLS13
# Allowed cipher suites (AEAD only for maximum security).
# Order matters: most preferred first.
# NOTE: this list applies to TLS 1.2 and below ONLY. TLS 1.3's cipher
# suites are fixed by the protocol and are not configurable in Traefik --
# they are all AEAD already, so there is nothing to harden there.
cipherSuites:
# ECDHE with AES-128 in GCM mode (fast, secure)
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
# ECDHE with AES-256 in GCM mode (more secure, slightly slower)
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
# ChaCha20-Poly1305 (modern, fast on mobile)
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
# Elliptic curves for key exchange.
# Best advice: OMIT this field entirely. Go's defaults are already correct
# and give you post-quantum key exchange for free. Read the note below
# before you uncomment this.
# curvePreferences:
# - X25519 # MUST be first if you set this at all
# - CurveP256
# - CurveP384
# Strict SNI: reject connections that send no SNI or an unknown one
sniStrict: true
# Client authentication (optional, for mTLS)
# clientAuth:
# secretNames:
# - ca-cert-secret
# clientAuthType: RequireAndVerifyClientCert
|
A note on elliptic curves (read this before you copy the YAML)
The original version of this guide recommended pinning curvePreferences to CurveP521 and CurveP384, on the reasoning that a bigger curve is a stronger curve. That reasoning is wrong, and the configuration it produces is actively worse than leaving the field out. Here’s why.
It costs you a round trip on nearly every connection. Browsers open a TLS 1.3 handshake by guessing which curve you’ll pick and sending that key share up front. Essentially all of them guess X25519. If your server only accepts P-521 and P-384, the guess is wrong every single time, and the server has to answer with a HelloRetryRequest — “wrong curve, try again” — forcing a second round trip before the handshake can even start. You have added a full RTT of latency to every new connection, worldwide, in exchange for nothing.
It silently disables post-quantum key exchange. Traefik is written in Go, and since Go 1.24 the TLS stack negotiates the hybrid X25519MLKEM768 key exchange by default, which protects today’s traffic against harvest-now-decrypt-later attacks. That default applies only when the curve list is left unset. The moment you specify curvePreferences, you replace the default list — and a list of CurveP521, CurveP384 contains no post-quantum option at all. You have opted out of the single most valuable TLS upgrade of the last decade while believing you were hardening the server.
And P-521 was never “stronger” in any way that matters. X25519 and P-256 both provide ~128-bit security, which is far beyond what any attacker can approach; nothing is being brute-forced at that level. X25519 is additionally easier to implement without side channels, which is the failure mode that actually breaks elliptic curve deployments in the real world.
So:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # ✅ BEST: omit curvePreferences entirely.
# You get X25519 + post-quantum X25519MLKEM768, negotiated correctly.
spec:
minVersion: VersionTLS12
sniStrict: true
# ✅ ACCEPTABLE: if a compliance checklist demands an explicit curve list,
# put X25519 first so the common case doesn't pay for a HelloRetryRequest.
spec:
curvePreferences:
- X25519
- CurveP256
- CurveP384
# ❌ BAD: what this guide used to say. Every browser eats a HelloRetryRequest,
# and post-quantum key exchange is turned off.
spec:
curvePreferences:
- CurveP521
- CurveP384
|
The examples that follow keep curvePreferences commented out for this reason. If you already deployed the P-521/P-384 list from an earlier version of this guide, removing the field is a safe, backwards-compatible change — every client that works today will keep working, and most will get faster.
Cipher Suite Explanation
| Cipher Suite | Key Exchange | Encryption | Authentication | Hash |
|---|
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 | ECDHE | AES-128-GCM | ECDSA | SHA256 |
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 | ECDHE | AES-128-GCM | RSA | SHA256 |
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 | ECDHE | AES-256-GCM | ECDSA | SHA384 |
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 | ECDHE | AES-256-GCM | RSA | SHA384 |
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 | ECDHE | ChaCha20 | ECDSA | Poly1305 |
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 | ECDHE | ChaCha20 | RSA | Poly1305 |
Why these ciphers?
- ✅ ECDHE: Perfect Forward Secrecy (PFS) - past sessions can’t be decrypted if key is compromised
- ✅ GCM/Poly1305: AEAD modes provide encryption + authentication in one operation
- ✅ AES-128/256: Industry standard, hardware-accelerated on most CPUs
- ✅ ChaCha20: Fast on mobile devices without AES acceleration
Why NOT these ciphers?
- ❌ CBC mode: Vulnerable to padding oracle attacks (Lucky13, POODLE)
- ❌ RC4: Known weaknesses, deprecated
- ❌ 3DES: 64-bit block size, sweet32 attack
- ❌ Static RSA: No forward secrecy
Option A: Using kubectl apply
Create a TLSOption file for each namespace:
File: tlsoption-prod.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: secure-tls
namespace: production
spec:
minVersion: VersionTLS12
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
# curvePreferences deliberately omitted -- see the note on elliptic curves
sniStrict: true
|
Apply the resource:
1
2
3
4
5
6
7
8
| # Apply to specific namespace
kubectl apply -f tlsoption-prod.yaml
# Verify creation
kubectl get tlsoption -n production
# View details
kubectl describe tlsoption secure-tls -n production
|
Option B: Using Kustomize (Recommended for Multiple Environments)
Kustomize allows you to define a base configuration and overlay it across multiple environments.
Directory Structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| tls-security/
├── base/
│ ├── kustomization.yaml
│ └── tlsoption.yaml
└── overlays/
├── production/
│ ├── kustomization.yaml
│ └── namespace.yaml
├── staging/
│ ├── kustomization.yaml
│ └── namespace.yaml
└── development/
├── kustomization.yaml
└── namespace.yaml
|
File: base/tlsoption.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: secure-tls
spec:
minVersion: VersionTLS12
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
# curvePreferences deliberately omitted -- see the note on elliptic curves
sniStrict: true
|
File: base/kustomization.yaml
1
2
3
4
5
6
7
8
9
10
11
| apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- tlsoption.yaml
# `labels` replaces `commonLabels`, which is deprecated in Kustomize v5+.
# Unlike commonLabels, this does not inject the labels into selectors.
labels:
- pairs:
security.policy: strict-tls
managed-by: platform-team
|
File: overlays/production/kustomization.yaml
1
2
3
4
5
6
7
8
9
10
| apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- ../../base
labels:
- pairs:
environment: production
|
File: overlays/staging/kustomization.yaml
1
2
3
4
5
6
7
8
9
10
| apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: staging
resources:
- ../../base
labels:
- pairs:
environment: staging
|
Kustomize v5 deprecations: if you are copying older manifests, note that commonLabels is deprecated in favour of labels, and patchesStrategicMerge / patchesJson6902 are both deprecated in favour of a single patches field. Running kustomize edit fix will migrate an existing kustomization.yaml for you.
Deploy with Kustomize:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Preview what will be applied
kubectl kustomize overlays/production
# Apply to production
kubectl apply -k overlays/production
# Apply to staging
kubectl apply -k overlays/staging
# Apply to all environments
for env in production staging development; do
echo "Deploying to $env..."
kubectl apply -k overlays/$env
done
|
Verify:
1
2
3
4
5
6
7
8
| # Check all TLSOptions across namespaces
kubectl get tlsoption -A
# Expected output:
# NAMESPACE NAME AGE
# production secure-tls 30s
# staging secure-tls 30s
# development secure-tls 30s
|
Option C: Using Helm
If you’re using Helm charts, add the TLSOption as a template:
File: templates/tlsoption.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| {{- if .Values.tls.enabled }}
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: {{ .Values.tls.optionName | default "secure-tls" }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
minVersion: {{ .Values.tls.minVersion | default "VersionTLS12" }}
cipherSuites:
{{- toYaml .Values.tls.cipherSuites | nindent 4 }}
{{- with .Values.tls.curvePreferences }}
curvePreferences:
{{- toYaml . | nindent 4 }}
{{- end }}
sniStrict: {{ .Values.tls.sniStrict | default true }}
{{- end }}
|
The with guard matters: it means an empty or absent curvePreferences in your values omits the field from the rendered TLSOption entirely, rather than emitting an empty list. That is what you want — see the note on curves.
File: values.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| tls:
enabled: true
optionName: secure-tls
minVersion: VersionTLS12
sniStrict: true
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
# Leave empty to use Go's defaults (X25519 + post-quantum X25519MLKEM768).
curvePreferences: []
|
Step 2: Annotate Your Ingress Resources
Before: Insecure Ingress (Default Configuration)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# No TLS options specified - uses Traefik defaults
# This allows TLS 1.0/1.1 and weak ciphers!
spec:
ingressClassName: traefik
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-service
port:
number: 80
tls:
- hosts:
- myapp.example.com
secretName: myapp-tls-cert
|
Security Issues:
- ⚠️ No TLS version restrictions
- ⚠️ Allows weak cipher suites
- ⚠️ Vulnerable to downgrade attacks
- ⚠️ Fails PCI DSS compliance
After: Secure Ingress (With TLSOption)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# Add this annotation to reference TLSOption
traefik.ingress.kubernetes.io/router.tls.options: production-secure-tls@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-service
port:
number: 80
tls:
- hosts:
- myapp.example.com
secretName: myapp-tls-cert
|
Security Improvements:
- ✅ TLS 1.2+ enforced
- ✅ Only AEAD cipher suites
- ✅ Strong elliptic curves
- ✅ Strict SNI validation
- ✅ PCI DSS compliant
Annotation Breakdown
1
2
3
4
5
6
| traefik.ingress.kubernetes.io/router.tls.options: production-secure-tls@kubernetescrd
│ │ │ │
│ │ │ └─ Provider type (CRD)
│ │ └─ Resource name
│ └─ Namespace
└─ Traefik annotation key
|
Important Notes:
- Namespace must match: The namespace in the annotation must match where the TLSOption is deployed
- Format is strict: Always use
<namespace>-<name>@kubernetescrd format - One TLSOption per Ingress: Each Ingress can only reference one TLSOption
- A typo fails open, not closed. If the referenced TLSOption doesn’t exist, Traefik falls back to the TLSOption named
default — and if you never created one, to Go’s built-in defaults, which permit TLS 1.0. A misspelled annotation therefore leaves the Ingress unhardened while looking hardened. This is the strongest practical argument for creating a default TLSOption: it makes the fallback path a safe one.
Updating Existing Ingresses
Option 1: Using kubectl patch
1
2
3
4
5
6
| # Patch a single Ingress
kubectl patch ingress myapp-ingress -n production \
-p '{"metadata":{"annotations":{"traefik.ingress.kubernetes.io/router.tls.options":"production-secure-tls@kubernetescrd"}}}'
# Verify the patch
kubectl get ingress myapp-ingress -n production -o yaml | grep tls.options
|
Option 2: Using kubectl edit
1
2
3
| kubectl edit ingress myapp-ingress -n production
# Add the annotation manually in your editor
# Save and exit
|
Option 3: Script for bulk updates
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| #!/bin/bash
# update-ingresses.sh
NAMESPACE="production"
TLSOPTION="production-secure-tls@kubernetescrd"
# Get all ingresses in namespace
INGRESSES=$(kubectl get ingress -n $NAMESPACE -o jsonpath='{.items[*].metadata.name}')
for ingress in $INGRESSES; do
echo "Updating $ingress..."
kubectl patch ingress $ingress -n $NAMESPACE \
-p "{\"metadata\":{\"annotations\":{\"traefik.ingress.kubernetes.io/router.tls.options\":\"$TLSOPTION\"}}}"
done
echo "All ingresses updated!"
|
Helm Chart Integration
If using Helm, update your ingress template:
File: templates/ingress.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| {{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
namespace: {{ .Release.Namespace }}
annotations:
{{- if .Values.ingress.certManager.enabled }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuer }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
traefik.ingress.kubernetes.io/router.tls.options: {{ .Release.Namespace }}-secure-tls@kubernetescrd
{{- end }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
{{- range .Values.ingress.hosts }}
- hosts:
- {{ .host }}
secretName: {{ .host | replace "." "-" }}-tls
{{- end }}
{{- end }}
{{- end }}
|
File: values.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| ingress:
enabled: true
className: traefik
annotations: {}
tls:
enabled: true
certManager:
enabled: true
issuer: letsencrypt-prod
hosts:
- host: myapp.example.com
paths:
- path: /
pathType: Prefix
|
Step 3: Deploy the Changes
Pre-Deployment Checklist
Deployment Strategy
Strategy 1: Gradual Rollout (Recommended)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Step 1: Deploy to development
kubectl apply -k overlays/development
kubectl patch ingress -n development --all \
-p '{"metadata":{"annotations":{"traefik.ingress.kubernetes.io/router.tls.options":"development-secure-tls@kubernetescrd"}}}'
# Wait 10 minutes, monitor logs
sleep 600
# Step 2: Deploy to staging
kubectl apply -k overlays/staging
kubectl patch ingress -n staging --all \
-p '{"metadata":{"annotations":{"traefik.ingress.kubernetes.io/router.tls.options":"staging-secure-tls@kubernetescrd"}}}'
# Wait 30 minutes, run validation
sleep 1800
# Step 3: Deploy to production (during maintenance window)
kubectl apply -k overlays/production
kubectl patch ingress -n production --all \
-p '{"metadata":{"annotations":{"traefik.ingress.kubernetes.io/router.tls.options":"production-secure-tls@kubernetescrd"}}}'
|
Strategy 2: Blue-Green Deployment
1
2
3
4
5
6
7
8
9
10
11
| # Create new ingress with TLS hardening
kubectl apply -f ingress-secure.yaml
# Test the new ingress
curl -v --tls-max 1.1 https://myapp-secure.example.com # Should fail
# Switch traffic (update DNS or load balancer)
# Monitor for 24 hours
# Delete old insecure ingress
kubectl delete ingress myapp-ingress-old -n production
|
Strategy 3: Canary Deployment
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Use Traefik weighted routing
apiVersion: traefik.io/v1alpha1
kind: TraefikService
metadata:
name: myapp-weighted
namespace: production
spec:
weighted:
services:
- name: myapp-secure # New secure ingress
weight: 10 # 10% traffic
- name: myapp-insecure # Old ingress
weight: 90 # 90% traffic
|
Gradually increase weight to secure service:
1
2
3
| # 10% → 50% → 100%
kubectl patch traefikservice myapp-weighted -n production \
--type=json -p='[{"op": "replace", "path": "/spec/weighted/services/0/weight", "value": 50}]'
|
Post-Deployment Verification
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # 1. Verify TLSOptions are active
kubectl get tlsoption -A
# 2. Check Traefik logs for errors
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik --tail=100
# 3. Verify ingress annotations
kubectl get ingress -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\t"}{.metadata.annotations.traefik\.ingress\.kubernetes\.io/router\.tls\.options}{"\n"}{end}'
# 4. Test TLS connection
openssl s_client -connect myapp.example.com:443 -tls1_2
# 5. Monitor error rates
kubectl top pods -n production
|
Verification (Traefik)
1. Check TLSOption Resource Status
1
2
3
4
5
6
7
8
| # List all TLSOptions
kubectl get tlsoption -A
# Describe specific TLSOption
kubectl describe tlsoption secure-tls -n production
# View YAML
kubectl get tlsoption secure-tls -n production -o yaml
|
Expected Output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: secure-tls
namespace: production
creationTimestamp: "2024-01-15T10:00:00Z"
generation: 1
resourceVersion: "12345"
uid: abc-def-ghi
spec:
minVersion: VersionTLS12
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
# ... other ciphers
sniStrict: true
|
2. Verify Ingress Annotations
1
2
3
4
5
6
7
8
| # Check single ingress
kubectl get ingress myapp-ingress -n production -o yaml | grep -A 3 annotations
# List all ingresses with TLS options
kubectl get ingress -A -o custom-columns=\
NAMESPACE:.metadata.namespace,\
NAME:.metadata.name,\
TLS-OPTION:.metadata.annotations.'traefik\.ingress\.kubernetes\.io/router\.tls\.options'
|
Expected Output:
1
2
3
4
| NAMESPACE NAME TLS-OPTION
production myapp-ingress production-secure-tls@kubernetescrd
production api-ingress production-secure-tls@kubernetescrd
staging test-ingress staging-secure-tls@kubernetescrd
|
3. Check Traefik Configuration
1
2
3
4
5
6
7
8
| # Access Traefik dashboard (if enabled)
kubectl port-forward -n kube-system $(kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik -o name) 9000:9000
# Visit http://localhost:9000/dashboard/
# Navigate to: HTTP > Routers > Your Router > TLS
# Or query Traefik API
curl http://localhost:9000/api/http/routers | jq '.[] | select(.name=="myapp") | .tls'
|
4. Monitor Traefik Logs
1
2
3
4
5
6
7
8
| # Watch Traefik logs in real-time
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik -f
# Look for TLS handshake errors
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik | grep -i "tls"
# Check for protocol version rejections
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik | grep -i "protocol version"
|
Good Log Example:
1
| time="2024-01-15T10:05:00Z" level=info msg="TLS connection established" protocol=TLS1.3 cipher=TLS_AES_128_GCM_SHA256
|
Bad Log Example (indicates old clients being rejected):
1
| time="2024-01-15T10:05:00Z" level=warning msg="TLS handshake error" error="tls: protocol version not supported"
|
Implementation with Nginx Ingress
Note: If you’re using Traefik instead, see Implementation with Traefik
Understanding Nginx Components
How Nginx Ingress Handles TLS
Unlike Traefik’s CRD-based approach, Nginx Ingress Controller uses:
- ConfigMap for global TLS settings (cluster-wide)
- Annotations for per-ingress overrides (optional)
- nginx.conf generated dynamically from ConfigMap values
Key Differences from Traefik:
| Aspect | Nginx Ingress | Traefik |
|---|
| Configuration | ConfigMap | Custom Resource (TLSOption) |
| Scope | Cluster-wide | Namespace-scoped |
| Syntax | OpenSSL directives | Traefik DSL |
| Updates | Requires reload | Automatic |
How Nginx Processes TLS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| ┌─────────────┐
│ Client │
│ (Browser) │
└──────┬──────┘
│ HTTPS Request
│
▼
┌─────────────────────────────────────┐
│ Nginx Ingress Controller │
│ │
│ 1. Receives connection │
│ 2. Reads ConfigMap settings │
│ 3. Applies ssl_protocols │
│ 4. Applies ssl_ciphers │
│ 5. Negotiates TLS handshake │
│ 6. Applies per-ingress overrides │
└──────┬──────────────────────────────┘
│ HTTP (unencrypted)
│
▼
┌─────────────────┐
│ Your Service │
│ (Backend) │
└─────────────────┘
|
Configuration Hierarchy:
- Global ConfigMap → Applied to all ingresses by default
- Ingress Annotations → Override global settings for specific ingresses (optional)
- Nginx conf snippets → Advanced customization (not recommended)
Implementation Steps
The primary method for configuring TLS in Nginx Ingress is through the controller’s ConfigMap.
Understanding Nginx TLS Directives
Nginx uses OpenSSL directives for TLS configuration:
| Directive | Type | Default | Purpose |
|---|
ssl-protocols | string | "TLSv1.2 TLSv1.3" | Allowed TLS versions |
ssl-ciphers | string | strong AEAD list | TLS ≤1.2 cipher suite list |
ssl-prefer-server-ciphers | bool | "true" | Use server cipher order |
ssl-ecdh-curve | string | "auto" | Elliptic curve preferences |
ssl-session-cache | bool | "true" | Enable shared session cache |
ssl-session-cache-size | string | "10m" | Size of that cache |
ssl-session-timeout | string | "10m" | Session cache lifetime |
Check your defaults before you change anything. On a current ingress-nginx, ssl-protocols already defaults to TLSv1.2 TLSv1.3 and ssl-ciphers already defaults to a strong AEAD-only list. If a pentest flagged TLS 1.0/1.1 on your cluster, the usual cause is not a missing setting but a present one — somebody overrode the default in the ConfigMap years ago. Read the live ConfigMap first; you may be reverting a change rather than making one.
Option A: Update Existing ConfigMap
First, identify your Nginx Ingress ConfigMap. A current Helm install creates ingress-nginx-controller in the ingress-nginx namespace:
1
2
3
4
5
6
| # List ConfigMaps in the controller namespace
kubectl get configmap -n ingress-nginx
# Ask the controller which ConfigMap it is actually reading
kubectl get deployment ingress-nginx-controller -n ingress-nginx \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep configmap
|
The name nginx-configuration in older guides (including earlier versions of this one) comes from the pre-Helm manifests and is long obsolete. Trust the --configmap= argument on the running controller over any name you read in a blog post — including this one.
Create or update the ConfigMap with secure TLS settings:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller # Confirm with the command above
namespace: ingress-nginx # Use your actual namespace
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/part-of: ingress-nginx
data:
# TLS Protocol versions - Only allow TLS 1.2 and 1.3.
# (This is already the default; set it explicitly so nobody "helpfully"
# adds TLSv1.1 back in a future PR without it being visible in review.)
ssl-protocols: "TLSv1.2 TLSv1.3"
# Cipher suites - AEAD only (GCM and ChaCha20-Poly1305), colon-separated.
# NOTE: nginx's ssl_ciphers only controls TLS 1.2 and below. TLS 1.3 suites
# are fixed by the protocol and are not configurable here -- they are all
# AEAD already, so there is nothing to harden.
ssl-ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305"
# Prefer server cipher order (not client's)
ssl-prefer-server-ciphers: "true"
# Elliptic curves. Leave this at the default "auto" unless you have a
# specific reason not to -- pinning it to big NIST curves costs you a
# round trip on most handshakes. If you must set it, X25519 goes first:
# ssl-ecdh-curve: "X25519:prime256v1:secp384r1"
ssl-ecdh-curve: "auto"
# Optional: TLS session caching for performance.
# ssl-session-cache is a BOOLEAN; the size is a separate key. Setting it to
# an nginx-style "shared:SSL:10m" string (as older guides do) is invalid.
ssl-session-cache: "true"
ssl-session-cache-size: "10m"
ssl-session-timeout: "10m"
# Optional: Enable HSTS (HTTP Strict Transport Security)
hsts: "true"
hsts-max-age: "31536000"
hsts-include-subdomains: "true"
|
Apply the ConfigMap:
1
2
3
4
5
6
7
8
9
10
| # Method 1: Apply from file
kubectl apply -f nginx-tls-configmap.yaml
# Method 2: Patch existing ConfigMap
kubectl patch configmap ingress-nginx-controller -n ingress-nginx \
--type merge \
-p '{"data":{"ssl-protocols":"TLSv1.2 TLSv1.3","ssl-ciphers":"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305","ssl-prefer-server-ciphers":"true","ssl-ecdh-curve":"auto"}}'
# Verify the ConfigMap was updated
kubectl get configmap ingress-nginx-controller -n ingress-nginx -o yaml
|
Cipher Suite Format Conversion
Nginx uses OpenSSL cipher names, which differ slightly from Traefik:
| Traefik Format | Nginx/OpenSSL Format |
|---|
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 | ECDHE-ECDSA-AES128-GCM-SHA256 |
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 | ECDHE-RSA-AES128-GCM-SHA256 |
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 | ECDHE-ECDSA-AES256-GCM-SHA384 |
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 | ECDHE-RSA-AES256-GCM-SHA384 |
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 | ECDHE-ECDSA-CHACHA20-POLY1305 |
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 | ECDHE-RSA-CHACHA20-POLY1305 |
Option B: Using Helm Values (If Installed via Helm)
If you installed Nginx Ingress using Helm, update your values.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
| controller:
config:
ssl-protocols: "TLSv1.2 TLSv1.3"
ssl-ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305"
ssl-prefer-server-ciphers: "true"
ssl-ecdh-curve: "auto"
ssl-session-cache: "true"
ssl-session-cache-size: "10m"
ssl-session-timeout: "10m"
hsts: "true"
hsts-max-age: "31536000"
hsts-include-subdomains: "true"
|
Apply with Helm:
1
2
3
4
5
6
7
8
9
10
| # Upgrade with new values
helm upgrade nginx-ingress ingress-nginx/ingress-nginx \
-n ingress-nginx \
-f values.yaml
# Or set values directly
helm upgrade nginx-ingress ingress-nginx/ingress-nginx \
-n ingress-nginx \
--set controller.config.ssl-protocols="TLSv1.2 TLSv1.3" \
--set controller.config.ssl-ciphers="ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:..."
|
Option C: Using Kustomize
Create a Kustomize patch for the ConfigMap:
File: nginx-tls-patch.yaml
1
2
3
4
5
6
7
8
9
10
| apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
ssl-protocols: "TLSv1.2 TLSv1.3"
ssl-ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305"
ssl-prefer-server-ciphers: "true"
ssl-ecdh-curve: "auto"
|
File: kustomization.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# `patches` replaces the deprecated `patchesStrategicMerge`.
# Note the file is listed ONLY here, not under `resources` -- it is a patch
# against the ConfigMap the ingress-nginx chart already owns, not a resource
# you are creating. Listing it in both places makes Kustomize emit the
# ConfigMap and then patch it with itself, which quietly drops every key the
# chart set that your patch doesn't mention.
patches:
- path: nginx-tls-patch.yaml
target:
kind: ConfigMap
name: ingress-nginx-controller
|
Apply:
1
2
3
4
| # Always preview a patch before applying it
kubectl kustomize .
kubectl apply -k .
|
Step 2: Reload Nginx Ingress Controller
Important: Unlike Traefik, Nginx requires the controller pod to reload configuration after ConfigMap changes.
Option A: Automatic Reload (Preferred)
The Nginx Ingress Controller watches ConfigMap changes and auto-reloads. Wait 30-60 seconds:
1
2
| # Watch for reload in logs
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller -f | grep -i reload
|
Expected output:
1
2
| I0115 10:05:30 backend_ssl.go:42] "Reloading TLS configuration"
I0115 10:05:30 controller.go:168] "Configuration changes detected, backend reload required"
|
Option B: Manual Reload (If Needed)
Force a reload by sending a signal to the controller:
1
2
3
4
5
6
7
8
| # Find the controller pod
NGINX_POD=$(kubectl get pods -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller -o jsonpath='{.items[0].metadata.name}')
# Send reload signal
kubectl exec -n ingress-nginx $NGINX_POD -- /usr/bin/nginx -s reload
# Or restart the pod (more disruptive)
kubectl rollout restart deployment ingress-nginx-controller -n ingress-nginx
|
Step 3: (Optional) Per-Ingress TLS Overrides
If you need different cipher settings for specific ingresses, use annotations:
There is no ssl-protocols annotation. Earlier versions of this guide showed one. It does not exist — ingress-nginx exposes ssl-ciphers and ssl-prefer-server-ciphers per Ingress, but not ssl-protocols. (Don’t confuse it with proxy-ssl-protocols, which controls TLS to your backend, not to the client.) A nonexistent annotation is silently ignored, which is the worst possible failure mode: you get a green kubectl apply, no warning in the logs, and a server that is not doing what you think it is. TLS protocol versions on ingress-nginx are global-only — set them in the ConfigMap.
This also means you cannot make a single Ingress TLS 1.3-only. If you need per-service protocol floors, that is a genuine reason to prefer Traefik, whose TLSOption does support it.
Example: Stricter ciphers for an API Ingress
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# Restrict TLS 1.2 ciphers for this ingress to AES-256 / ChaCha20 only.
# (TLS 1.3 suites are NOT settable here -- ssl_ciphers only affects
# TLS 1.2 and below. Listing TLS_AES_128_GCM_SHA256 & friends does nothing.)
nginx.ingress.kubernetes.io/ssl-ciphers: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305"
nginx.ingress.kubernetes.io/ssl-prefer-server-ciphers: "true"
spec:
ingressClassName: nginx
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
tls:
- hosts:
- api.example.com
secretName: api-tls-cert
|
Example: Standard Web Application
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# No TLS overrides - uses global ConfigMap settings
spec:
ingressClassName: nginx
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80
tls:
- hosts:
- myapp.example.com
secretName: web-tls-cert
|
Available Nginx TLS Annotations:
| Annotation | Purpose | Example |
|---|
nginx.ingress.kubernetes.io/ssl-ciphers | Override TLS ≤1.2 cipher suites | "ECDHE-RSA-..." |
nginx.ingress.kubernetes.io/ssl-prefer-server-ciphers | Prefer server cipher order | "true" |
nginx.ingress.kubernetes.io/ssl-redirect | Force HTTPS redirect | "true" |
nginx.ingress.kubernetes.io/force-ssl-redirect | Force HTTPS even with X-Forwarded-Proto | "true" |
nginx.ingress.kubernetes.io/proxy-ssl-protocols | TLS versions to the backend (not clients) | "TLSv1.3" |
nginx.ingress.kubernetes.io/backend-protocol | Backend protocol | "HTTPS" |
Verification (Nginx)
1. Verify ConfigMap Settings
1
2
3
4
5
| # Check ConfigMap values
kubectl get configmap ingress-nginx-controller -n ingress-nginx -o yaml
# Extract just the TLS settings
kubectl get configmap ingress-nginx-controller -n ingress-nginx -o jsonpath='{.data}' | jq -r 'to_entries[] | select(.key | startswith("ssl")) | "\(.key): \(.value)"'
|
Expected output:
1
2
3
4
5
6
7
| ssl-ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...
ssl-ecdh-curve: auto
ssl-prefer-server-ciphers: true
ssl-protocols: TLSv1.2 TLSv1.3
ssl-session-cache: true
ssl-session-cache-size: 10m
ssl-session-timeout: 10m
|
2. Verify Nginx Configuration
Check that the ConfigMap settings were applied to nginx.conf:
1
2
3
4
5
| # Get the nginx pod name
NGINX_POD=$(kubectl get pods -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller -o jsonpath='{.items[0].metadata.name}')
# View the generated nginx.conf
kubectl exec -n ingress-nginx $NGINX_POD -- cat /etc/nginx/nginx.conf | grep -A 5 "ssl_"
|
Expected output:
1
2
3
4
5
6
| ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
ssl_prefer_server_ciphers on;
ssl_ecdh_curve auto;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
|
3. Test Nginx Configuration Syntax
Verify there are no syntax errors:
1
2
| # Test nginx configuration
kubectl exec -n ingress-nginx $NGINX_POD -- nginx -t
|
Expected output:
1
2
| nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
4. Monitor Nginx Logs
1
2
3
4
5
6
7
8
| # Watch controller logs
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller -f
# Check for TLS-related errors
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller | grep -i "ssl\|tls"
# Check for handshake failures
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller | grep -i "handshake"
|
Good log example:
1
| 2024/01/15 10:05:00 [info] TLS connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
|
Bad log example (indicates old clients being rejected):
1
| 2024/01/15 10:05:00 [error] SSL_do_handshake() failed (SSL: error:1417A0C1:SSL routines:tls_post_process_client_hello:no shared cipher)
|
5. Verify Ingress Resources
1
2
3
4
5
6
7
8
| # List all ingresses
kubectl get ingress -A
# Check specific ingress
kubectl describe ingress myapp-ingress -n production
# Check if TLS is properly configured
kubectl get ingress myapp-ingress -n production -o jsonpath='{.spec.tls}'
|
Troubleshooting (Nginx)
Issue 1: ConfigMap Changes Not Applied
Symptom: TLS settings not taking effect after updating ConfigMap
Diagnosis:
1
2
3
4
5
| # Check if ConfigMap was updated
kubectl get configmap ingress-nginx-controller -n ingress-nginx -o yaml
# Check controller logs for reload
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller --tail=50 | grep reload
|
Solutions:
- Wait for auto-reload (30-60 seconds):
1
2
| # Watch for reload signal
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller -f | grep -i "reload\|backend"
|
- Force manual reload:
1
2
| NGINX_POD=$(kubectl get pods -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ingress-nginx $NGINX_POD -- nginx -s reload
|
- Restart controller (last resort):
1
| kubectl rollout restart deployment ingress-nginx-controller -n ingress-nginx
|
Issue 2: Syntax Error in Cipher List
Symptom: Nginx configuration test fails
Diagnosis:
1
| kubectl exec -n ingress-nginx $NGINX_POD -- nginx -t
|
Error example:
1
2
| nginx: [emerg] SSL_CTX_set_cipher_list("INVALID_CIPHER") failed
nginx: configuration file /etc/nginx/nginx.conf test failed
|
Solutions:
- Check cipher syntax:
1
2
| # Valid OpenSSL cipher names
openssl ciphers -v 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256'
|
- Use colon-separated format (not spaces):
1
2
3
4
5
| # ✅ Correct
ssl-ciphers: "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384"
# ❌ Wrong
ssl-ciphers: "ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384"
|
- Verify cipher names:
1
2
| # Test if ciphers are valid
openssl ciphers -v 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'
|
Issue 3: ConfigMap Name Mismatch
Symptom: Changes to ConfigMap don’t affect nginx
Diagnosis:
1
2
| # Check what ConfigMap the controller is using
kubectl describe deployment ingress-nginx-controller -n ingress-nginx | grep -i configmap
|
Solutions:
Find the correct ConfigMap name:
1
2
3
4
5
| # Common names to check
kubectl get configmap -n ingress-nginx | grep -E "nginx|ingress"
# Check controller args for ConfigMap reference
kubectl get deployment ingress-nginx-controller -n ingress-nginx -o yaml | grep -A 5 "configmap"
|
Update the correct ConfigMap or update the controller to reference your ConfigMap.
Issue 4: TLS Still Accepts Old Protocols
Symptom: TLS 1.0/1.1 still working after configuration
Diagnosis:
1
2
3
4
5
| # Test from outside the cluster
openssl s_client -connect myapp.example.com:443 -tls1_1
# Check actual nginx config
kubectl exec -n ingress-nginx $NGINX_POD -- cat /etc/nginx/nginx.conf | grep ssl_protocols
|
Solutions:
- Ensure ConfigMap data is correctly formatted:
1
2
| data:
ssl-protocols: "TLSv1.2 TLSv1.3" # Must be quoted
|
- Check for per-ingress overrides:
1
2
| # Look for annotations that might override global settings
kubectl get ingress -A -o yaml | grep "ssl-protocols"
|
- Verify reload happened:
1
| kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller | tail -20
|
Issue 5: Certificate Errors
Symptom: ERR_SSL_VERSION_OR_CIPHER_MISMATCH or certificate warnings
Diagnosis:
1
2
3
4
5
| # Check certificate
kubectl get secret myapp-tls -n production -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -noout
# Check if secret exists and is referenced
kubectl get ingress myapp-ingress -n production -o yaml | grep -A 5 tls:
|
Solutions:
- Verify TLS secret exists:
1
| kubectl get secret myapp-tls -n production
|
- Check cert-manager status (if using):
1
2
| kubectl get certificate -n production
kubectl describe certificate myapp-tls -n production
|
- Test certificate validity:
1
| openssl s_client -connect myapp.example.com:443 -showcerts
|
Validation Methods
1. OpenSSL Command-Line Testing
Make sure you are testing the server, not your own OpenSSL. On OpenSSL 3.x (the default on any current distro) TLS 1.0 and 1.1 are disabled client-side at the default security level, so -tls1 can fail with a local error before a single packet leaves your machine — which looks exactly like the server rejecting you. That is a false pass: you will conclude the server is hardened without ever having tested it.
If -tls1 errors out instantly, force your client to be willing to speak it:
1
2
| openssl s_client -connect myapp.example.com:443 -tls1 \
-cipher 'DEFAULT@SECLEVEL=0'
|
If that still fails, your OpenSSL was compiled without TLS 1.0 entirely. Use nmap --script ssl-enum-ciphers or testssl.sh (both below) instead — they probe with raw sockets and don’t depend on your local OpenSSL’s policy.
Test TLS 1.0 (Should Fail)
1
| openssl s_client -connect myapp.example.com:443 -tls1
|
Expected Result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| Connecting to 203.0.113.1
CONNECTED(00000003)
140736242796096:error:1400442E:SSL routines:CONNECT_CR_SRVR_HELLO:tlsv1 alert protocol version:ssl/statem/statem_clnt.c:1269:
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 7 bytes and written 107 bytes
---
New, (NONE), Cipher is (NONE)
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
Protocol : TLSv1
Cipher : 0000
Session-ID:
Session-ID-ctx:
Master-Key:
Start Time: 1673778000
Timeout : 7200 (sec)
Verify return code: 0 (ok)
---
|
✅ PASS: Connection rejected (protocol version error)
Test TLS 1.1 (Should Fail)
1
| openssl s_client -connect myapp.example.com:443 -tls1_1
|
Expected: Similar error as TLS 1.0
Test TLS 1.2 (Should Succeed)
1
| openssl s_client -connect myapp.example.com:443 -tls1_2
|
Expected Result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| Connecting to 203.0.113.1
CONNECTED(00000003)
depth=2 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root CA
verify return:1
depth=1 C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
verify return:1
depth=0 CN = myapp.example.com
verify return:1
---
Certificate chain
0 s:CN = myapp.example.com
i:C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIFmDCCBICgAwIBAgIQBrCWwSZLqqQZV...
-----END CERTIFICATE-----
subject=CN = myapp.example.com
issuer=C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: RSA-PSS
Server Temp Key: X25519, 253 bits
---
SSL handshake has read 3286 bytes and written 395 bytes
Verification: OK
---
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
Server public key is 2048 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
Protocol : TLSv1.2
Cipher : ECDHE-RSA-AES128-GCM-SHA256
Session-ID: 2F3A4B5C6D7E8F...
Session-ID-ctx:
Master-Key: 9E8D7C6B5A4F3E...
PSK identity: None
PSK identity hint: None
SRP username: None
TLS session ticket lifetime hint: 300 (seconds)
TLS session ticket:
0000 - ab cd ef 12 34 56 78 90-...
Start Time: 1673778100
Timeout : 7200 (sec)
Verify return code: 0 (ok)
Extended master secret: yes
---
|
✅ PASS:
- Protocol: TLSv1.2
- Cipher: ECDHE-RSA-AES128-GCM-SHA256 (approved)
- Verification: OK
Test TLS 1.3 (Should Succeed)
1
| openssl s_client -connect myapp.example.com:443 -tls1_3
|
Expected: Similar success, with Protocol: TLSv1.3
Test Specific Cipher Suite
1
2
3
4
5
6
7
8
9
| # Test approved cipher
openssl s_client -connect myapp.example.com:443 \
-tls1_2 -cipher 'ECDHE-RSA-AES128-GCM-SHA256'
# Should succeed
# Test weak cipher (CBC mode)
openssl s_client -connect myapp.example.com:443 \
-tls1_2 -cipher 'AES128-SHA'
# Should fail with "no ciphers available"
|
Comprehensive OpenSSL Test Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
| #!/bin/bash
# tls-test.sh
DOMAIN="$1"
if [ -z "$DOMAIN" ]; then
echo "Usage: $0 <domain>"
exit 1
fi
echo "Testing TLS configuration for $DOMAIN"
echo "======================================="
# Test TLS versions
echo ""
echo "1. Testing TLS Versions"
echo "-----------------------"
for version in tls1 tls1_1 tls1_2 tls1_3; do
echo -n "Testing $version: "
result=$(echo "Q" | timeout 5 openssl s_client -connect $DOMAIN:443 -$version 2>&1)
if echo "$result" | grep -q "Cipher is (NONE)"; then
echo "❌ REJECTED (Good!)"
elif echo "$result" | grep -q "Cipher is"; then
cipher=$(echo "$result" | grep "Cipher is" | head -1 | awk '{print $3}')
echo "✅ ACCEPTED - Cipher: $cipher"
else
echo "⚠️ ERROR - Connection failed"
fi
done
# Test weak ciphers
echo ""
echo "2. Testing Weak Ciphers (should be rejected)"
echo "---------------------------------------------"
WEAK_CIPHERS=(
"DES-CBC3-SHA" # 3DES
"AES128-SHA" # CBC mode
"AES256-SHA" # CBC mode
"RC4-SHA" # RC4
"NULL-SHA256" # NULL encryption
)
for cipher in "${WEAK_CIPHERS[@]}"; do
echo -n "Testing $cipher: "
result=$(echo "Q" | timeout 5 openssl s_client -connect $DOMAIN:443 -cipher $cipher 2>&1)
if echo "$result" | grep -q "Cipher is (NONE)"; then
echo "✅ REJECTED"
else
echo "❌ ACCEPTED (Bad!)"
fi
done
# Test strong ciphers
echo ""
echo "3. Testing Strong Ciphers (should be accepted)"
echo "-----------------------------------------------"
STRONG_CIPHERS=(
"ECDHE-RSA-AES128-GCM-SHA256"
"ECDHE-RSA-AES256-GCM-SHA384"
"ECDHE-ECDSA-AES128-GCM-SHA256"
)
for cipher in "${STRONG_CIPHERS[@]}"; do
echo -n "Testing $cipher: "
result=$(echo "Q" | timeout 5 openssl s_client -connect $DOMAIN:443 -cipher $cipher 2>&1)
if echo "$result" | grep -q "Cipher is" && ! echo "$result" | grep -q "Cipher is (NONE)"; then
echo "✅ ACCEPTED"
else
echo "⚠️ REJECTED or ERROR"
fi
done
echo ""
echo "Testing complete!"
|
Usage:
1
2
| chmod +x tls-test.sh
./tls-test.sh myapp.example.com
|
2. nmap Testing
1
2
3
4
5
6
| # Install nmap
# Ubuntu/Debian: apt install nmap
# macOS: brew install nmap
# Scan TLS protocols and ciphers
nmap --script ssl-enum-ciphers -p 443 myapp.example.com
|
Expected Output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| Starting Nmap 7.93 ( https://nmap.org )
Nmap scan report for myapp.example.com (203.0.113.1)
Host is up (0.012s latency).
PORT STATE SERVICE
443/tcp open https
| ssl-enum-ciphers:
| TLSv1.2:
| ciphers:
| TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (secp256r1) - A
| TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (secp256r1) - A
| TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (secp256r1) - A
| compressors:
| NULL
| cipher preference: server
| warnings:
| Forward Secrecy not supported by all ciphers
| TLSv1.3:
| ciphers:
| TLS_AKE_WITH_AES_128_GCM_SHA256 (secp256r1) - A
| TLS_AKE_WITH_AES_256_GCM_SHA384 (secp384r1) - A
| TLS_AKE_WITH_CHACHA20_POLY1305_SHA256 (secp256r1) - A
| cipher preference: server
|_ least strength: A
Nmap done: 1 IP address (1 host up) scanned in 2.84 seconds
|
✅ PASS Criteria:
- Only TLSv1.2 and TLSv1.3 present
- All ciphers rated “A”
- No TLSv1.0 or TLSv1.1
- No weak ciphers (rated below “A”)
3. testssl.sh (Comprehensive)
testssl.sh is a command-line tool that checks TLS/SSL encryption.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # Install testssl.sh
git clone --depth 1 https://github.com/drwetter/testssl.sh.git
cd testssl.sh
# Run full test
./testssl.sh --full myapp.example.com
# Quick test (protocols and ciphers only)
./testssl.sh --protocols --ciphers myapp.example.com
# Test specific aspects
./testssl.sh --protocols myapp.example.com # Protocol versions
./testssl.sh --standard myapp.example.com # Standard cipher suites
./testssl.sh --server-preference myapp.example.com # Cipher order
./testssl.sh --pfs myapp.example.com # Perfect Forward Secrecy
|
Expected Output (partial):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| ###########################################################
testssl.sh 3.0.8
This program is free software. Distribution and
modification under GPLv2 permitted.
Please file bugs @ https://testssl.sh/bugs/
###########################################################
Start 2024-01-15 10:15:00 -->> 203.0.113.1:443 (myapp.example.com) <<--
rDNS (203.0.113.1): myapp.example.com.
Service detected: HTTP
Testing protocols via sockets
SSLv2 not offered (OK)
SSLv3 not offered (OK)
TLS 1 not offered (OK)
TLS 1.1 not offered (OK)
TLS 1.2 offered (OK)
TLS 1.3 offered (OK): final
Testing cipher categories
NULL ciphers (no encryption) not offered (OK)
Anonymous NULL Ciphers (no authentication) not offered (OK)
Export ciphers (w/o ADH+NULL) not offered (OK)
LOW: 64 Bit + DES, RC[2,4], MD5 (w/o export) not offered (OK)
Triple DES Ciphers / IDEA not offered (OK)
Obsolete CBC ciphers (AES, ARIA etc.) not offered (OK)
Strong encryption (AEAD ciphers) with no FS not offered (OK)
Forward Secrecy strong encryption (AEAD ciphers) offered (OK)
Testing server preferences
Has server cipher order? yes (OK)
Negotiated protocol TLSv1.3
Negotiated cipher TLS_AES_128_GCM_SHA256
Testing ciphers per protocol via OpenSSL
Hexcode Cipher Suite Name (OpenSSL) KeyExch. Encryption Bits
---------------------------------------------------------------------
TLSv1.2 (server order)
xc02f ECDHE-RSA-AES128-GCM-SHA256 ECDH 256 AESGCM 128
xc030 ECDHE-RSA-AES256-GCM-SHA384 ECDH 256 AESGCM 256
xcca8 ECDHE-RSA-CHACHA20-POLY1305 ECDH 256 ChaCha20 256
TLSv1.3 (server order)
x1301 TLS_AES_128_GCM_SHA256 ECDH 253 AESGCM 128
x1302 TLS_AES_256_GCM_SHA384 ECDH 253 AESGCM 256
x1303 TLS_CHACHA20_POLY1305_SHA256 ECDH 253 ChaCha20 256
Rating (experimental)
Rating specs (not complete) SSL Labs's 'SSL Server Rating Guide' (version 2009q)
Specification documentation https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide
Protocol Support (weighted) 100 (30)
Key Exchange (weighted) 100 (30)
Cipher Strength (weighted) 100 (40)
Final Score 100
Overall Grade A+
Done 2024-01-15 10:17:23 [ 143s] -->> 203.0.113.1:443 (myapp.example.com) <<--
|
✅ PASS Criteria:
- SSLv2, SSLv3, TLS 1.0, TLS 1.1 → “not offered (OK)”
- TLS 1.2, TLS 1.3 → “offered (OK)”
- Only AEAD ciphers listed
- Forward Secrecy → “offered (OK)”
- Overall Grade → A or A+
4. curl Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # Test with curl (should work)
curl -v https://myapp.example.com
# Force TLS 1.0 (should fail)
curl --tlsv1.0 --tls-max 1.0 https://myapp.example.com
# Expected: curl: (35) error:1400442E:SSL routines:CONNECT_CR_SRVR_HELLO:tlsv1 alert protocol version
# Force TLS 1.1 (should fail)
curl --tlsv1.1 --tls-max 1.1 https://myapp.example.com
# Force TLS 1.2 (should work)
curl --tlsv1.2 https://myapp.example.com
# Force TLS 1.3 (should work)
curl --tlsv1.3 https://myapp.example.com
# Verbose output shows negotiated cipher
curl -v https://myapp.example.com 2>&1 | grep -E "(SSL|TLS|cipher)"
|
Expected Output:
1
2
3
4
5
6
7
8
9
10
| * TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256
|
5. SSL Labs Online Testing
SSL Labs provides a comprehensive online TLS testing service.
URL: https://www.ssllabs.com/ssltest/
Steps:
- Go to https://www.ssllabs.com/ssltest/analyze.html
- Enter your domain:
myapp.example.com - Check “Do not show the results on the boards”
- Click “Submit”
- Wait 2-5 minutes for scan to complete
Expected Results:
Overall Rating: A or A+
Protocol Support:
- ✅ TLS 1.3: Yes
- ✅ TLS 1.2: Yes
- ❌ TLS 1.1: No
- ❌ TLS 1.0: No
Cipher Suites:
1
2
3
4
5
6
7
8
9
| TLS 1.3 (suites in server-preferred order)
TLS_AES_128_GCM_SHA256 (0x1301) ECDH x25519 (eq. 3072 bits RSA) FS 128
TLS_AES_256_GCM_SHA384 (0x1302) ECDH x25519 (eq. 3072 bits RSA) FS 256
TLS_CHACHA20_POLY1305_SHA256 (0x1303) ECDH x25519 (eq. 3072 bits RSA) FS 256
TLS 1.2 (suites in server-preferred order)
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f) ECDH secp256r1 FS 128
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030) ECDH secp256r1 FS 256
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca8) ECDH secp256r1 FS 256
|
✅ All ciphers should show “FS” (Forward Secrecy)
Protocol Details:
- Secure Renegotiation: Supported
- BEAST attack: Not applicable (TLS 1.0/1.1 disabled)
- POODLE (TLS): No
- Downgrade attack prevention: Yes
- Forward Secrecy: Yes (with most browsers)
Certificate:
- Trusted: Yes
- Chain issues: None
Modern browsers show TLS information in DevTools.
Chrome/Edge:
- Open DevTools (F12)
- Go to Security tab
- Click on main origin
- Check “Connection” section
Expected:
1
2
3
| Connection: TLS 1.3
Cipher suite: TLS_AES_128_GCM_SHA256
Key exchange: X25519
|
Firefox:
- Click lock icon in address bar
- Click “Connection secure”
- Click “More information”
- Click “Technical Details”
Expected:
1
| Connection Encrypted (TLS 1.3, TLS_AES_128_GCM_SHA256, 128 bit keys)
|
7. Automated Monitoring Script
Create a script to continuously monitor TLS configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| #!/bin/bash
# monitor-tls.sh
DOMAIN="$1"
SLACK_WEBHOOK="${2:-}" # Optional Slack webhook for alerts
# Returns 0 if the handshake NEGOTIATED a cipher (i.e. the server accepted it),
# 1 otherwise. Note `grep -q X | grep -v Y` does NOT work here: grep -q writes
# nothing to stdout, so the second grep always sees empty input and fails --
# which silently makes every check pass. Do the negation in one expression.
handshake_succeeds() {
echo "Q" | timeout 5 openssl s_client -connect "$1":443 "${@:2}" 2>/dev/null \
| grep "Cipher is" | grep -qv "(NONE)"
}
check_tls() {
local domain=$1
local issues=0
echo "Checking TLS configuration for $domain..."
# Check TLS 1.0 (should be rejected)
if handshake_succeeds "$domain" -tls1; then
echo "❌ ALERT: TLS 1.0 is enabled!"
((issues++))
fi
# Check TLS 1.1 (should be rejected)
if handshake_succeeds "$domain" -tls1_1; then
echo "❌ ALERT: TLS 1.1 is enabled!"
((issues++))
fi
# Check TLS 1.2 (should be enabled)
if ! handshake_succeeds "$domain" -tls1_2; then
echo "❌ ALERT: TLS 1.2 is NOT enabled!"
((issues++))
fi
# Check for weak cipher (CBC mode)
if handshake_succeeds "$domain" -cipher 'AES128-SHA'; then
echo "❌ ALERT: Weak CBC cipher is enabled!"
((issues++))
fi
if [ $issues -eq 0 ]; then
echo "✅ TLS configuration is secure"
return 0
else
echo "⚠️ Found $issues issue(s)"
# Send Slack alert if webhook provided
if [ -n "$SLACK_WEBHOOK" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"🚨 TLS Configuration Issue on $domain: $issues problems detected\"}" \
"$SLACK_WEBHOOK"
fi
return 1
fi
}
check_tls "$DOMAIN"
|
Run as cron job:
1
2
| # Add to crontab -e
0 */6 * * * /path/to/monitor-tls.sh myapp.example.com https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
Troubleshooting
Issue 1: Ingress Not Using TLSOption
Symptom: Old protocols still accepted after applying TLSOption
Diagnosis:
1
2
3
4
5
| # Check if annotation is present
kubectl get ingress myapp-ingress -n production -o jsonpath='{.metadata.annotations}'
# Check Traefik logs
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik | grep tlsoption
|
Solutions:
- Verify annotation format:
1
2
| # Must be exactly this format
traefik.ingress.kubernetes.io/router.tls.options: <namespace>-<name>@kubernetescrd
|
- Check namespace match:
1
2
3
| # TLSOption namespace must match ingress namespace
kubectl get tlsoption -n production
kubectl get ingress -n production
|
- Restart Traefik:
1
| kubectl rollout restart deployment traefik -n kube-system
|
Issue 2: TLSOption Not Found
Symptom: Traefik logs show “TLSOption not found”
Diagnosis:
1
| kubectl logs -n kube-system -l app.kubernetes.io/name=traefik | grep "not found"
|
Solutions:
- Verify TLSOption exists:
1
| kubectl get tlsoption -A
|
- Check CRD is installed:
1
| kubectl get crd tlsoptions.traefik.io
|
- Recreate TLSOption:
1
2
| kubectl delete tlsoption secure-tls -n production
kubectl apply -f tlsoption-prod.yaml
|
Issue 3: Certificate Errors After Applying TLSOption
Symptom: ERR_SSL_VERSION_OR_CIPHER_MISMATCH in browser
Diagnosis:
1
2
3
4
5
| # Check certificate
openssl s_client -connect myapp.example.com:443 -showcerts
# Check cert-manager logs
kubectl logs -n cert-manager -l app=cert-manager
|
Solutions:
- Verify certificate is valid:
1
2
| kubectl get certificate -n production
kubectl describe certificate myapp-tls -n production
|
- Check certificate issuer:
1
2
| kubectl get clusterissuer
kubectl describe clusterissuer letsencrypt-prod
|
- Force certificate renewal:
1
2
| kubectl delete secret myapp-tls -n production
kubectl annotate certificate myapp-tls -n production cert-manager.io/issue-temporary-certificate="true"
|
Issue 4: Some Clients Can’t Connect
Symptom: Old browsers/clients fail to connect
Diagnosis:
1
2
| # Check client's TLS capabilities
openssl s_client -connect myapp.example.com:443 -debug
|
Solutions:
Check if client supports TLS 1.2+:
- Windows XP: No TLS 1.2 support
- Android 4.4 and older: No TLS 1.2 by default
- Java 7 and older: No TLS 1.2 by default
Temporarily enable TLS 1.1 (not recommended):
1
2
| spec:
minVersion: VersionTLS11 # Only if absolutely necessary
|
- Document minimum client requirements:
- Windows 7+, macOS 10.13+, iOS 11+, Android 5.0+
- Modern browsers (Chrome 30+, Firefox 27+, Safari 7+, Edge all versions)
Symptom: Slower response times after enabling strict TLS
Diagnosis:
1
2
3
4
5
| # Measure TLS handshake time
time openssl s_client -connect myapp.example.com:443 < /dev/null
# Check Traefik resource usage
kubectl top pod -n kube-system -l app.kubernetes.io/name=traefik
|
Solutions:
- Enable TLS session resumption (already enabled by default). If you want HTTP/3 as well, note that it is no longer experimental in Traefik v3 — the old
--experimental.http3=true flag is gone, and it is enabled per entrypoint:
1
2
3
4
5
| # In Traefik v3 Helm values
ports:
websecure:
http3:
enabled: true # Opens UDP/443 -- allow it in your firewall and LB
|
- Use ECDSA certificates (faster than RSA):
1
2
3
4
| spec:
privateKey:
algorithm: ECDSA
size: 256
|
- Increase Traefik resources:
1
2
3
4
5
6
7
| resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi
|
Issue 6: Kustomize Overlay Not Working
Symptom: TLSOption deployed to wrong namespace
Diagnosis:
1
2
3
4
5
| # Preview what will be applied
kubectl kustomize overlays/production
# Check namespace in output
kubectl kustomize overlays/production | grep namespace
|
Solutions:
- Verify kustomization.yaml has namespace:
1
| namespace: production # Must be set
|
- Check directory structure:
1
2
| tree tls-security/
# Should match documented structure
|
- Use explicit namespace:
1
| kubectl apply -k overlays/production --namespace=production
|
Common Error Messages
| Error | Cause | Solution |
|---|
tls: protocol version not supported | Client using TLS 1.0/1.1 | Expected behavior, client needs upgrade |
tls: no cipher suite supported by both client and server | Client doesn’t support AEAD ciphers | Client needs modern TLS stack |
TLSOption not found: production-secure-tls@kubernetescrd | TLSOption missing or wrong name | Create TLSOption, check annotation format |
x509: certificate has expired | TLS certificate expired | Renew certificate, check cert-manager |
remote error: tls: handshake failure | SNI mismatch or invalid cert | Check certificate SAN, verify hostname |
Best Practices
1. Security Best Practices
Use Strong Cipher Suites Only
1
2
3
4
5
6
7
8
9
10
| # ✅ GOOD: Only AEAD ciphers
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
# ❌ BAD: Includes CBC mode
cipherSuites:
- TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 # Vulnerable to padding oracle
|
Enable Strict SNI
1
2
| # Always enable SNI strictness
sniStrict: true
|
This prevents:
- IP-based access to your service
- SNI downgrade attacks
- Certificate enumeration
Know what you’re turning off. “Prevents IP-based access” is the point of sniStrict, and it is also the thing that will page you at 2am. Anything that connects to the ingress by IP address rather than hostname stops working the moment you enable it — external load-balancer health checks, uptime probes pointed at the node IP, curl https://<node-ip> in a runbook, and older clients that don’t send SNI at all. Before enabling it in production, check that every health check and synthetic monitor hits a real hostname. Enable it in staging first and watch for handshake failures.
Don’t Pin Curves At All
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # ✅ BEST: omit curvePreferences. Go negotiates X25519 and the
# post-quantum X25519MLKEM768 hybrid for you.
spec:
minVersion: VersionTLS12
sniStrict: true
# ✅ ACCEPTABLE: if you must list curves, X25519 goes first.
curvePreferences:
- X25519
- CurveP256
- CurveP384
# ❌ BAD: costs a HelloRetryRequest on every browser handshake and
# disables post-quantum key exchange. Bigger curve != stronger.
curvePreferences:
- CurveP521
- CurveP384
|
The one curve worth actually excluding is P-224, which is below the 128-bit security level — but Go does not offer it, so there is nothing for you to do. This is a setting where the default is better than anything you are likely to write. See the full explanation.
2. Operational Best Practices
Version Control All Configurations
1
2
3
4
| # Store in git
git add helm/tlsoption-*.yaml
git commit -m "Add TLS hardening configuration"
git push
|
Use Consistent Naming
1
2
3
4
5
6
| # Follow this pattern across all namespaces
<environment>-<region>-secure-tls
# Examples:
prod-secure-tls
stage-secure-tls
|
Label All Resources
1
2
3
4
5
6
| metadata:
labels:
security.policy: strict-tls
managed-by: platform-team
environment: production
compliance: pci-dss
|
Document Exceptions
If you must support older protocols temporarily:
1
2
3
4
5
6
7
8
9
10
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: legacy-support-tls
annotations:
security.exception: "temporary-support-for-legacy-client"
security.expires: "2024-06-30"
security.approved-by: "security-team"
spec:
minVersion: VersionTLS11 # Exception: legacy client support
|
3. Deployment Best Practices
Always Test in Non-Production First
1
2
3
4
5
6
| # Deployment order
1. Development
2. Staging
3. Production
# Never skip staging!
|
Use Canary Deployments
1
2
3
4
| # Gradual rollout
10% → 25% → 50% → 100%
# Monitor error rates at each step
|
Have a Rollback Plan
1
2
3
4
5
| # Save old configuration
kubectl get ingress myapp-ingress -n production -o yaml > ingress-backup.yaml
# Quick rollback command
kubectl apply -f ingress-backup.yaml
|
4. Monitoring Best Practices
Monitor TLS Handshake Failures
1
2
3
4
| # Alert on handshake failures
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik \
| grep "tls: handshake failure" \
| wc -l
|
Track Protocol Usage
1
2
3
4
5
| # Log which protocols are being used
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik \
| grep "TLS connection" \
| awk '{print $NF}' \
| sort | uniq -c
|
Set Up Alerts
1
2
3
4
5
6
7
8
9
| # Prometheus alert example
- alert: WeakTLSProtocol
expr: |
count(traefik_tls_connections_total{protocol=~"TLS1[01]"}) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Weak TLS protocol detected"
|
5. Compliance Best Practices
Regular Audits
1
2
| # Schedule monthly TLS audits
0 0 1 * * ./tls-audit.sh
|
Document Configuration
Maintain a security configuration document:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # TLS Configuration Standard
## Policy
- Minimum TLS version: 1.2
- Allowed ciphers: AEAD only
- Certificate validity: Max 90 days
- Audits: Monthly
## Exceptions
None currently
## Last Review
2024-01-15 by Security Team
|
Keep Evidence
1
2
3
4
5
| # Save test results for compliance
./testssl.sh myapp.example.com > tls-report-$(date +%Y%m%d).txt
# Store in compliance folder
aws s3 cp tls-report-*.txt s3://compliance-evidence/tls-reports/
|
Advanced Topics
1. Mutual TLS (mTLS)
For API-to-API communication, enable client certificate authentication:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: mtls-strict
namespace: production
spec:
minVersion: VersionTLS12
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
clientAuth:
secretNames:
- client-ca-cert # Secret containing CA certificate
clientAuthType: RequireAndVerifyClientCert
|
Create CA secret:
1
2
3
| kubectl create secret generic client-ca-cert \
--from-file=tls.ca=/path/to/ca.crt \
-n production
|
2. Per-Service TLS Configuration
Different services may need different TLS settings:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Strict for APIs
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: api-strict-tls
namespace: production
spec:
minVersion: VersionTLS13 # TLS 1.3 only
sniStrict: true
---
# Moderate for web apps
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: web-standard-tls
namespace: production
spec:
minVersion: VersionTLS12 # TLS 1.2+
sniStrict: true
|
3. Integration with Service Mesh
If using Istio or Linkerd, coordinate TLS settings:
1
2
3
4
5
6
7
8
9
| # Traefik handles edge TLS
# Service mesh handles internal TLS
# Disable TLS in backend services
apiVersion: v1
kind: Service
metadata:
annotations:
traefik.ingress.kubernetes.io/service.serversscheme: http # Backend is HTTP
|
4. ACME Certificate Automation
Use cert-manager with TLSOption:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: myapp-tls
namespace: production
spec:
secretName: myapp-tls
# cert-manager uses `issuerRef`, not `issuer` -- and the kind must be
# spelled out, or it defaults to a namespaced Issuer and the Certificate
# will sit in a "not found" loop against a ClusterIssuer that does exist.
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
dnsNames:
- myapp.example.com
privateKey:
algorithm: ECDSA # Faster than RSA
size: 256
duration: 2160h # 90 days
renewBefore: 720h # Renew 30 days before expiry
|
5. Geographic TLS Configuration
Different regions may have different compliance requirements:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # EU: GDPR compliant
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: eu-compliant-tls
namespace: prod-eu
spec:
minVersion: VersionTLS12
# ... standard config
---
# US: FIPS compliant
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: us-fips-tls
namespace: prod-us
spec:
minVersion: VersionTLS12
cipherSuites:
# Only FIPS-approved ciphers
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
6. CI/CD Integration
Automate TLS configuration deployment:
1
2
3
4
5
6
7
8
9
10
11
| # .gitlab-ci.yml
deploy-tls-security:
stage: deploy
script:
- kubectl apply -k overlays/$ENVIRONMENT
- ./validate-tls.sh $DOMAIN
only:
changes:
- tls-security/**/*
environment:
name: $ENVIRONMENT
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # GitHub Actions
name: Deploy TLS Configuration
on:
push:
paths:
- "tls-security/**"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy TLSOptions
run: |
kubectl apply -k overlays/production
- name: Validate
run: |
./tls-test.sh myapp.example.com
|
Rollback Procedures
Quick Rollback
If issues arise immediately after deployment:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # 1. Backup current config
kubectl get tlsoption -n production -o yaml > tlsoption-backup.yaml
kubectl get ingress -n production -o yaml > ingress-backup.yaml
# 2. Remove TLS annotation
kubectl annotate ingress myapp-ingress -n production \
traefik.ingress.kubernetes.io/router.tls.options-
# 3. Delete TLSOption
kubectl delete tlsoption secure-tls -n production
# 4. Verify old behavior restored
openssl s_client -connect myapp.example.com:443 -tls1
|
Gradual Rollback
For controlled rollback:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Step 1: Switch 50% of traffic back to old config
# Create parallel ingress without TLSOption
kubectl apply -f ingress-legacy.yaml
# Step 2: Update TraefikService weights
kubectl patch traefikservice myapp-weighted -n production \
--type=json -p='[
{"op": "replace", "path": "/spec/weighted/services/0/weight", "value": 50},
{"op": "replace", "path": "/spec/weighted/services/1/weight", "value": 50}
]'
# Step 3: Monitor for 1 hour
# Step 4: If stable, complete rollback
# Step 5: If issues resolved, roll forward again
|
Emergency Rollback Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| #!/bin/bash
# emergency-rollback.sh
NAMESPACE="$1"
if [ -z "$NAMESPACE" ]; then
echo "Usage: $0 <namespace>"
exit 1
fi
echo "🚨 EMERGENCY ROLLBACK for $NAMESPACE"
echo "Removing TLS security configurations..."
# Remove annotations from all ingresses
for ingress in $(kubectl get ingress -n $NAMESPACE -o name); do
echo "Rolling back $ingress..."
kubectl annotate $ingress -n $NAMESPACE \
traefik.ingress.kubernetes.io/router.tls.options- \
--overwrite
done
# Delete TLSOptions
kubectl delete tlsoption --all -n $NAMESPACE
echo "✅ Rollback complete"
echo "⚠️ WARNING: TLS security is now disabled"
echo " Please investigate and reapply when ready"
|
Appendix
A. Cipher Suite Reference
| Cipher Suite Name | Key Exchange | Encryption | AEAD | Forward Secrecy | Performance | Security |
|---|
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 | ECDHE | AES-128-GCM | Yes | Yes | Fast | High |
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 | ECDHE | AES-128-GCM | Yes | Yes | Fast | High |
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 | ECDHE | AES-256-GCM | Yes | Yes | Medium | Very High |
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 | ECDHE | AES-256-GCM | Yes | Yes | Medium | Very High |
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 | ECDHE | ChaCha20 | Yes | Yes | Fast (mobile) | High |
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 | ECDHE | ChaCha20 | Yes | Yes | Fast (mobile) | High |
B. TLS Version Timeline
| Version | Released | Deprecated | Status | Security Issues |
|---|
| SSL 2.0 | 1995 | 2011 (RFC 6176) | ❌ Forbidden | Multiple critical flaws |
| SSL 3.0 | 1996 | 2015 (RFC 7568) | ❌ Forbidden | POODLE attack |
| TLS 1.0 | 1999 | 2021 (RFC 8996) | ❌ Deprecated | BEAST, downgrade attacks |
| TLS 1.1 | 2006 | 2021 (RFC 8996) | ❌ Deprecated | Limited cipher suites |
| TLS 1.2 | 2008 | - | ✅ Current | Secure with AEAD ciphers |
| TLS 1.3 | 2018 | - | ✅ Recommended | Most secure, fastest |
Browsers dropped TLS 1.0/1.1 in 2020, a year before RFC 8996 made the deprecation formal in March 2021.
C. Compliance Mapping
| Standard | Requirement | This Configuration |
|---|
| PCI DSS v4.0.1 | TLS 1.2+ | ✅ Enforced |
| PCI DSS v4.0.1 | Strong cryptography | ✅ AEAD only |
| NIST SP 800-52 Rev. 2 | Disable TLS 1.0/1.1 | ✅ Disabled |
| NIST SP 800-52 Rev. 2 | Use AEAD ciphers | ✅ Only AEAD |
| HIPAA | Encryption in transit | ✅ TLS 1.2+ |
| GDPR Art. 32 | State-of-the-art security | ✅ Modern ciphers |
| ISO 27001 A.13.1.1 | Network security controls | ✅ Strict TLS |
PCI DSS v3.2.1 retired on 31 March 2024; v4.0.1 is the version you will be assessed against today.
D. Client Compatibility Matrix
| Client | TLS 1.2 | TLS 1.3 | GCM Ciphers | ChaCha20 | Compatible? |
|---|
| Chrome 30+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| Firefox 27+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| Safari 7+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| Edge (all) | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| IE 11 | ✅ | ❌ | ✅ | ❌ | ✅ Yes |
| IE 10 | ✅ | ❌ | ⚠️ | ❌ | ⚠️ Limited |
| Android 5.0+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| Android 4.4 | ⚠️ | ❌ | ⚠️ | ❌ | ⚠️ Limited |
| iOS 11+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| iOS 9-10 | ✅ | ❌ | ✅ | ❌ | ✅ Yes |
| Windows 7 SP1+ | ✅ | ❌ | ✅ | ❌ | ✅ Yes |
| Windows XP | ❌ | ❌ | ❌ | ❌ | ❌ No |
| Java 8+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| Java 7 | ⚠️ | ❌ | ⚠️ | ❌ | ⚠️ Limited |
| Python 3.6+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| Python 2.7 | ⚠️ | ❌ | ⚠️ | ❌ | ⚠️ Limited |
| curl 7.34+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| wget 1.14+ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
E. Useful Commands Reference
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Kubernetes Commands
kubectl get tlsoption -A
kubectl describe tlsoption <name> -n <namespace>
kubectl get ingress -A -o yaml
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik -f
# TLS Testing
openssl s_client -connect domain:443 -tls1_2
openssl s_client -connect domain:443 -showcerts
curl -v --tls-max 1.1 https://domain
nmap --script ssl-enum-ciphers -p 443 domain
# Certificate Management
kubectl get certificate -A
kubectl describe certificate <name> -n <namespace>
openssl x509 -in cert.pem -text -noout
# Debugging
kubectl get events -n <namespace> --sort-by='.lastTimestamp'
kubectl port-forward -n kube-system svc/traefik 9000:9000
|
F. Additional Resources
Ingress Controller Documentation:
General Kubernetes:
Security Standards:
Testing Tools:
Learning Resources:
G. Traefik vs Nginx: Detailed Comparison
This section provides a side-by-side comparison to help you understand the differences between implementing TLS hardening with Traefik versus Nginx Ingress Controller.
Configuration Approach
| Aspect | Traefik | Nginx Ingress |
|---|
| Primary Method | Custom Resource Definition (TLSOption) | ConfigMap |
| Configuration Scope | Namespace-scoped | Cluster-wide |
| Syntax Style | Kubernetes-native YAML | OpenSSL directives |
| File Location | Separate CRD per namespace | Single ConfigMap |
| Versioning | Per namespace versions | Single global version |
Implementation Complexity
| Task | Traefik | Nginx Ingress |
|---|
| Initial Setup | Create TLSOption CRD per namespace | Update single ConfigMap |
| Multi-Team Environments | Easy (namespace isolation) | More complex (shared config) |
| Per-Service Customization | Create different TLSOptions | Use ingress annotations |
| Testing Changes | Deploy to test namespace | Test on non-prod cluster |
| Rollback | Delete/revert specific TLSOption | Revert ConfigMap |
Cipher Suite Syntax
1
2
3
4
5
6
7
| # Traefik Format
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
# Nginx Format
ssl-ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"
|
Operational Characteristics
| Operation | Traefik | Nginx Ingress |
|---|
| Config Updates | Automatic detection | Requires reload (auto or manual) |
| Reload Time | Immediate | 30-60 seconds (automatic) |
| Downtime on Update | Zero downtime | Zero downtime (graceful reload) |
| Monitoring Changes | Watch TLSOption resources | Watch ConfigMap + nginx reloads |
| Debugging | Check TLSOption status | Check nginx.conf + logs |
Example: Setting TLS 1.2 Minimum
Traefik:
1
2
3
4
5
6
7
| apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: secure-tls
namespace: production
spec:
minVersion: VersionTLS12
|
Nginx:
1
2
3
4
5
6
7
| apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
ssl-protocols: "TLSv1.2 TLSv1.3"
|
When to Choose Which
Choose Traefik if:
- You need namespace-level TLS policy isolation
- Multiple teams manage their own ingresses
- You prefer Kubernetes-native CRD approach
- You want automatic updates without reloads
- You’re already using Traefik features (middleware, etc.)
Choose Nginx if:
- ⚠️ Don’t, for anything new. Ingress NGINX is retired as of March 2026 and receives no further security patches. The reasons below explain why teams are on it, not why you should join them:
- You prefer centralized TLS configuration
- You’re familiar with nginx/OpenSSL syntax
- You want simple cluster-wide defaults
- Your team has nginx expertise
- You need nginx-specific features (Lua, modules)
Migration Between Controllers
Traefik → Nginx:
- Export TLSOption settings from each namespace
- Merge into single nginx ConfigMap
- Convert cipher syntax (TLS_ → ECDHE-)
- Update ingress class annotations
- Test thoroughly before switching
Nginx → Traefik:
- Extract ConfigMap ssl-* settings
- Create TLSOption CRD per namespace
- Convert cipher syntax (ECDHE- → TLS_)
- Add TLS annotation to ingresses
- Test namespace by namespace
Conclusion
By following this guide, you have successfully hardened TLS security in your Kubernetes cluster using either Traefik or Nginx Ingress Controller. You have:
- ✅ Disabled insecure TLS 1.0 and TLS 1.1 protocols
- ✅ Enabled only modern AEAD cipher suites (GCM, ChaCha20-Poly1305)
- ✅ Left elliptic curve negotiation at its defaults, keeping X25519 and post-quantum key exchange
- ✅ Enabled strict SNI validation (Traefik) or server cipher preference (Nginx)
- ✅ Applied configuration consistently across environments
- ✅ Validated configuration with multiple methods
- ✅ Established monitoring and alerting
- ✅ Documented configuration for compliance
Your Kubernetes cluster now enforces modern TLS security standards, protecting against known vulnerabilities and meeting compliance requirements for PCI DSS, HIPAA, GDPR, and other security frameworks.
Implementation Summary
If you used Traefik:
- ✅ Created TLSOption CRDs in each namespace
- ✅ Applied TLS annotations to ingress resources
- ✅ Verified TLSOptions are active
If you used Nginx Ingress:
- ✅ Updated the nginx ConfigMap with secure TLS settings
- ✅ Verified nginx reloaded the configuration
- ✅ Confirmed settings in nginx.conf
Next Steps:
- Schedule monthly security audits using SSL Labs or testssl.sh
- Monitor for deprecated protocol connection attempts in controller logs
- Keep cipher suite list updated with current recommendations
- Review client compatibility if adding new integrations
- Document any exceptions with security team approval
- Consider implementing mutual TLS (mTLS) for service-to-service communication
- If you are on Ingress NGINX, start planning your migration — it is retired and will not receive further security patches
Questions or Issues?
- Check the controller-specific Troubleshooting sections:
- Review controller logs:
- Traefik:
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik - Nginx:
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller
- Consult official documentation (see Additional Resources)
- Test configuration changes in non-production first
- Use the Validation Methods section to verify your configuration
Remember: Security is an ongoing process. Regularly review and update your TLS configuration as new vulnerabilities are discovered and security standards evolve.