A platform engineer wants a three-server NATS cluster with JetStream in one Kubernetes cluster, and one stream on it. No auth plane: every client lands in the global account.

The NATS cluster #

The cluster controller renders the server config, owns the StatefulSet, and self-signs route certificates because none is named.

01-natscluster.yaml

apiVersion: cluster.nats.mikluko.io/v1beta1
kind: NatsCluster
metadata:
  name: demo
  namespace: nats-system
spec:
  # `image` sets the repository and a digest, never the tag.
  version: 2.15.0
  replicas: 3

  resources:
    requests:
      cpu: "1"
      memory: 4Gi
    limits:
      memory: 4Gi
  # GOMEMLIMIT and jetstream.max_memory_store derive from limits.memory;
  # max_file_store derives from the PVC size. The store limits are shown in
  # status and overridable under jetstream.limits.

  jetstream:
    volumeClaimTemplate:
      spec:
        storageClassName: standard
        resources:
          requests:
            storage: 20Gi

  # No `routes` block: route TLS is on by default, and with no certificate
  # named the controller self-signs one. `routes.tls.secretRef` or
  # `routes.tls.certManager.issuerRef` name a route certificate;
  # `routes.tls.enabled: false` turns route TLS off.

  # No `tls` block: clients connect in the clear. `tls.secretRef` or
  # `tls.certManager.issuerRef` put the client listener under TLS, and
  # clients then dial `tls://`.
kubectl apply -f https://nats-operator.io/docs/stories/01-quickstart/01-natscluster.yaml

At rest it reports every server on the same config revision, and Settled once every Raft group has a leader and every member is current. endpoints is where a connection’s address comes from.

01-status-natscluster-at-rest.yaml

status:
  observedGeneration: 1
  version: 2.15.0
  replicas: 3
  readyReplicas: 3
  conditions:
    - type: Ready
      status: "True"
      reason: AllServersReady
    - type: Settled
      status: "True"
      reason: AllGroupsCurrent
      message: every Raft group has a leader and every member is current
    - type: Progressing
      status: "False"
      reason: UpToDate
  # What a NatsConnection's `servers` is copied from.
  endpoints:
    client: nats://demo.nats-system.svc:4222
    monitor: http://demo-headless.nats-system.svc:8222
  config:
    revision: 3f9a1c
    appliedBy: Restart
  jetstream:
    metaLeader: demo-1
    limits:
      maxMemoryStore: 3Gi
      maxFileStore: 19Gi
  servers:
    - name: demo-0
      version: 2.15.0
      ready: true
      configRevision: 3f9a1c
    - name: demo-1
      version: 2.15.0
      ready: true
      configRevision: 3f9a1c
    - name: demo-2
      version: 2.15.0
      ready: true
      configRevision: 3f9a1c

Raising the memory changes each server’s pod template, which is restart-only, so the rollout restarts one server at a time and waits for Settled before the next. A spec.version change rolls the same way.

02-natscluster-8gi.yaml

# The same NatsCluster with its memory raised from 4Gi to 8Gi; nothing else
# changes.
apiVersion: cluster.nats.mikluko.io/v1beta1
kind: NatsCluster
metadata:
  name: demo
  namespace: nats-system
spec:
  version: 2.15.0
  replicas: 3
  resources:
    requests:
      cpu: "1"
      memory: 8Gi
    limits:
      memory: 8Gi
  jetstream:
    volumeClaimTemplate:
      spec:
        storageClassName: standard
        resources:
          requests:
            storage: 20Gi
kubectl apply -f https://nats-operator.io/docs/stories/01-quickstart/02-natscluster-8gi.yaml

02-status-natscluster-mid-rollout.yaml

status:
  observedGeneration: 2
  version: 2.15.0
  replicas: 3
  readyReplicas: 2
  conditions:
    - type: Ready
      status: "True"
      reason: QuorumAvailable
      message: 2 of 3 servers ready
    - type: Settled
      status: "False"
      reason: GroupsCatchingUp
      message: 4 Raft groups have a member on demo-1 that is not current
    - type: Progressing
      status: "True"
      reason: RollingRestart
      message: restarting demo-1 (2 of 3); waiting for Settled
  config:
    revision: 8b27e0
    appliedBy: Restart
    restartReason: the StatefulSet spec is restart-only
  rollout:
    targetRevision: 8b27e0
    updated: [demo-2]
    current: demo-1
    pending: [demo-0]
    gate:
      waitingFor: Settled
      since: "2026-09-25T16:02:11Z"
      # A gate that stays shut holds here with no timeout; after ten
      # minutes Progressing reads GateBlocked, naming the unsettled groups.
  jetstream:
    metaLeader: demo-0
  servers:
    - name: demo-0
      version: 2.15.0
      ready: true
      configRevision: 3f9a1c
    - name: demo-1
      version: 2.15.0
      ready: false
      configRevision: 8b27e0
    - name: demo-2
      version: 2.15.0
      ready: true
      configRevision: 8b27e0

The stream #

The JetStream controller reaches the NATS cluster only through a connection, the same way it reaches a NATS cluster nobody here deployed.

03-natsconnection.yaml

apiVersion: nats.mikluko.io/v1beta1
kind: NatsConnection
metadata:
  name: demo
  namespace: nats-system
spec:
  servers: ["nats://demo.nats-system.svc:4222"]
  # No credentials; story 2 adds `credentials`.
kubectl apply -f https://nats-operator.io/docs/stories/01-quickstart/03-natsconnection.yaml

03-natsstream.yaml

apiVersion: jetstream.nats.mikluko.io/v1beta1
kind: NatsStream
metadata:
  name: orders
  namespace: nats-system
spec:
  # The only way to a NATS cluster. The account is whichever one the
  # connection's credentials sign into.
  connectionRef:
    name: demo
  # Never (default) | Adopt | AdoptOrCreate. Never: the controller creates the
  # stream, and if one of that name exists that it does not own, the resource
  # goes Terminal rather than taking it over. Adoption is story 3.
  adoptionPolicy: Never
  # Retain (default for streams, KV and object stores) | Delete. Retain leaves
  # the stream and its data on the server when this resource is deleted.
  deletionPolicy: Retain
  # Hold (default) | Retry: what a Terminal condition does, story 3.
  terminalPolicy: Hold
  # Optional; falls back to metadata.name.
  name: ORDERS
  subjects: ["orders.>"]
  storage: File
  replicas: 3
  retention: Limits
  maxAge: 72h
  maxBytes: 5Gi
kubectl apply -f https://nats-operator.io/docs/stories/01-quickstart/03-natsstream.yaml

The stream’s status is re-read on a resync period, so drift made outside Kubernetes is reapplied from spec and reported, for one resync, as Synced=False, reason DriftCorrected.

03-status-natsstream.yaml

status:
  observedGeneration: 1
  conditions:
    - type: Ready
      status: "True"
      reason: Synced
    - type: Synced
      status: "True"
      reason: MatchesSpec
      message: server config matches spec as of last check
      lastTransitionTime: "2026-09-25T15:58:40Z"
    # Terminal is absent while nothing blocks reconciling. It turns True, for
    # example, on a create that finds an existing stream it does not own.
  # Re-read on a resync period, not only on spec change, so drift shows.
  lastSyncedTime: "2026-09-25T16:10:40Z"
  # Ownership is a marker the controller writes into the stream's own
  # `metadata` map naming this resource's UID; a stream without it is not the
  # controller's to update or delete.
  ownership:
    origin: Created   # Created | Adopted
    uid: 5b1e0c4a-2f7d-4c0e-9a51-0d6f3e2b7c19
  server:
    created: "2026-09-25T15:58:39Z"
    leader: demo-2
    replicas:
      - name: demo-0
        current: true
      - name: demo-1
        current: true
    messages: 18204
    bytes: 41Mi

Connecting a client #

With the NATS CLI, through a port-forward to the NATS cluster’s client Service:

kubectl -n nats-system port-forward svc/demo 4222:4222 &
nats -s nats://localhost:4222 pub orders.created '{"id": 1}'
nats -s nats://localhost:4222 stream info ORDERS