Exposing Apache Kafka to clients running outside a Kubernetes cluster has always required some creative plumbing.
Over the years, Strimzi has accumulated four external listener types (nodeport, loadbalancer, route for OpenShift only, and ingress), and they each come with their own trade-offs.
The ingress type deserves special mention here, because it was recently deprecated and the story behind that deprecation is exactly what motivates this post.
The type: ingress listener relied on the Ingress NGINX Controller for Kubernetes, a project that was announced for retirement by the Kubernetes community.
It was actually archived on the stage during KubeCon EU Amsterdam this year, and I was there!
With no future investment planned for that controller, building on top of it is no longer a good idea.
Fortunately, the Kubernetes ecosystem has a well-supported successor: the Gateway API.
Starting with Strimzi 1.1, the operator natively supports a new external listener type (type: tlsroute) based on the Kubernetes Gateway API and its TLSRoute resource.
In this post we will look at what the Gateway API is, why TLSRoute is the right fit for Kafka traffic, and then walk through a fully working example on minikube using Envoy Gateway as the Gateway controller.
From Ingress to Gateway API
The Ingress resource was the original Kubernetes abstraction for north-south HTTP traffic.
It was never designed for non-HTTP protocols and, as a result, anyone trying to expose Kafka through it had to rely on vendor-specific annotations and implementation quirks.
The Kubernetes Gateway API was created to be a proper successor. It is an official Kubernetes SIG project that defines a set of standard Custom Resource Definitions for routing L4 and L7 traffic. It ships only the API specification; the actual data-plane work is delegated to Gateway controller implementations, of which there are many. This clean separation means you can swap controllers without changing your application-level configuration.
The Gateway API introduces a hierarchy of resources:
GatewayClass: a cluster-scoped resource that names a specific controller (comparable toIngressClass).Gateway: an instance of a load balancer / proxy configured to handle traffic according to aGatewayClass.- Route resources (
HTTPRoute,GRPCRoute,TLSRoute,TCPRoute, …): describe how traffic should be forwarded from the gateway to backend services.
Why TLSRoute?
Kafka speaks its own binary protocol over TCP, not HTTP or gRPC, so HTTPRoute and GRPCRoute are immediately ruled out.
That leaves TCPRoute and TLSRoute.
TCPRoute routes arbitrary TCP traffic based solely on the destination port.
This means you need a unique IP address or a unique port for every broker you want to expose, effectively 1 + N addresses/ports for a cluster with N brokers.
At any meaningful scale that quickly becomes unmanageable.
TLSRoute solves this elegantly by using TLS-SNI (Server Name Indication) to route traffic.
Because Kafka clients always open a TLS-encrypted connection to the broker (when TLS is enabled), the client hostname is present in the TLS handshake before any Kafka protocol bytes are exchanged.
The gateway reads the SNI hostname and decides which backend service to forward the connection to.
This means that one gateway address and one port can serve the bootstrap endpoint and every individual broker, distinguished purely by hostname.
TLSRoute moved to the Standard API channel in Gateway API v1.4 and got a stable v1 API version in v1.5, making it a solid foundation to build on.
How Strimzi uses TLSRoute
When you configure a type: tlsroute listener, the Strimzi Cluster Operator takes care of all the Gateway API plumbing on your behalf:
- A bootstrap service pointing to all brokers is created, along with a bootstrap
TLSRoutefor the bootstrap hostname. - A per-broker service and a per-broker
TLSRouteare created for each Kafka broker node, using the per-broker hostname.
You only need to:
- Deploy and configure a
Gateway(using any Gateway API compatible controller). - Reference that
Gatewayin yourKafkaCR via the newparentRefsconfiguration field.
Strimzi will create and keep the TLSRoute resources in sync.
This is especially useful when you combine type: tlsroute with horizontal auto-scaling of broker node pools: as brokers are added or removed, Strimzi automatically creates or deletes the corresponding TLSRoute resources.
TLS passthrough vs. TLS termination: the TLS mode is configured on the
Gatewaylistener, not in Strimzi. With TLS passthrough, the encrypted connection travels all the way to the Kafka broker; the broker’s certificate is what clients verify. This is what we use in this guide and is the most common setup. TLS termination at the gateway is also possible in principle, but Gateway API controller support for it is still emerging.
Trying it on minikube
The rest of this post is a step-by-step walkthrough for running the type: tlsroute listener on a laptop using minikube and Envoy Gateway.
At the end you will be able to produce and consume messages from outside the cluster using the standard Kafka client tools.
We assume minikube is already running locally. If you need to set it up first, follow the minikube getting started guide.
Prerequisites
You will need the following tools installed:
- kubectl
- Helm 3
- Strimzi 1.1.0+ installed in your cluster (the Quickstart is the fastest path)
- Kafka CLI tools (
kafka-topics.sh,kafka-console-producer.sh,kafka-console-consumer.sh); alternatively, you can run them from inside a broker pod
Step 1: Install Envoy Gateway
Envoy Gateway ships with the Gateway API CRDs bundled, so there is no need to install them separately.
helm install envoygateway oci://docker.io/envoyproxy/gateway-helm \
-n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system \
deployment/envoy-gateway --for=condition=Available
Verify the installation and confirm that the Gateway API CRDs are present:
kubectl get pods -n envoy-gateway-system
kubectl get crd | grep gateway
You should see several CRDs including gateways.gateway.networking.k8s.io and tlsroutes.gateway.networking.k8s.io.
Step 2: Create the EnvoyProxy resource
The EnvoyProxy resource tells Envoy Gateway how to deploy the data-plane Envoy pods and what service type to use.
On minikube we use LoadBalancer and rely on minikube tunnel to expose it.
# envoy-proxy.yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: minikube-proxy
namespace: envoy-gateway-system
spec:
provider:
type: Kubernetes
kubernetes:
envoyService:
type: LoadBalancer
kubectl apply -f envoy-proxy.yaml
Step 3: Create the GatewayClass
A GatewayClass is a cluster-scoped resource that registers a specific Gateway controller with Kubernetes, much like IngressClass does for Ingress controllers.
Every Gateway you create later must reference a GatewayClass, which is how Kubernetes knows which controller should reconcile it.
The controllerName field identifies the Envoy Gateway controller, and the parametersRef points to the EnvoyProxy resource we created in the previous step so that the controller knows how to deploy the data-plane pods.
# gateway-class.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: envoy-gateway
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: minikube-proxy
namespace: envoy-gateway-system
kubectl apply -f gateway-class.yaml
kubectl get gatewayclass
The output should show the GatewayClass with ACCEPTED set to True, which means the Envoy Gateway controller has picked it up successfully.
Step 4: Start minikube tunnel
The LoadBalancer service created for the gateway needs an external IP.
Open a new terminal and keep this command running throughout the session:
minikube tunnel
You may be prompted for your sudo password.
Leave this terminal open and go back to your main terminal.
Step 5: Create the Gateway
A Gateway is an instance of a load balancer or proxy that listens for incoming traffic and routes it to backend services based on the attached route resources.
It references a GatewayClass (via gatewayClassName) so Envoy Gateway knows it is responsible for reconciling this resource and deploying the corresponding Envoy proxy pods.
The listener we define here is the entry point for all Kafka traffic:
protocol: TLSwithmode: Passthroughtells the gateway to forward the raw TLS connection directly to the backend without terminating it. The TLS handshake happens end-to-end between the Kafka client and the Kafka broker.port: 8443is the port on which the gateway will accept connections from outside the cluster.hostname: "*.kafka.local"restricts this listener to SNI hostnames matching that wildcard, which covers both the bootstrap address and all per-broker addresses we will configure later.allowedRoutes.namespaces.from: AllpermitsTLSRouteresources from any namespace to attach to this listener, which is needed because Strimzi will create the routes in the Kafka cluster’s namespace.
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: envoy-gateway
namespace: envoy-gateway-system
spec:
gatewayClassName: envoy-gateway
listeners:
- name: tls-passthrough
protocol: TLS
port: 8443
hostname: "*.kafka.local"
tls:
mode: Passthrough
allowedRoutes:
namespaces:
from: All
kubectl apply -f gateway.yaml
Wait a few seconds and then verify the Gateway is PROGRAMMED:
kubectl get gateway envoy-gateway -n envoy-gateway-system
If PROGRAMMED shows False, check that minikube tunnel is still running and that the LoadBalancer service has been assigned an EXTERNAL-IP:
kubectl get svc -n envoy-gateway-system
Step 6: Configure DNS
Because we are running on a laptop, there is no real DNS server that can resolve the Kafka hostnames to the gateway’s address.
We solve this by adding static entries to the local /etc/hosts file.
This is the same technique used when doing local Kubernetes development: map the hostnames you care about directly to the IP exposed by minikube tunnel.
The hostnames we need come from the Kafka listener configuration we will apply in Step 7.
The bootstrap.host field sets the bootstrap address (bootstrap.kafka.local), and the hostTemplate field defines the per-broker naming pattern (broker-{nodeId}.kafka.local), which for a three-broker cluster produces broker-0.kafka.local, broker-1.kafka.local, and broker-2.kafka.local.
We add all four entries now so that the cluster is reachable as soon as it finishes reconciling.
First, retrieve the external IP that was assigned to the gateway:
GATEWAY_IP=$(kubectl get gateway envoy-gateway -n envoy-gateway-system \
-o jsonpath='{.status.addresses[0].value}')
echo $GATEWAY_IP
Add entries to /etc/hosts so your laptop can resolve the Kafka hostnames:
sudo tee -a /etc/hosts <<EOF
${GATEWAY_IP} bootstrap.kafka.local
${GATEWAY_IP} broker-0.kafka.local
${GATEWAY_IP} broker-1.kafka.local
${GATEWAY_IP} broker-2.kafka.local
EOF
You can verify that DNS resolves correctly with:
ping -c 1 bootstrap.kafka.local
Note that the ping itself will likely time out (the gateway only listens on TCP); what matters is that the hostname resolves to the right IP.
Step 7: Deploy the Kafka cluster with a tlsroute listener
We assume the Strimzi Cluster Operator is installed in the kafka namespace and we will deploy the Kafka cluster there as well.
Create the Kafka and KafkaNodePool resources.
The key part is the external listener of type: tlsroute, which instructs Strimzi to create TLSRoute resources instead of managing Ingress or LoadBalancer objects itself.
A few fields in the listener configuration are worth calling out:
parentRefspoints to theGatewayresource created in Step 5. This is how Strimzi knows which gateway to attach theTLSRouteresources to.bootstrap.hostsets the hostname advertised to Kafka clients for the initial connection.hostTemplatedefines the per-broker hostname pattern. The{nodeId}placeholder is replaced by the actual broker node ID at runtime.advertisedPortTemplate: 8443fixes the advertised port to8443for every broker, matching the single port our gateway listener is configured on.
# kafka-cluster.yaml
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: controller
labels:
strimzi.io/cluster: my-cluster
spec:
replicas: 3
roles:
- controller
storage:
type: jbod
volumes:
- id: 0
type: ephemeral
kraftMetadata: shared
---
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: broker
labels:
strimzi.io/cluster: my-cluster
spec:
replicas: 3
roles:
- broker
storage:
type: jbod
volumes:
- id: 0
type: ephemeral
kraftMetadata: shared
---
apiVersion: kafka.strimzi.io/v1
kind: Kafka
metadata:
name: my-cluster
spec:
kafka:
version: 4.3.0
metadataVersion: 4.3-IV0
listeners:
- name: plain
port: 9092
type: internal
tls: false
- name: tls
port: 9093
type: internal
tls: true
- name: external
port: 9094
type: tlsroute
tls: true
configuration:
bootstrap:
host: bootstrap.kafka.local
parentRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: envoy-gateway
namespace: envoy-gateway-system
hostTemplate: broker-{nodeId}.kafka.local
advertisedPortTemplate: "8443"
config:
offsets.topic.replication.factor: 3
transaction.state.log.replication.factor: 3
transaction.state.log.min.isr: 2
default.replication.factor: 3
min.insync.replicas: 2
entityOperator:
topicOperator: {}
userOperator: {}
kubectl apply -f kafka-cluster.yaml -n kafka
kubectl wait kafka/my-cluster -n kafka --for=condition=Ready --timeout=300s
Once the cluster is ready, verify that Strimzi has created the TLSRoute resources automatically in the kafka namespace:
kubectl get tlsroute -n kafka
You should see one bootstrap route and one route per broker:
NAME HOSTNAMES AGE
my-cluster-kafka-bootstrap ["bootstrap.kafka.local"] 2m
my-cluster-broker-0 ["broker-0.kafka.local"] 2m
my-cluster-broker-1 ["broker-1.kafka.local"] 2m
my-cluster-broker-2 ["broker-2.kafka.local"] 2m
Tip: If the
TLSRouteresources are not created and the Cluster Operator logs showThe Gateway API TLSRoute resource is not available in this Kubernetes cluster, the operator was started before the Gateway API CRDs were installed. Simply restart it (replace<operator-namespace>with the namespace where Strimzi is installed, e.g.strimziif you used the Quickstart):kubectl rollout restart deployment strimzi-cluster-operator -n <operator-namespace>
Step 8: Test the connection
Before connecting Kafka clients, we need to extract the cluster CA certificate that Strimzi generated for the cluster. Clients must trust this CA to be able to establish a TLS connection to the brokers.
kubectl get secret my-cluster-cluster-ca-cert -n kafka \
-o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt
With the CA certificate in hand, do a quick sanity check with OpenSSL to verify the TLS passthrough is working end to end:
openssl s_client -connect broker-0.kafka.local:8443 \
-servername broker-0.kafka.local -showcerts
You should see CONNECTED and the broker certificate chain.
A Verify return code: 19 (self-signed certificate in certificate chain) is expected; the important thing is that the TLS handshake completes successfully.
Now test with the Kafka CLI tools.
All commands need to know the bootstrap address and that TLS is required, so start by creating a client properties file that every tool will share via --command-config or --producer.config / --consumer.config:
# client-ssl.properties
bootstrap.servers=bootstrap.kafka.local:8443
security.protocol=SSL
ssl.truststore.type=PEM
ssl.truststore.location=ca.crt
The ssl.truststore.location points to the ca.crt file we just extracted.
Using ssl.truststore.type=PEM means we can pass the certificate file directly without converting it to a Java keystore first.
First, verify that the client can reach the cluster and list topics. At this point the list will be empty, but a successful response confirms that the bootstrap connection and broker metadata exchange are working correctly:
kafka-topics.sh --bootstrap-server bootstrap.kafka.local:8443 \
--command-config client-ssl.properties \
--list
Next, create a test topic with three partitions and a replication factor of three, one replica per broker:
kafka-topics.sh --bootstrap-server bootstrap.kafka.local:8443 \
--command-config client-ssl.properties \
--create --topic test-topic \
--partitions 3 --replication-factor 3
With the topic in place, open an interactive producer session and type a few messages, pressing Enter after each one.
Press Ctrl+C when you are done to close the producer:
kafka-console-producer.sh --bootstrap-server bootstrap.kafka.local:8443 \
--producer.config client-ssl.properties \
--topic test-topic
Finally, start a consumer reading from the beginning of the topic.
It should print every message you just produced, which confirms that the per-broker TLSRoute resources are working correctly.
Each message was written to a specific broker and the consumer was able to reach that broker individually through its own dedicated route:
kafka-console-consumer.sh --bootstrap-server bootstrap.kafka.local:8443 \
--consumer.config client-ssl.properties \
--topic test-topic \
--from-beginning
Press Ctrl+C to stop the consumer once you have seen all the messages.
Conclusion
The new type: tlsroute listener brings first-class Gateway API support to Strimzi.
It fills the gap left by the deprecated type: ingress listener and does so on a stable, vendor-neutral standard that has broad ecosystem support.
From a user perspective, the configuration is straightforward: bring your own Gateway, point Strimzi at it via parentRefs, and Strimzi takes care of creating and maintaining the TLSRoute resources, including as brokers are scaled up or down.
From a protocol perspective, TLS-SNI-based routing gives you the clean one-address-one-port topology that is so important for the Kafka protocol, without the complexity of managing one unique IP per broker that TCPRoute would require.
If you are currently using type: ingress and wondering what to migrate to, type: tlsroute is the answer.
Any Gateway API compatible controller will work; in this post we used Envoy Gateway, but you could equally use Cilium Gateway API, Istio, NGINX Gateway Fabric, or any other implementation you already have in your cluster.
For more background on the design decisions behind this feature, have a look at Strimzi proposal #136.
