Kubernetes TLS Security Hardening Guide (Traefik & Nginx)

Kubernetes TLS hardening with Traefik and Nginx Ingress: disable TLS 1.0 and 1.1, enforce TLS 1.2 and 1.3, and validate cipher suites for secure ingress.

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.

  1. 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.
  2. 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:

  1. 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
  2. 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
  3. 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:

  1. Disable TLS 1.0 and TLS 1.1 completely
  2. Enable only TLS 1.2 and TLS 1.3 (if supported)
  3. Use only modern AEAD cipher suites (GCM, ChaCha20-Poly1305)
  4. Enable strict SNI validation
  5. Leave the elliptic curve defaults alone — or, if you must pin them, put X25519 first (see the note on curves)
  6. Make these settings cluster-wide for all services

Prerequisites

Required Components

Before starting, ensure you have:

ComponentVersionPurpose
Kubernetes1.19+networking.k8s.io/v1 Ingress
Traefik OR Nginx Ingress2.10+ / 1.0+Ingress controller (choose one)
kubectlLatestKubernetes CLI tool
cert-manager1.0+ (optional)Automatic TLS certificate management
OpenSSL1.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

FeatureTraefikNginx Ingress
Configuration MethodCustom Resource (TLSOption CRD)ConfigMap + Annotations
ScopeNamespace-scopedCluster-wide (ConfigMap)
GranularityPer-namespace TLS policiesGlobal with per-ingress overrides
ComplexityMore Kubernetes-nativeMore traditional nginx config
Dynamic UpdatesAutomaticRequires controller reload
Best ForCloud-native, multiple teamsTraditional 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:

  1. Client initiates HTTPS connection to your domain
  2. Traefik receives the connection at the edge
  3. Traefik reads the Ingress resource to find TLS configuration
  4. The annotation traefik.ingress.kubernetes.io/router.tls.options points to a TLSOption
  5. Traefik applies the TLSOption rules during TLS handshake
  6. If negotiation succeeds, traffic is decrypted and forwarded to backend

Annotation Format

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 SuiteKey ExchangeEncryptionAuthenticationHash
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256ECDHEAES-128-GCMECDSASHA256
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256ECDHEAES-128-GCMRSASHA256
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384ECDHEAES-256-GCMECDSASHA384
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384ECDHEAES-256-GCMRSASHA384
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305ECDHEChaCha20ECDSAPoly1305
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305ECDHEChaCha20RSAPoly1305

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:

  1. Namespace must match: The namespace in the annotation must match where the TLSOption is deployed
  2. Format is strict: Always use <namespace>-<name>@kubernetescrd format
  3. One TLSOption per Ingress: Each Ingress can only reference one TLSOption
  4. 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

  • TLSOption resources created in all namespaces
  • Ingress resources updated with annotations
  • Changes tested in non-production first
  • Monitoring/alerting configured
  • Rollback plan prepared
  • Team notified of maintenance window

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)

Immediate Verification

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:

  1. ConfigMap for global TLS settings (cluster-wide)
  2. Annotations for per-ingress overrides (optional)
  3. nginx.conf generated dynamically from ConfigMap values

Key Differences from Traefik:

AspectNginx IngressTraefik
ConfigurationConfigMapCustom Resource (TLSOption)
ScopeCluster-wideNamespace-scoped
SyntaxOpenSSL directivesTraefik DSL
UpdatesRequires reloadAutomatic

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:

  1. Global ConfigMap → Applied to all ingresses by default
  2. Ingress Annotations → Override global settings for specific ingresses (optional)
  3. Nginx conf snippets → Advanced customization (not recommended)

Implementation Steps

Step 1: Configure Global TLS Settings

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:

DirectiveTypeDefaultPurpose
ssl-protocolsstring"TLSv1.2 TLSv1.3"Allowed TLS versions
ssl-ciphersstringstrong AEAD listTLS ≤1.2 cipher suite list
ssl-prefer-server-ciphersbool"true"Use server cipher order
ssl-ecdh-curvestring"auto"Elliptic curve preferences
ssl-session-cachebool"true"Enable shared session cache
ssl-session-cache-sizestring"10m"Size of that cache
ssl-session-timeoutstring"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 FormatNginx/OpenSSL Format
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256ECDHE-ECDSA-AES128-GCM-SHA256
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256ECDHE-RSA-AES128-GCM-SHA256
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384ECDHE-ECDSA-AES256-GCM-SHA384
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384ECDHE-RSA-AES256-GCM-SHA384
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305ECDHE-ECDSA-CHACHA20-POLY1305
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305ECDHE-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:

AnnotationPurposeExample
nginx.ingress.kubernetes.io/ssl-ciphersOverride TLS ≤1.2 cipher suites"ECDHE-RSA-..."
nginx.ingress.kubernetes.io/ssl-prefer-server-ciphersPrefer server cipher order"true"
nginx.ingress.kubernetes.io/ssl-redirectForce HTTPS redirect"true"
nginx.ingress.kubernetes.io/force-ssl-redirectForce HTTPS even with X-Forwarded-Proto"true"
nginx.ingress.kubernetes.io/proxy-ssl-protocolsTLS versions to the backend (not clients)"TLSv1.3"
nginx.ingress.kubernetes.io/backend-protocolBackend 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:

  1. 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"
  1. 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
  1. 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:

  1. Check cipher syntax:
1
2
# Valid OpenSSL cipher names
openssl ciphers -v 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256'
  1. 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"
  1. 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:

  1. Ensure ConfigMap data is correctly formatted:
1
2
data:
  ssl-protocols: "TLSv1.2 TLSv1.3" # Must be quoted
  1. Check for per-ingress overrides:
1
2
# Look for annotations that might override global settings
kubectl get ingress -A -o yaml | grep "ssl-protocols"
  1. 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:

  1. Verify TLS secret exists:
1
kubectl get secret myapp-tls -n production
  1. Check cert-manager status (if using):
1
2
kubectl get certificate -n production
kubectl describe certificate myapp-tls -n production
  1. 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:

  1. Go to https://www.ssllabs.com/ssltest/analyze.html
  2. Enter your domain: myapp.example.com
  3. Check “Do not show the results on the boards”
  4. Click “Submit”
  5. 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

6. Browser DevTools Testing

Modern browsers show TLS information in DevTools.

Chrome/Edge:

  1. Open DevTools (F12)
  2. Go to Security tab
  3. Click on main origin
  4. Check “Connection” section

Expected:

1
2
3
Connection: TLS 1.3
Cipher suite: TLS_AES_128_GCM_SHA256
Key exchange: X25519

Firefox:

  1. Click lock icon in address bar
  2. Click “Connection secure”
  3. Click “More information”
  4. 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:

  1. Verify annotation format:
1
2
# Must be exactly this format
traefik.ingress.kubernetes.io/router.tls.options: <namespace>-<name>@kubernetescrd
  1. Check namespace match:
1
2
3
# TLSOption namespace must match ingress namespace
kubectl get tlsoption -n production
kubectl get ingress -n production
  1. 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:

  1. Verify TLSOption exists:
1
kubectl get tlsoption -A
  1. Check CRD is installed:
1
kubectl get crd tlsoptions.traefik.io
  1. 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:

  1. Verify certificate is valid:
1
2
kubectl get certificate -n production
kubectl describe certificate myapp-tls -n production
  1. Check certificate issuer:
1
2
kubectl get clusterissuer
kubectl describe clusterissuer letsencrypt-prod
  1. 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:

  1. 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
  2. Temporarily enable TLS 1.1 (not recommended):

1
2
spec:
  minVersion: VersionTLS11 # Only if absolutely necessary
  1. 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)

Issue 5: Performance Degradation

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:

  1. 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
  1. Use ECDSA certificates (faster than RSA):
1
2
3
4
spec:
  privateKey:
    algorithm: ECDSA
    size: 256
  1. 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:

  1. Verify kustomization.yaml has namespace:
1
namespace: production # Must be set
  1. Check directory structure:
1
2
tree tls-security/
# Should match documented structure
  1. Use explicit namespace:
1
kubectl apply -k overlays/production --namespace=production

Common Error Messages

ErrorCauseSolution
tls: protocol version not supportedClient using TLS 1.0/1.1Expected behavior, client needs upgrade
tls: no cipher suite supported by both client and serverClient doesn’t support AEAD ciphersClient needs modern TLS stack
TLSOption not found: production-secure-tls@kubernetescrdTLSOption missing or wrong nameCreate TLSOption, check annotation format
x509: certificate has expiredTLS certificate expiredRenew certificate, check cert-manager
remote error: tls: handshake failureSNI mismatch or invalid certCheck 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 NameKey ExchangeEncryptionAEADForward SecrecyPerformanceSecurity
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256ECDHEAES-128-GCMYesYesFastHigh
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256ECDHEAES-128-GCMYesYesFastHigh
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384ECDHEAES-256-GCMYesYesMediumVery High
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384ECDHEAES-256-GCMYesYesMediumVery High
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305ECDHEChaCha20YesYesFast (mobile)High
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305ECDHEChaCha20YesYesFast (mobile)High

B. TLS Version Timeline

VersionReleasedDeprecatedStatusSecurity Issues
SSL 2.019952011 (RFC 6176)❌ ForbiddenMultiple critical flaws
SSL 3.019962015 (RFC 7568)❌ ForbiddenPOODLE attack
TLS 1.019992021 (RFC 8996)❌ DeprecatedBEAST, downgrade attacks
TLS 1.120062021 (RFC 8996)❌ DeprecatedLimited cipher suites
TLS 1.22008-✅ CurrentSecure with AEAD ciphers
TLS 1.32018-✅ RecommendedMost 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

StandardRequirementThis Configuration
PCI DSS v4.0.1TLS 1.2+✅ Enforced
PCI DSS v4.0.1Strong cryptography✅ AEAD only
NIST SP 800-52 Rev. 2Disable TLS 1.0/1.1✅ Disabled
NIST SP 800-52 Rev. 2Use AEAD ciphers✅ Only AEAD
HIPAAEncryption in transit✅ TLS 1.2+
GDPR Art. 32State-of-the-art security✅ Modern ciphers
ISO 27001 A.13.1.1Network 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

ClientTLS 1.2TLS 1.3GCM CiphersChaCha20Compatible?
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

AspectTraefikNginx Ingress
Primary MethodCustom Resource Definition (TLSOption)ConfigMap
Configuration ScopeNamespace-scopedCluster-wide
Syntax StyleKubernetes-native YAMLOpenSSL directives
File LocationSeparate CRD per namespaceSingle ConfigMap
VersioningPer namespace versionsSingle global version

Implementation Complexity

TaskTraefikNginx Ingress
Initial SetupCreate TLSOption CRD per namespaceUpdate single ConfigMap
Multi-Team EnvironmentsEasy (namespace isolation)More complex (shared config)
Per-Service CustomizationCreate different TLSOptionsUse ingress annotations
Testing ChangesDeploy to test namespaceTest on non-prod cluster
RollbackDelete/revert specific TLSOptionRevert 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

OperationTraefikNginx Ingress
Config UpdatesAutomatic detectionRequires reload (auto or manual)
Reload TimeImmediate30-60 seconds (automatic)
Downtime on UpdateZero downtimeZero downtime (graceful reload)
Monitoring ChangesWatch TLSOption resourcesWatch ConfigMap + nginx reloads
DebuggingCheck TLSOption statusCheck 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:

  1. Export TLSOption settings from each namespace
  2. Merge into single nginx ConfigMap
  3. Convert cipher syntax (TLS_ → ECDHE-)
  4. Update ingress class annotations
  5. Test thoroughly before switching

Nginx → Traefik:

  1. Extract ConfigMap ssl-* settings
  2. Create TLSOption CRD per namespace
  3. Convert cipher syntax (ECDHE- → TLS_)
  4. Add TLS annotation to ingresses
  5. 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:

  1. ✅ Disabled insecure TLS 1.0 and TLS 1.1 protocols
  2. ✅ Enabled only modern AEAD cipher suites (GCM, ChaCha20-Poly1305)
  3. ✅ Left elliptic curve negotiation at its defaults, keeping X25519 and post-quantum key exchange
  4. ✅ Enabled strict SNI validation (Traefik) or server cipher preference (Nginx)
  5. ✅ Applied configuration consistently across environments
  6. ✅ Validated configuration with multiple methods
  7. ✅ Established monitoring and alerting
  8. ✅ 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:

  1. Schedule monthly security audits using SSL Labs or testssl.sh
  2. Monitor for deprecated protocol connection attempts in controller logs
  3. Keep cipher suite list updated with current recommendations
  4. Review client compatibility if adding new integrations
  5. Document any exceptions with security team approval
  6. Consider implementing mutual TLS (mTLS) for service-to-service communication
  7. 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.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy