Configure TLS routing with TLSRoute
Learn how to configure TLS routing with TLSRoute using NGINX Gateway Fabric.
TLSRoute supports two TLS modes:
- Passthrough: The Gateway reads the SNI and forwards encrypted TCP traffic to the backend. The backend holds and terminates TLS with its own certificate. Use this mode when the backend needs its own certificate, or when you can’t expose the private key to the gateway.
- Terminate: The Gateway holds the certificate, terminates TLS, and forwards plain TCP to the backend. Use this mode when the backend shouldn’t handle TLS, or when it serves a non-HTTP TCP protocol.
You can add an HTTPS listener on the same port that terminates TLS connections, as long as the hostname doesn’t overlap with the TLS listener hostname.
- Install NGINX Gateway Fabric.
Set up cert-manager and a local CA for both examples:
Install cert-manager onto the cluster using Helm with Gateway API features enabled.
-
Add the Helm repository.
shell helm repo add jetstack https://charts.jetstack.io helm repo update -
Install cert-manager, and enable the GatewayAPI feature gate:
shell helm install \ cert-manager jetstack/cert-manager \ --namespace cert-manager \ --create-namespace \ --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \ --set config.kind="ControllerConfiguration" \ --set config.enableGatewayAPI=true \ --set crds.enabled=true
Create a self-signed ClusterIssuer, a CA Certificate, and a CA-backed ClusterIssuer. cert-manager uses the resulting local-ca-issuer to sign certificates in any namespace:
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-cluster-issuer
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: local-ca
namespace: cert-manager
spec:
isCA: true
commonName: LocalCA
secretName: local-ca-secret
issuerRef:
name: selfsigned-cluster-issuer
kind: ClusterIssuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: local-ca-issuer
spec:
ca:
secretName: local-ca-secret
EOFCreate a Certificate for app.example.com. cert-manager creates the app-tls-secret Secret, which contains tls.crt, tls.key, and ca.crt and is mounted by the secure-app Pod:
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: app-cert
namespace: default
spec:
secretName: app-tls-secret
issuerRef:
name: local-ca-issuer
kind: ClusterIssuer
commonName: app.example.com
dnsNames:
- app.example.com
EOFCreate the secure-app application by copying and pasting the following block into your terminal:
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app
spec:
replicas: 1
selector:
matchLabels:
app: secure-app
template:
metadata:
labels:
app: secure-app
spec:
containers:
- name: secure-app
image: nginxdemos/nginx-hello:plain-text
ports:
- containerPort: 8443
volumeMounts:
- name: secret
mountPath: /etc/nginx/ssl
readOnly: true
- name: config-volume
mountPath: /etc/nginx/conf.d
volumes:
- name: secret
secret:
secretName: app-tls-secret
- name: config-volume
configMap:
name: secure-config
---
apiVersion: v1
kind: Service
metadata:
name: secure-app
spec:
ports:
- port: 8443
targetPort: 8443
protocol: TCP
name: https
selector:
app: secure-app
---
apiVersion: v1
kind: ConfigMap
metadata:
name: secure-config
data:
app.conf: |-
server {
listen 8443 ssl;
listen [::]:8443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
default_type text/plain;
location / {
return 200 "hello from pod \$hostname\n";
}
}
EOFThis will create the secure-app Service and a Deployment. The secure app is configured to serve HTTPS traffic on port 8443 for the host app.example.com, using the cert-manager-issued TLS certificate from app-tls-secret. The app responds to a client’s HTTPS requests with a simple text response "hello from pod $POD_HOSTNAME".
Run the following command to verify the resources were created:
kubectl get pods,svcThe output should include the secure-app pod and the secure-app Service:
NAME READY STATUS RESTARTS AGE
pod/secure-app-575785644-kzqf6 1/1 Running 0 12s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/secure-app ClusterIP 192.168.194.152 <none> 8443/TCP 12sCreate a Gateway with a TLS listener in passthrough mode. Copy and paste this into your terminal:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: gateway
namespace: default
spec:
gatewayClassName: nginx
listeners:
- name: tls
port: 443
protocol: TLS
hostname: "*.example.com"
allowedRoutes:
namespaces:
from: All
kinds:
- kind: TLSRoute
tls:
mode: Passthrough
EOFThis Gateway configures NGINX Gateway Fabric to accept TLS connections on port 443 and forward them to the backend without decryption. The routing uses the SNI, which lets clients specify a server name during the TLS handshake.
After creating the Gateway resource, NGINX Gateway Fabric provisions an NGINX Pod and Service to route traffic. Verify the Gateway is created:
kubectl describe gateways.gateway.networking.k8s.io gatewayVerify the status is Accepted:
Status:
Addresses:
Type: IPAddress
Value: 10.96.36.219
Conditions:
Last Transition Time: 2026-01-09T05:40:37Z
Message: The Gateway is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Last Transition Time: 2026-01-09T05:40:37Z
Message: The Gateway is programmed
Observed Generation: 1
Reason: Programmed
Status: True
Type: ProgrammedSave the public IP address and port(s) of the Gateway into shell variables:
GW_IP=XXX.YYY.ZZZ.III
GW_TLS_PORT=<port number>In a production environment, you should have a DNS record for the external IP address that is exposed, and it should refer to the hostname that the Gateway will forward for.
Create a TLSRoute that attaches to the Gateway and routes requests to app.example.com to the secure-app Service:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata:
name: tls-secure-app-route
namespace: default
spec:
parentRefs:
- name: gateway
namespace: default
hostnames:
- "app.example.com"
rules:
- backendRefs:
- name: secure-app
port: 8443
EOFTo route to a Service in a Namespace different from the TLSRoute Namespace, create a ReferenceGrant to permit the cross-namespace reference.
Using the external IP address and port for the NGINX Service, send traffic to the secure-app application.
If you have a DNS record allocated forapp.example.com, you can send the request directly to that hostname, without needing to resolve.
Send a request to the secure-app Service on the TLS port with the --insecure flag. The flag is required because secure-app uses a certificate signed by a local self-signed CA that curl does not trust.
curl --resolve app.example.com:$GW_TLS_PORT:$GW_IP https://app.example.com:$GW_TLS_PORT --insecure -vAdded app.example.com:8443:127.0.0.1 to DNS cache
* Hostname app.example.com was found in DNS cache
* Trying 127.0.0.1:8443...
* Connected to app.example.com (127.0.0.1) port 8443
* ALPN: curl offers h2,http/1.1
Handling connection for 8443
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted http/1.1
* Server certificate:
* subject: CN=app.example.com
* start date: May 6 21:23:51 2026 GMT
* expire date: Aug 4 21:23:51 2026 GMT
* issuer: CN=LocalCA
* SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
* using HTTP/1.x
> GET / HTTP/1.1
> Host: app.example.com:8443
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx/1.29.1
< Date: Wed, 06 May 2026 21:25:18 GMT
< Content-Type: text/plain
< Content-Length: 42
< Connection: keep-alive
<
hello from pod secure-app-59bbd475b-phgsvNote that the server certificate used to terminate the TLS connection has the subject common name of app.example.com. This is the server certificate that the secure-app is configured with and shows that the TLS connection was terminated by the secure-app, not NGINX Gateway Fabric.
In terminate mode, NGINX Gateway Fabric holds the TLS certificate, terminates the TLS connection, and forwards plain TCP traffic to the backend. The backend doesn’t need a certificate or TLS configuration.
Use TLS terminate mode when:
- Your backend serves a non-HTTP TCP protocol, such as a database or custom binary protocol.
- You want to centralize certificate management at the gateway rather than on each backend.
If your backend serves HTTP traffic and you need HTTP-level routing — such as path matching or header manipulation — use an HTTPS listener with an HTTPRoute instead. See Configure HTTPS termination.
Create a Certificate for app.example.com. cert-manager creates the gateway-tls-secret Secret, which the Gateway uses to terminate TLS:
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gateway-cert
namespace: default
spec:
secretName: gateway-tls-secret
issuerRef:
name: local-ca-issuer
kind: ClusterIssuer
commonName: app.example.com
dnsNames:
- app.example.com
EOFCreate the app application. Because the Gateway terminates TLS, the backend doesn’t need a certificate:
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
replicas: 1
selector:
matchLabels:
app: app
template:
metadata:
labels:
app: app
spec:
containers:
- name: app
image: nginxdemos/nginx-hello:plain-text
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: app
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
selector:
app: app
EOFRun the following command to verify the resources were created:
kubectl get pods,svcThe output should include the app pod and the app Service:
NAME READY STATUS RESTARTS AGE
pod/app-6c8d9c4b5f-xr7tq 1/1 Running 0 10s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/app ClusterIP 192.168.194.87 <none> 80/TCP 10sCreate a Gateway with a TLS listener in terminate mode. Copy and paste this into your terminal:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: gateway
namespace: default
spec:
gatewayClassName: nginx
listeners:
- name: tls
port: 443
protocol: TLS
hostname: "*.example.com"
allowedRoutes:
namespaces:
from: All
kinds:
- kind: TLSRoute
tls:
mode: Terminate
certificateRefs:
- name: gateway-tls-secret
namespace: default
EOFNGINX Gateway Fabric terminates TLS using the certificate from gateway-tls-secret and forwards plain TCP traffic to the backend.
After creating the Gateway resource, NGINX Gateway Fabric provisions an NGINX Pod and Service to route traffic. Verify the Gateway is created:
kubectl describe gateways.gateway.networking.k8s.io gatewayVerify the status is Accepted:
Status:
Addresses:
Type: IPAddress
Value: 10.96.36.219
Conditions:
Last Transition Time: 2026-01-09T05:40:37Z
Message: The Gateway is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Last Transition Time: 2026-01-09T05:40:37Z
Message: The Gateway is programmed
Observed Generation: 1
Reason: Programmed
Status: True
Type: ProgrammedSave the public IP address and port of the Gateway into shell variables:
GW_IP=XXX.YYY.ZZZ.III
GW_TLS_PORT=<port number>In a production environment, you should have a DNS record for the external IP address that is exposed, and it should refer to the hostname that the Gateway will forward for.
Create a TLSRoute that attaches to the Gateway and routes requests to app.example.com to the app Service:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata:
name: tls-app-route
namespace: default
spec:
parentRefs:
- name: gateway
namespace: default
hostnames:
- "app.example.com"
rules:
- backendRefs:
- name: app
port: 80
EOFTo route to a Service in a Namespace different from the TLSRoute Namespace, create a ReferenceGrant to permit the cross-namespace reference.
Using the external IP address and port for the NGINX Service, send traffic to the app application.
If you have a DNS record allocated forapp.example.com, you can send the request directly to that hostname, without needing to resolve.
Send a request to the app Service on the TLS port with the --insecure flag. The flag is required because the Gateway uses a certificate signed by a local self-signed CA that curl doesn’t trust.
curl --resolve app.example.com:$GW_TLS_PORT:$GW_IP https://app.example.com:$GW_TLS_PORT --insecureServer address: 10.244.0.9:8080
Server name: app-6f65b8c59b-9lk8j
Date: 14/May/2026:17:06:41 +0000
URI: /
Request ID: dca1e1d0f48b11f50e15007056242349The server certificate subject is app.example.com, which matches the certificate in gateway-tls-secret. This confirms that NGINX Gateway Fabric terminated the TLS connection, not the backend.
To learn more about TLS routing using the Gateway API, see the following resources: