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.yamlAt 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.yaml02-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.yaml03-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.yamlThe 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