Skip to content

Deploy Apache Kafka in KRaft Mode with Strimzi (2026)

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 kubectl with 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 using acks=all keep working while one broker is down.
  • Entity Operator turns KafkaTopic and KafkaUser resources 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-operator

The 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: brokers

The 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 pods

Expected 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  Running

The 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: 2
kubectl apply -f topic.yaml
kubectl -n kafka wait kafkatopic/orders --for=condition=Ready

6. 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 5

Expected 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

  • v1beta2 is gone in Strimzi 1.x. Change apiVersion to kafka.strimzi.io/v1 in 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 advertisedHost per broker in the listener configuration.
  • --producer-property is deprecated in the Kafka 4.3 console tools; use --command-property.
  • Upgrade Kafka in two steps: raise version, let the rolling restart finish, then raise metadataVersion.

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.

Links

Published inKubernetes