Last updated on October 26, 2026
Updated October 2026 for Strimzi 1.3. The first version of this guide used Strimzi 0.48 and the kafka.strimzi.io/v1beta2 API. Strimzi 1.x serves only v1, so those manifests no longer apply. Everything below was re-tested with Strimzi 1.3.0 and Kafka 4.3.1 on Kubernetes 1.36: the operator now comes from the Helm chart, which also makes the RBAC patches from the old version unnecessary, and the NodePorts are set in the manifest instead of patched afterwards.
When Bitnami restructured its free images and charts in 2025, I moved our Kafka clusters to Strimzi, an operator that manages Kafka declaratively: brokers, controllers, topics and users are all Kubernetes resources, and rolling restarts, upgrades and certificate rotation are handled for you. This guide builds a highly available KRaft cluster: three controllers, three brokers, replication factor 3. For a quick single-node setup for development see How to Deploy Kafka in Kubernetes with Helm (Strimzi) and Kafdrop.
Prerequisites
- A Kubernetes cluster with at least three worker nodes, so each broker and controller can run on its own node.
- A StorageClass with enough space: 50Gi per broker and 8Gi per controller in this example.
- Helm 3 and
kubectlwith cluster-admin rights (the operator installs CRDs).
Architecture
- 3 controllers (node IDs 100-102) keep the cluster metadata in the KRaft quorum. Losing one is fine, losing two stops metadata changes.
- 3 brokers (node IDs 0-2) store the data. With replication factor 3 and
min.insync.replicas=2, producers usingacks=allkeep working while one broker is down. - Entity Operator turns
KafkaTopicandKafkaUserresources into real topics and users.
1. Install the Strimzi operator
kubectl create namespace kafka
helm install strimzi oci://quay.io/strimzi-helm/strimzi-kafka-operator \
--version 1.3.0 -n kafka
kubectl -n kafka rollout status deploy/strimzi-cluster-operatorThe old version of this guide installed Strimzi from the release YAML and then needed several kubectl patch commands, because those files hard-code the myproject namespace in their RoleBindings. The Helm chart renders the bindings for the namespace you install into, so that whole step is gone.
2. The Kafka resource
Save as kafka-cluster.yaml. It defines the listeners and the cluster-wide configuration; the pods themselves come from the node pools in the next step:
apiVersion: kafka.strimzi.io/v1
kind: Kafka
metadata:
name: kafka-cluster
namespace: kafka
spec:
kafka:
version: 4.3.1
metadataVersion: 4.3-IV0
listeners:
- name: plain
port: 9092
type: internal
tls: false
- name: external
port: 9094
type: nodeport
tls: false
configuration:
bootstrap:
nodePort: 32500
brokers:
- broker: 0
nodePort: 32501
- broker: 1
nodePort: 32502
- broker: 2
nodePort: 32503
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
auto.create.topics.enable: "false"
log.retention.hours: 72
log.retention.bytes: 2147483648
entityOperator:
topicOperator:
resources:
requests: { cpu: 50m, memory: 256Mi }
limits: { memory: 512Mi }
userOperator:
resources:
requests: { cpu: 50m, memory: 256Mi }
limits: { memory: 512Mi }The external listener exposes each broker on a fixed NodePort (32501-32503) plus a bootstrap port (32500). Defining them here replaces the kubectl patch svc commands from the first version, which the operator may overwrite when it reconciles those services. auto.create.topics.enable is off on purpose: topics are created as KafkaTopic resources, so their replication settings are reviewed like any other manifest.
3. The node pools
Save as kafka-nodepools.yaml. If your default StorageClass is not the one you want, add class: <name> under each volume:
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: controllers
namespace: kafka
labels:
strimzi.io/cluster: kafka-cluster
annotations:
strimzi.io/next-node-ids: "[100-102]"
spec:
replicas: 3
roles:
- controller
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 2Gi }
jvmOptions:
-Xms: 512m
-Xmx: 512m
storage:
type: jbod
volumes:
- id: 0
type: persistent-claim
size: 8Gi
kraftMetadata: shared
deleteClaim: false
template:
pod:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
strimzi.io/cluster: kafka-cluster
strimzi.io/pool-name: controllers
---
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: brokers
namespace: kafka
labels:
strimzi.io/cluster: kafka-cluster
annotations:
strimzi.io/next-node-ids: "[0-2]"
spec:
replicas: 3
roles:
- broker
resources:
requests: { cpu: "1", memory: 4Gi }
limits: { memory: 4Gi }
jvmOptions:
-Xms: 2g
-Xmx: 2g
storage:
type: jbod
volumes:
- id: 0
type: persistent-claim
size: 50Gi
deleteClaim: false
template:
pod:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
strimzi.io/cluster: kafka-cluster
strimzi.io/pool-name: brokersThe strimzi.io/next-node-ids annotations matter. Without them Strimzi hands out node IDs in creation order across both pools, and the brokers may end up as 3-5 instead of 0-2, which breaks the broker: 0..2 NodePort mapping above. The heap is set explicitly at half of the container memory; the rest is page cache, which Kafka relies on heavily.
4. Deploy and wait
kubectl apply -f kafka-cluster.yaml -f kafka-nodepools.yaml
kubectl -n kafka wait kafka/kafka-cluster --for=condition=Ready --timeout=900s
kubectl -n kafka get podsExpected result:
kafka-cluster-brokers-0 1/1 Running
kafka-cluster-brokers-1 1/1 Running
kafka-cluster-brokers-2 1/1 Running
kafka-cluster-controllers-100 1/1 Running
kafka-cluster-controllers-101 1/1 Running
kafka-cluster-controllers-102 1/1 Running
kafka-cluster-entity-operator-... 2/2 Running
strimzi-cluster-operator-... 1/1 RunningThe address external clients should use is in the status:
kubectl -n kafka get kafka kafka-cluster \
-o jsonpath='{.status.listeners[?(@.name=="external")].bootstrapServers}'5. Create a replicated topic
apiVersion: kafka.strimzi.io/v1
kind: KafkaTopic
metadata:
name: orders
namespace: kafka
labels:
strimzi.io/cluster: kafka-cluster
spec:
partitions: 6
replicas: 3
config:
min.insync.replicas: 2kubectl apply -f topic.yaml
kubectl -n kafka wait kafkatopic/orders --for=condition=Ready6. Prove that it survives a broker failure
Produce through the external NodePort, then delete a broker and produce again with acks=all:
EXT=$(kubectl -n kafka get kafka kafka-cluster \
-o jsonpath='{.status.listeners[?(@.name=="external")].bootstrapServers}')
printf "a\nb\nc\n" | kubectl -n kafka exec -i -c kafka kafka-cluster-brokers-0 -- \
bin/kafka-console-producer.sh --bootstrap-server "$EXT" --topic orders --command-property acks=all
kubectl -n kafka delete pod kafka-cluster-brokers-1 --wait=false
printf "d\ne\n" | kubectl -n kafka exec -i -c kafka kafka-cluster-brokers-0 -- \
bin/kafka-console-producer.sh --bootstrap-server kafka-cluster-kafka-bootstrap:9092 \
--topic orders --command-property acks=all
kubectl -n kafka exec -c kafka kafka-cluster-brokers-2 -- \
bin/kafka-console-consumer.sh --bootstrap-server kafka-cluster-kafka-bootstrap:9092 \
--topic orders --from-beginning --max-messages 5Expected result: all five messages come back, and while broker 1 restarts kafka-topics.sh --describe shows two replicas in the ISR, for example Replicas: 2,0,1 Isr: 2,0. The pod returns by itself within a minute or two.
7. Kafdrop (optional)
For a web UI, use the Kafdrop manifest from the single-node guide with KAFKA_BROKERCONNECT set to kafka-cluster-kafka-bootstrap:9092, and keep it on ClusterIP: Kafdrop has no login.
Gotchas
v1beta2is gone in Strimzi 1.x. ChangeapiVersiontokafka.strimzi.io/v1in every Kafka, KafkaNodePool, KafkaTopic and KafkaUser manifest before upgrading the operator.- Preferred anti-affinity is a wish, not a rule. If a node is full, two brokers may land on the same node and one node failure takes out two replicas. With exactly three dedicated nodes, switch to
requiredDuringSchedulingIgnoredDuringExecution. - NodePort clients need routable node addresses. Each broker advertises its node’s address; if clients cannot reach the node InternalIPs, set
advertisedHostper broker in the listener configuration. --producer-propertyis deprecated in the Kafka 4.3 console tools; use--command-property.- Upgrade Kafka in two steps: raise
version, let the rolling restart finish, then raisemetadataVersion.
Takeaways
- Install Strimzi with its Helm chart; no RBAC patching.
- Separate controller and broker pools with fixed node IDs, fixed NodePorts in the manifest.
- Replication factor 3 with
min.insync.replicas=2, verified by killing a broker under load.
