Introduction
The Qdrant Operator runs Qdrant, the vector search
engine, on Kubernetes. You declare a QdrantCluster and the operator installs
the official qdrant-helm chart with
values derived from the spec, then reports StatefulSet readiness in the
resource status. Six more kinds manage what lives inside or around that
cluster: collections with their payload indexes and aliases, access tokens
for applications, backups of every node to S3-compatible storage, backups on
a cron schedule, restores from a backup, and point-by-point migrations from
any other Qdrant.
Helm underneath
The operator does not render Qdrant's StatefulSet itself. Each QdrantCluster
is one Helm release named qdrant-<name>, installed with
helm upgrade --install from the upstream chart repository, and
spec.version is both the Qdrant version and the chart version. A cluster you
already run from the chart translates to a QdrantCluster almost value for
value; Helm values the operator renders lists
the mapping. What the chart cannot do, and the operator adds, is everything
that talks to Qdrant's HTTP API: collection reconciliation, snapshots,
recovery, point migration, and signing access tokens.
The kinds
| Kind | Short name | What it manages |
|---|---|---|
QdrantCluster | qc | One Helm release of the qdrant chart: replicas, version, storage, service, API keys, TLS, metrics, scheduling |
QdrantCollection | qcol | A collection inside a cluster: vectors, sharding, payload indexes, aliases |
QdrantAccessKey | qak | A role-based access token signed with the cluster's API key, kept in a Secret and renewed before expiry |
QdrantBackup | qb | One backup: a snapshot of every node per collection, streamed into a bucket with a manifest |
QdrantBackupSchedule | qbs | Backups on a cron schedule with CronJob-style concurrency and grandfather-father-son retention |
QdrantRestore | qr | Recovery of a backup into a cluster with the same node count, node by node |
QdrantMigration | qmig | A copy of collections from any reachable Qdrant, including Qdrant Cloud, into a managed cluster |
All kinds live in the API group qdrant.io, version v1alpha1, and are
namespaced.
Every node, not one
Qdrant in distributed mode spreads a collection's shards across nodes, and a snapshot taken on one node holds only that node's shards. The operator therefore snapshots each StatefulSet pod through the chart's headless Service and records which node every snapshot came from. A restore hands each target node the snapshot of the matching source node, which is why the node counts of source and target must be equal.
Where to go next
Installation deploys the operator and its CRDs. Quickstart creates a cluster, a collection and a backup. The guide then takes one topic per chapter, from running a cluster through backups to troubleshooting, and the reference section has a field table for every kind.
Installation
The operator needs Kubernetes 1.26 or newer and a Helm 3 client for anything
you run from your machine. Inside the cluster the operator image ships its own
helm binary and needs no Tiller, plugin or repository cache.
With Helm
The chart is published to the GitHub container registry on every release:
helm install qdrant-operator oci://ghcr.io/4thel00z/charts/qdrant-operator \
--namespace qdrant-system --create-namespace
The chart carries the seven CRDs in its crds/ directory, so a fresh install
creates them. Helm does not upgrade CRDs on helm upgrade; apply them
yourself when moving to a new version, see
Upgrading the operator.
Values worth setting are listed under Operator configuration. For example:
helm install qdrant-operator oci://ghcr.io/4thel00z/charts/qdrant-operator \
--namespace qdrant-system --create-namespace \
--set operator.logLevel=DEBUG \
--set operator.logFormat=text
From the repository
git clone https://github.com/4thel00z/qdrant-operator
cd qdrant-operator
make crds-install
make install IMAGE_TAG=<version>
make install runs helm upgrade --install on charts/qdrant-operator into
the qdrant-system namespace with the image from ghcr.io/4thel00z/qdrant-operator.
Outside the cluster
The operator is also a Python package. The qdrant-operator command runs it
against your current kubeconfig, with the CRDs applied to the cluster first:
git clone https://github.com/4thel00z/qdrant-operator
kubectl apply -f qdrant-operator/manifests/crds/
uv tool install qdrant-operator # or: pipx install qdrant-operator
qdrant-operator
Each GitHub release also attaches the seven CRD files as assets, so a pinned version can be applied without a clone.
Extra arguments go to kopf run and override the defaults, so
qdrant-operator --namespace team-a watches one namespace instead of all.
This mode needs helm on your PATH.
What gets installed
The chart creates, in the release namespace, a Deployment with one replica, a
ServiceAccount, and a ClusterRole plus binding. The ClusterRole covers the
qdrant.io kinds and their status subresources, the objects the qdrant chart
renders (StatefulSets, Services, ConfigMaps, Secrets, ServiceAccounts,
PodDisruptionBudgets, Ingresses, ServiceMonitors), Events, and kopf's own
peering resource. The Deployment uses the Recreate strategy, because two
operator replicas would race on the same Helm releases.
Verifying
kubectl get pods -n qdrant-system -l app.kubernetes.io/name=qdrant-operator
kubectl get crd | grep qdrant.io
Seven CRDs are installed: qdrantclusters, qdrantcollections,
qdrantaccesskeys, qdrantbackups, qdrantbackupschedules,
qdrantrestores and qdrantmigrations. The pod
answers liveness probes on /healthz port 8080.
Uninstalling
helm uninstall qdrant-operator --namespace qdrant-system
Helm leaves the CRDs in place. Deleting them deletes every custom resource,
and with the operator gone nobody runs the finalizers, so uninstall the
QdrantCluster resources first if you want their Helm releases removed, and
keep in mind that deleting a QdrantBackup while the operator runs deletes
its objects in the bucket.
kubectl delete -f manifests/crds/
Quickstart
This walk-through creates a three-node cluster, declares a collection in it, and takes a backup. It assumes the operator is installed and that you have an S3 bucket or a MinIO instance at hand.
Create a cluster
apiVersion: qdrant.io/v1alpha1
kind: QdrantCluster
metadata:
name: my-qdrant
spec:
replicas: 3
version: v1.16.3
resources:
requests: {cpu: 500m, memory: 1Gi}
limits: {cpu: "2", memory: 4Gi}
persistence:
size: 10Gi
apiKey:
autoGenerate: true
kubectl apply -f my-qdrant.yaml
kubectl get qc my-qdrant -w
NAME PHASE REPLICAS READY VERSION AGE
my-qdrant Pending 3 0 v1.16.3 4s
my-qdrant Running 3 3 v1.16.3 71s
Behind the scenes the operator ran helm upgrade --install qdrant-my-qdrant
with the qdrant chart at version 1.16.3. Running means the StatefulSet
reports as many ready replicas as the spec asks for; the operator checks every
thirty seconds.
Connect
The chart generated a Secret named qdrant-my-qdrant-apikey with the key
api-key. The Service address is in the status:
kubectl get qc my-qdrant -o jsonpath='{.status.endpoint}{"\n"}'
API_KEY=$(kubectl get secret qdrant-my-qdrant-apikey -o jsonpath='{.data.api-key}' | base64 -d)
kubectl port-forward svc/qdrant-my-qdrant 6333:6333 &
curl -H "api-key: $API_KEY" http://127.0.0.1:6333/collections
Inside the cluster, connect to qdrant-my-qdrant.<namespace>.svc.cluster.local
on port 6333 for HTTP and 6334 for gRPC.
Declare a collection
apiVersion: qdrant.io/v1alpha1
kind: QdrantCollection
metadata:
name: documents
spec:
clusterRef: {name: my-qdrant}
vectors:
- size: 1536
distance: Cosine
replicationFactor: 2
payloadIndexes:
- field: tenant
type: keyword
kubectl apply -f documents.yaml
kubectl get qcol
NAME CLUSTER COLLECTION PHASE POINTS HEALTH AGE
documents my-qdrant documents Ready 0 green 6s
The operator waited for the cluster to answer /readyz, created the
collection with PUT /collections/documents, and added the payload index.
It re-checks every minute, so a collection dropped by hand comes back.
Take a backup
Put the bucket credentials in a Secret, then create a QdrantBackup:
apiVersion: v1
kind: Secret
metadata:
name: s3-credentials
stringData:
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
---
apiVersion: qdrant.io/v1alpha1
kind: QdrantBackup
metadata:
name: first
spec:
clusterRef: {name: my-qdrant}
storage:
s3:
bucket: qdrant-backups
prefix: my-qdrant
endpoint: http://minio.minio.svc:9000
forcePathStyle: true
credentialsSecretRef: {name: s3-credentials}
kubectl apply -f backup.yaml
kubectl get qb first -w
NAME CLUSTER PHASE SIZE AGE
first my-qdrant InProgress 2s
first my-qdrant Completed 12.4MB 19s
The bucket now holds my-qdrant/first/manifest.json and one snapshot per
collection per node under my-qdrant/first/<collection>/node-<i>/.
Next
- Running a cluster covers the rest of the
QdrantClusterspec. - Collections explains what the operator can and cannot change on a live collection.
- Scheduled backups and Restore complete the backup story.
Running a cluster
A QdrantCluster describes one Helm release of the qdrant chart. This
chapter walks the spec top to bottom; the
reference has every field with its default,
and Helm values the operator renders shows
what each field becomes.
Version and image
spec:
version: v1.16.3
image:
repository: docker.io/qdrant/qdrant
pullPolicy: IfNotPresent
version is required and pins both the Qdrant image tag and the chart
version: the operator passes it, without the leading v, as --version to
Helm and lets the chart's appVersion pick the image tag. The chart and
Qdrant release in lockstep, so every Qdrant release has a chart of the same
number. image.repository points at a mirror; the tag always follows
version.
Replicas and distributed mode
spec:
replicas: 3
cluster:
enabled: true
p2p:
port: 6335
enableTls: false
cluster.enabled defaults to true, and the API server rejects
replicas > 1 with cluster.enabled: false, because a multi-pod StatefulSet
of stand-alone nodes would not form a cluster. Scaling up adds pods that join
through the p2p port. Scaling down is the chart's business; Qdrant does not
move shards off a node by itself, so drain a node's shards before you lower
replicas.
Persistence
spec:
persistence:
size: 50Gi
storageClassName: fast-ssd
accessModes: [ReadWriteOnce]
snapshotPersistence:
enabled: true
size: 50Gi
Each pod gets a PersistentVolumeClaim from the StatefulSet's claim template.
persistence.size and storageClassName are immutable after creation and
the API server rejects the update naming the field. To grow a volume, expand
the PVCs directly if the storage class allows it.
snapshotPersistence gives each pod a second volume for the snapshots Qdrant
writes before the operator streams them out. Without it, snapshots are written to
the data volume, so leave headroom equal to your largest collection's shards
on a node, or turn this on. Like persistence, it becomes a volume claim
template and cannot be changed on an existing cluster; the API server does
not reject the change, Helm does.
Resources and scheduling
spec:
resources:
requests: {cpu: "2", memory: 8Gi}
limits: {cpu: "4", memory: 8Gi}
nodeSelector:
workload: vector-search
tolerations:
- {key: dedicated, operator: Equal, value: qdrant, effect: NoSchedule}
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels: {app.kubernetes.io/name: qdrant}
resources, nodeSelector, tolerations and affinity pass straight
through to the chart. Qdrant holds its HNSW graphs in memory, so memory
requests should track the indexed vectors; the chart sets no default
requests.
Service
spec:
service:
type: LoadBalancer
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: nlb
The chart creates a Service named qdrant-<name> with the HTTP port 6333,
the gRPC port 6334 and, in distributed mode, the p2p port. A headless Service
qdrant-<name>-headless gives each pod a stable DNS name; the operator uses
it to reach individual nodes for snapshots. status.endpoint holds the
in-cluster HTTP URL of the client Service.
Metrics
spec:
metrics:
enabled: true
serviceMonitor:
enabled: true
interval: 15s
labels: {release: kube-prometheus-stack}
Qdrant always serves /metrics. What metrics controls is the
ServiceMonitor the chart renders for the Prometheus operator: it is created
only when both metrics.enabled and serviceMonitor.enabled are true, and
the monitoring.coreos.com CRDs must exist on the cluster or the Helm
release fails.
Extra chart configuration
spec:
config:
storage:
performance:
optimizer_cpu_budget: 2
log_level: INFO
config is merged into the chart's config value, which becomes Qdrant's
config/production.yaml. The operator writes cluster.enabled, the p2p
settings, service.enable_tls and the TLS certificate paths into the same
map first, and your config wins on conflicts. Anything Qdrant's
configuration file accepts goes here.
Status and phases
| Phase | Meaning |
|---|---|
Pending | First Helm install applied, StatefulSet not yet fully ready |
Upgrading | Spec changed on an existing release, rollout in progress |
Running | readyReplicas equals replicas |
The Progressing condition turns True with reason HelmReleaseApplied
whenever the operator applies the release and False with RolloutComplete
once the StatefulSet is ready. The Ready condition reports
AllReplicasReady or ReplicasNotReady with the ready count in its message.
The operator re-reads the StatefulSet every thirty seconds.
Deleting
Deleting a QdrantCluster runs helm uninstall --wait on its release. The
chart's PersistentVolumeClaims stay behind, as StatefulSet claims do; delete
them yourself once you are sure. Backups referencing the cluster keep their
data in the bucket.
API keys and TLS
The chart can protect Qdrant with an API key and serve HTTPS. The operator passes both settings through and, because it talks to Qdrant itself for collections, backups and restores, reads the same key and CA it configured.
API keys
spec:
apiKey:
autoGenerate: true
readOnlyApiKey:
secretRef: {name: qdrant-readonly, key: api-key}
Set either secretRef or autoGenerate on each key, not both; the API
server rejects the combination. With autoGenerate the chart creates a
Secret named qdrant-<name>-apikey holding the key under api-key. With
secretRef you bring your own Secret in the cluster's namespace, and the
chart mounts it through valueFrom.secretKeyRef.
The operator resolves the full-access key the same way, from your Secret or
the generated one, and sends it as the api-key header on every call it
makes. readOnlyApiKey is for your clients only; the operator never uses it.
Without an apiKey Qdrant accepts unauthenticated requests. That is fine on
a private network and wrong anywhere else.
apiKey.jwtRbac: true additionally lets Qdrant accept tokens signed with
the API key, which is what access keys hand to your
applications: scoped, expiring credentials instead of the one key that can
do everything.
TLS
spec:
tls:
enabled: true
secretRef: {name: my-qdrant-tls}
tls.enabled requires secretRef.name. The Secret is a
kubernetes.io/tls Secret with tls.crt and tls.key, and the operator
mounts it at /qdrant/tls and sets service.enable_tls together with the
certificate paths in Qdrant's configuration. status.endpoint switches to
https://.
To verify the server certificate on its own calls, the operator reads
ca.crt from the same Secret. If the key is absent, it falls back to the
system trust store, which works for a certificate from a public CA and fails
for a self-signed one. cert-manager writes ca.crt into Secrets issued by its CA and
self-signed issuers, so a cert-manager Certificate for qdrant-<name>.<namespace>.svc
and its headless pod names covers both the Service and the per-node
snapshot traffic.
The certificate must be valid for the names the operator connects to:
qdrant-<name>.<namespace>.svc.cluster.local and
qdrant-<name>-<i>.qdrant-<name>-headless.<namespace>.svc.cluster.local
for every ordinal.
p2p TLS
cluster.p2p.enableTls turns on TLS for the inter-node Raft and shard
transfer traffic and reuses the same certificate. Qdrant then requires every
node to present a certificate the others trust, so the mounted tls.crt
must cover the headless pod names.
Where secrets are read from
| Secret | Namespace | Used for |
|---|---|---|
apiKey.secretRef / qdrant-<name>-apikey | the cluster's | Header on every operator call to Qdrant |
tls.secretRef | the cluster's | Served by Qdrant; ca.crt verifies the operator's calls |
storage.s3.credentialsSecretRef | the backup's, schedule's or restore's own | Bucket access |
A QdrantBackup in namespace ops can reference a cluster in namespace
prod through clusterRef.namespace, and the operator reads the cluster's
API key from prod. Its bucket credentials still come from ops.
Access keys
A QdrantAccessKey issues a Qdrant role-based access token and keeps it in a
Secret for your applications. Qdrant validates such tokens as JSON Web Tokens
signed with the cluster's API key, so no call to Qdrant is needed to create
one: the operator signs the claims itself and rewrites the Secret before the
token expires.
Turn on JWT RBAC
apiVersion: qdrant.io/v1alpha1
kind: QdrantCluster
metadata:
name: my-qdrant
spec:
version: v1.16.3
apiKey:
autoGenerate: true
jwtRbac: true
apiKey.jwtRbac sets service.jwt_rbac in Qdrant's configuration and needs
an API key to sign with, either secretRef or autoGenerate; the API server
rejects jwtRbac without one. The full-access key keeps working as before,
and tokens signed with it are accepted next to it. A QdrantAccessKey
against a cluster without jwtRbac stays Pending with reason
JwtRbacDisabled.
Qdrant's parser accepts HS256 only, and HS256 wants at least 32 bytes of key.
The chart's generated key is long enough; with secretRef, use 32 random
bytes or more, or the operator logs a warning on every token.
A read-only token for one collection
apiVersion: qdrant.io/v1alpha1
kind: QdrantAccessKey
metadata:
name: search-frontend
spec:
clusterRef: {name: my-qdrant}
collections:
- name: documents
access: r
subject: search-frontend
ttl: 720h
NAME CLUSTER ACCESS PHASE SECRET EXPIRES AGE
search-frontend my-qdrant Ready search-frontend 2026-10-24T07:30:00Z 4s
The Secret search-frontend in the same namespace now holds two keys:
token, the signed JWT, and url, the cluster's in-cluster HTTP URL. Mount
or inject both:
env:
- name: QDRANT_URL
valueFrom: {secretKeyRef: {name: search-frontend, key: url}}
- name: QDRANT_API_KEY
valueFrom: {secretKeyRef: {name: search-frontend, key: token}}
Qdrant clients send the token where they would send the API key, in the
api-key header or as a bearer token.
Access levels
Set exactly one of access and collections.
| Field | Values | Meaning |
|---|---|---|
access | r | Read everything on the cluster |
m | Manage everything, the same as the API key | |
collections[].access | r | Read this collection |
rw | Read and write points, manage the collection | |
prw | Read and write points only; no snapshots, no index or schema changes |
subject becomes the sub claim, which Qdrant records in its logs and
which your own auditing can use. valueExists adds Qdrant's condition that
the token is valid only while a point matching every listed key and value
exists in the named collection, so deleting that point revokes the token
without touching the API key:
spec:
access: r
valueExists:
collection: api_tokens
matches:
- {key: owner, value: search-frontend}
- {key: active, value: true}
Lifetime and renewal
ttl is a duration in hours, minutes and seconds such as 720h or 1h30m
and sets the exp claim. Without ttl the token never expires. With it,
the operator writes a fresh token at renewAt, which is renewBefore ahead
of expiry, or a third of ttl when renewBefore is unset. The check runs
once a minute. Applications must re-read the Secret to pick the new token
up; a Secret mounted as a file is updated by the kubelet, an environment
variable is not.
A token is also re-issued when the spec changes, when the Secret is deleted,
and when the cluster's API key changes. status.keyFingerprint records the
key the token was signed with.
Revocation
A JWT cannot be revoked. Deleting the QdrantAccessKey deletes its Secret
through the owner reference, but a copy of the token stays valid until exp.
To cut a token off early, use valueExists, keep ttl short, or rotate the
cluster's API key, which invalidates every token at once and makes the
operator re-issue every QdrantAccessKey on its next tick.
Status
| Field | Meaning |
|---|---|
phase | Pending while blocked, Ready with a token in the Secret, Failed on an error |
secretRef | Name and key of the token |
issuedAt, expiresAt, renewAt | The current token's timeline |
keyFingerprint | Prefix of the SHA-256 of the signing key |
conditions[Ready] | TokenIssued, JwtRbacDisabled or ApiKeyMissing |
Collections
A QdrantCollection declares a collection inside a cluster and keeps it that
way. The operator creates the collection when it is missing, patches what
Qdrant allows to change on a live collection, and manages payload indexes and
aliases. It runs on every spec change and once a minute, so a collection or
index someone dropped by hand comes back.
A minimal collection
apiVersion: qdrant.io/v1alpha1
kind: QdrantCollection
metadata:
name: documents
spec:
clusterRef: {name: my-qdrant}
vectors:
- size: 1536
distance: Cosine
One entry without a name is Qdrant's single unnamed vector. Name every
entry to declare several dense vector spaces; the API server rejects a mix.
Declare at least one of vectors or sparseVectors.
collectionName defaults to metadata.name. Set it when the Qdrant name
needs characters a Kubernetes name cannot carry, such as underscores or
upper case. Both clusterRef and collectionName are immutable.
Vectors, sparse vectors and multivectors
spec:
vectors:
- name: text
size: 768
distance: Cosine
datatype: float16
onDisk: true
hnsw: {m: 32, ef_construct: 200}
quantization: {scalar: {type: int8, always_ram: true}}
- name: colbert
size: 128
distance: Dot
multivector: {comparator: max_sim}
sparseVectors:
- name: bm25
modifier: idf
index: {on_disk: true}
hnsw, quantization and index take Qdrant's own field names verbatim
and are passed through untouched, so anything the Qdrant version you run
accepts is valid here.
Collection-wide settings
spec:
shardNumber: 6
replicationFactor: 2
writeConsistencyFactor: 1
onDiskPayload: true
hnsw: {m: 16, ef_construct: 100}
optimizers: {indexing_threshold: 20000}
quantization: {scalar: {type: int8}}
wal: {wal_capacity_mb: 64}
strictMode: {enabled: true, max_query_limit: 1000}
hnsw, optimizers, quantization, wal and strictMode map to the
*_config bodies of the collection API. wal applies at creation only;
Qdrant has no way to change it afterwards, and the operator does not compare
it.
What can change on a live collection
| Field | On change |
|---|---|
vectors[].size, distance, datatype, multivector | Immutable. The resource turns Degraded |
shardNumber, shardingMethod | Immutable. The resource turns Degraded |
vectors[].onDisk, hnsw, quantization | Patched per vector |
sparseVectors | Patched |
replicationFactor, writeConsistencyFactor, readFanOutFactor, onDiskPayload | Patched |
hnsw, optimizers, quantization, strictMode | Patched |
wal | Applied at creation, never compared afterwards |
payloadIndexes, aliases | Reconciled, see below |
When an immutable field differs from the live collection, the phase becomes
Degraded with reason ImmutableFieldMismatch, nothing is changed, and
indexes and aliases are left as they are until the mismatch is resolved. Fix
it by reverting the spec, or by deleting and re-creating the collection
through a resource with deletionPolicy: Delete.
The operator compares only fields the spec declares. Anything you leave out
stays at whatever Qdrant chose, and setting a boolean to false matches a
value Qdrant reports as unset.
Payload indexes
spec:
payloadIndexes:
- field: tenant
type: keyword
- field: body
type: text
params: {tokenizer: word, lowercase: true, min_token_len: 2}
- field: created_at
type: datetime
Each entry becomes PUT /collections/<name>/index with field_schema set to
the bare type, or to the type plus params when given. An existing index of
another type or with other parameters is dropped and re-created. Indexes are
removed only when this resource created them earlier and the spec no longer
lists them; indexes created by applications stay. status.payloadIndexes
lists the fields under management.
Aliases
spec:
aliases: [documents-current]
Aliases are pointed at this collection through one atomic alias update. An
alias that currently points elsewhere is moved. As with indexes, the operator
deletes an alias only when it created it and the spec dropped it, and
status.aliases lists the ones it manages.
Deleting
deletionPolicy defaults to Retain: deleting the resource leaves the
collection in Qdrant. With Delete the operator issues
DELETE /collections/<name> and ignores a 404. When the cluster itself is
already gone, deletion completes without contacting Qdrant.
Status
NAME CLUSTER COLLECTION PHASE POINTS HEALTH AGE
documents my-qdrant documents Ready 120044 green 3d
health is Qdrant's own collection status (green, yellow, grey,
red), and pointsCount, indexedVectorsCount and segmentsCount are
copied from the collection info on every reconcile. The Ready condition
carries CollectionReady or the mismatch reason.
Backups
A QdrantBackup snapshots every node of a cluster, collection by collection,
and streams each snapshot straight into an S3-compatible bucket. Nothing is
staged on the operator's disk. A manifest written next to the snapshots
records which node each one came from, so a restore needs no guessing.
Requirements
- A bucket on S3 or anything speaking its API. MinIO needs
endpointandforcePathStyle: true. - A Secret in the backup's namespace with the access key pair. The default
keys are
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY; other key names go incredentialsSecretRef.accessKeyIdKeyandsecretAccessKeyKey. - Network access from the operator pod to every Qdrant pod through the headless Service, and from every Qdrant pod to the bucket for restores.
- Disk on each node for the snapshot Qdrant writes before it is streamed.
snapshotPersistenceon the cluster gives that its own volume.
apiVersion: v1
kind: Secret
metadata:
name: s3-credentials
stringData:
AWS_ACCESS_KEY_ID: AKIA...
AWS_SECRET_ACCESS_KEY: ...
A one-off backup
apiVersion: qdrant.io/v1alpha1
kind: QdrantBackup
metadata:
name: before-upgrade
spec:
clusterRef: {name: my-qdrant}
storage:
s3:
bucket: qdrant-backups
prefix: prod/my-qdrant
region: eu-central-1
credentialsSecretRef: {name: s3-credentials}
collections: [documents, images]
retentionDays: 30
NAME CLUSTER PHASE SIZE AGE
before-upgrade my-qdrant Completed 1.3GB 2m
collections limits the backup; leave it out to back up every collection
the cluster lists. The spec is immutable, so a change of mind means a new
resource.
What happens
- The operator resolves the cluster: its Service URL, one URL per StatefulSet pod, the API key and the CA certificate. A missing cluster or Secret is retried every thirty seconds rather than failed.
- The phase moves to
InProgresswithstartTime. - For each collection, for each node in ordinal order:
POST /collections/<c>/snapshots?wait=trueon that node, thenGETthe snapshot as a stream into a multipart upload at<prefix>/<backup>/<collection>/node-<i>/<snapshot>, thenDELETEthe snapshot on the node. The node-local snapshot is deleted even when the upload fails. manifest.jsonis written at<prefix>/<backup>/listing the completed collections, their snapshots, sizes and checksums, and the node count.- The phase becomes
Completed, orFailedwhen any collection failed.status.collectionscarries each collection's outcome andstatus.errorthe first failure.
Collections and nodes are processed one after another, not in parallel. The
whole backup happens inside one handler invocation, so a restart of the
operator mid-way leaves the resource without a final phase; kopf then re-runs
the handler and the backup is taken again from the start. Snapshot names
carry Qdrant's timestamp, so the second run writes new objects and only
manifest.json is overwritten; the first run's partial objects stay under
the same root until the resource is deleted, when everything under it goes.
Consistency
Each snapshot is consistent for the shards on its node at the moment Qdrant took it. Snapshots on different nodes are taken a few seconds apart and are not coordinated, so writes that arrive during the backup may be in one node's snapshot and not another's. Pause writers or accept that window.
Retention and deletion
retentionDays sets status.expiresAt to the completion time plus that
many days. A timer checks every five minutes and deletes the resource once
the time has passed.
Deleting a QdrantBackup, by hand, by expiry or by a schedule's retention,
deletes every object under <prefix>/<backup>/ in the bucket. When the
credentials Secret is gone at that point, the operator logs a warning and
leaves the objects in place. To keep the data but drop the resource, copy
the objects first, or point a later QdrantRestore at their path through
source.s3.
Cross-namespace clusters
clusterRef.namespace defaults to the backup's namespace. When it names
another namespace, the operator reads the cluster and its API key Secret
there, while the bucket credentials still come from the backup's own
namespace.
Scheduled backups
A QdrantBackupSchedule creates QdrantBackup resources on a cron schedule
and prunes old ones. Its semantics follow Kubernetes CronJobs: one missed
slot is caught up, a concurrencyPolicy decides what happens when the
previous backup is still running, and startingDeadlineSeconds bounds how
late a run may start.
apiVersion: qdrant.io/v1alpha1
kind: QdrantBackupSchedule
metadata:
name: nightly
spec:
schedule: "0 2 * * *"
clusterRef: {name: my-qdrant}
storage:
s3:
bucket: qdrant-backups
prefix: prod/my-qdrant/nightly
credentialsSecretRef: {name: s3-credentials}
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
retentionPolicy:
keepLast: 3
keepDaily: 7
keepWeekly: 4
keepMonthly: 6
NAME SCHEDULE CLUSTER PHASE LAST BACKUP AGE
nightly 0 2 * * * my-qdrant Active 6h 12d
When a backup is created
schedule is a five-field cron expression evaluated in UTC. The operator
looks at the schedule once a minute and on every spec change. It computes the
most recent slot before now and creates a backup for it when that slot is
later than status.lastScheduleTime, or, before the first run, later than
the schedule's creation time. Only that one slot is created; if the operator
was down for a day, one catch-up backup is taken, not twenty-four.
startingDeadlineSeconds skips the slot when more than that many seconds
have passed since it was due. Without it a missed slot is always caught up.
The backup is named <schedule>-<YYYYmmdd-HHMMSS> after the slot time,
carries the label qdrant.io/schedule=<schedule>, and has the schedule as
its controller owner. Deleting the schedule therefore garbage-collects its
backups, and with them their objects in the bucket. collections and
storage are copied into each backup.
Concurrency
| Policy | When the previous backup is still running |
|---|---|
Forbid (default) | Skip this slot; it is retried at the next tick until the running backup finishes or the deadline passes |
Allow | Create the new backup alongside |
Replace | Delete the running backup, and its partial objects, then create the new one |
status.activeBackup names the backup in flight.
Retention
retentionPolicy keeps the union of four selections over the schedule's
finished backups, newest first:
keepLast: the N most recent.keepDaily: the newest backup of each of the N most recent days that have one.keepWeekly: the same per ISO week.keepMonthly: the same per month.
Every finished backup outside that union is deleted at the next tick, which
deletes its objects in the bucket. Backups still running are never pruned.
Without a retentionPolicy nothing is deleted. Retention runs even while the
schedule is suspended.
Suspending
suspend: true stops creating backups and sets the phase to Suspended.
Retention and status updates continue. lastScheduleTime is frozen while
suspended, so resuming takes one catch-up backup for the most recent slot
missed in the meantime, unless startingDeadlineSeconds rules it out; then
the schedule continues normally.
Status
| Field | Meaning |
|---|---|
lastScheduleTime | The cron slot the most recent backup was created for |
lastBackupName, lastBackupTime, lastBackupStatus | The most recent finished backup, its completion time and phase |
nextBackupTime | The next slot after now |
activeBackup | A backup that is Pending or InProgress |
recentBackups | The ten newest backups with creation time, completion time, phase and size |
Restore
A QdrantRestore recovers the collections of a backup into a cluster. Each
target node receives the snapshot that the source node with the same ordinal
produced, through Qdrant's snapshot recovery API and a presigned URL, so the
data flows from the bucket to the node without passing through the operator.
From a backup resource
apiVersion: qdrant.io/v1alpha1
kind: QdrantRestore
metadata:
name: restore-documents
spec:
backupRef: {name: nightly-20260924-020000}
targetClusterRef: {name: my-qdrant-new}
collections: [documents]
collectionMapping:
documents: documents_restored
priority: snapshot
waitForIndexing: true
backupRef must name a QdrantBackup whose phase is Completed. A backup
still running is retried every thirty seconds. The restore reads the
backup's storage settings and root path, and the bucket credentials from the
backup's namespace.
From a bucket path
spec:
source:
s3:
bucket: qdrant-backups
path: prod/my-qdrant/nightly/nightly-20260924-020000
endpoint: http://minio.minio.svc:9000
forcePathStyle: true
credentialsSecretRef: {name: s3-credentials}
targetClusterRef: {name: my-qdrant}
source.s3.path is the key prefix that holds manifest.json. This is the
way to restore into another Kubernetes cluster, or after the QdrantBackup
resource is gone. Set exactly one of backupRef and source; the API
server rejects both or neither. The spec is immutable.
Node counts must match
The manifest records how many nodes the source cluster had. The target cluster must have the same number of replicas, or the restore fails before touching any collection with a message naming both counts. Shards are placed per node, and there is no re-sharding on the way in. To restore into a cluster of a different size, create a cluster of the original size, restore, then let Qdrant move shards.
What happens
- The phase moves to
Downloadingwhile the manifest is read. collectionsselects a subset; a name missing from the manifest fails the restore. Withoutcollections, every collection in the manifest is restored.- For each collection, phase
Restoringwithprogressupdated first, then for each snapshot in the manifest: a presignedGETURL valid for one hour, andPUT /collections/<target>/snapshots/recover?wait=trueon the node with the same ordinal, passing the URL,priorityand the checksum. - With
waitForIndexing, the collection is polled every five seconds until its status isgreen, for up to one hour. Without it, the restore records the point count and moves on. Completed, orFailedwhen any collection failed.status.restoredCollectionslists each collection with its original name when remapped, size, point count and error.
Recovering a snapshot into an existing collection replaces it on that node.
collectionMapping renames on the way in, so a restore next to the live
collection is possible.
Priority
priority | Meaning |
|---|---|
snapshot (default) | The snapshot wins; existing data for the collection on that node is replaced |
replica | Existing replicas win; the snapshot fills in what is missing |
no_sync | Load the snapshot and skip synchronisation with other replicas |
These are Qdrant's SnapshotPriority values, passed through unchanged.
After the restore
Collections come back with the configuration they had at backup time. A
QdrantCollection resource for the same collection will, on its next
reconcile, compare the restored configuration with its spec and patch the
mutable parts or report Degraded on an immutable mismatch. Aliases are not
part of a snapshot; declare them on a QdrantCollection or re-create them
by hand.
Migrations
A QdrantMigration copies collections point by point from any reachable
Qdrant into a cluster the operator manages. The source can be another
QdrantCluster, a Qdrant Cloud cluster, or a server outside Kubernetes;
only the target has to be managed. Use it to move into the operator, to
change a cluster's node count or sharding, or to clone data into a staging
cluster.
From Qdrant Cloud
apiVersion: v1
kind: Secret
metadata:
name: qdrant-cloud
stringData:
api-key: eyJhbGciOi...
---
apiVersion: qdrant.io/v1alpha1
kind: QdrantMigration
metadata:
name: from-cloud
spec:
source:
endpoint:
url: https://xyz-example.eu-central.aws.cloud.qdrant.io:6333
apiKeySecretRef: {name: qdrant-cloud, key: api-key}
targetClusterRef: {name: my-qdrant}
collections: [documents, images]
batchSize: 512
NAME SOURCE TARGET PHASE PROGRESS AGE
from-cloud https://xyz-example.eu-central.aws.cloud... my-qdrant Running 37 2m
from-cloud https://xyz-example.eu-central.aws.cloud... my-qdrant Completed 100 9m
endpoint.url is the HTTP URL of the source. apiKeySecretRef and
caSecretRef name Secrets in the migration's namespace; the CA is a PEM
bundle for a source behind a private certificate authority.
Between two managed clusters
spec:
source:
clusterRef: {name: my-qdrant}
targetClusterRef: {name: my-qdrant-v2}
collectionMapping:
documents: documents_v2
target:
replicationFactor: 2
With source.clusterRef the operator resolves the source's Service, API key
and CA the way it does for backups. Set exactly one of clusterRef and
endpoint. The spec is immutable.
What happens
- The source and target are resolved; the target must answer
/readyzor the migration is retried every thirty seconds. collectionsselects the collections, or every collection the source lists. Each one's point count is read, approximately, and the phase moves toRunningwith a per-collection entry in the status.- For each collection, when the target collection does not exist and
createMissingis true, it is created from the source's configuration: vectors, sparse vectors, sharding method, payload on disk, HNSW, optimizer, WAL, quantization and strict mode settings, plus every payload index the source has. Shard number, replication factor and write consistency are left to the target cluster's defaults so a copy from one node to three gets three shards, unlesstargetoverrides them. - Points are scrolled from the source in pages of
batchSizewith payload and vectors, and upserted on the target withwait=true, grouped by shard key. Progress is written every twenty batches. Completed, orFailedwhen any collection failed.status.collectionscarries each one's counts and error.
Upserts are idempotent by point id. If the operator restarts in the middle, kopf re-runs the handler and points already copied are written again with the same content, so a migration is safe to repeat and a second migration with the same spec brings a target up to date.
Consistency
The copy is not a snapshot. Points written to the source while a collection
is being scrolled may or may not be in the target, and points deleted on the
source are never deleted on the target. Stop writers before the final run,
or run once for the bulk and once more after cutover to pick up the rest.
pointsTotal is Qdrant's approximate count, so percentage can end short
of or above the real figure while running.
Choosing between migration and restore
QdrantRestore | QdrantMigration | |
|---|---|---|
| Source | A backup in a bucket | A live Qdrant |
| Node count | Must match the backup | Any |
| Sharding | Kept as at backup time | Target decides, or target overrides |
| Speed | Snapshot files, fast | Point by point over HTTP |
| Consistency | As of the snapshot | Live, see above |
Status
| Field | Meaning |
|---|---|
phase | Pending, Running, Completed or Failed |
source | <namespace>/<name> of the source cluster, or the endpoint URL |
collections[] | Per collection: name, targetName, status, pointsTotal, pointsCopied, error |
progress | collectionsTotal, collectionsCompleted, pointsTotal, pointsCopied, percentage |
conditions[Complete] | MigrationCompleted or MigrationFailed, message Copied n/m |
Upgrades and configuration changes
What a spec change does
Every change to a QdrantCluster spec runs helm upgrade --install again
with the values derived from the new spec. Helm then does what it always
does: the StatefulSet is patched, and the StatefulSet controller rolls pods
one at a time, highest ordinal first, waiting for each to become ready. The
resource reports Upgrading with Progressing: True until every replica is
ready again.
Fields that change the pod template roll the pods: version, image,
resources, config, tls, apiKey, readOnlyApiKey, nodeSelector,
tolerations, affinity. Changes to service,
metrics and replicas do not restart existing pods.
What cannot change
persistence.size and persistence.storageClassName are rejected by the
API server, because a StatefulSet's volume claim template cannot change.
Expand the PVCs in place if the storage class allows it, or take a
backup and restore into a new cluster.
replicas > 1 with cluster.enabled: false is rejected as well.
snapshotPersistence is a second volume claim template in the chart's
StatefulSet, so it is just as immutable, but the CRD does not guard it:
turning it on or off on an existing cluster makes helm upgrade fail and
the reconcile retry until the spec is reverted. Decide on it when creating
the cluster.
Upgrading Qdrant
kubectl patch qc my-qdrant --type merge -p '{"spec":{"version":"v1.16.4"}}'
Take a QdrantBackup first. Because version is also the chart version,
read the chart's release notes along with Qdrant's: a chart release can
change defaults or rename values that your config relies on. Qdrant
supports rolling upgrades between adjacent minor versions in distributed
mode; check the Qdrant release notes before skipping versions or crossing a
major one. Downgrades are not guaranteed.
Scaling
Raising replicas adds pods that join the cluster through the p2p port and
start empty. Qdrant does not rebalance existing shards onto them by itself;
use the cluster API to move shards or set replicationFactor on your
collections so new replicas are created. Lowering replicas removes the
highest ordinals; move their shards away first, or the collections holding
them turn yellow or red.
A QdrantCollection with shardNumber unset lets Qdrant pick the shard
count at creation time from the node count then, and the number never
changes afterwards.
Upgrading the operator
kubectl apply -f manifests/crds/
helm upgrade qdrant-operator oci://ghcr.io/4thel00z/charts/qdrant-operator \
--namespace qdrant-system --version <version>
Helm installs CRDs on first install only, so apply them from the new release
before upgrading the chart. The operator's Deployment uses the Recreate
strategy: the old pod stops before the new one starts, and handlers that were
in flight, such as a running backup, are re-run by the new pod.
On startup the new operator resumes every resource. For QdrantCluster that
means one helm upgrade --install per cluster with the values the new
version renders. A version that renders the same values changes nothing; one
that renders different values rolls the pods once.
Troubleshooting
Every kind carries a phase and a Ready or Complete condition whose
reason names the situation and whose message gives the detail. Start
there, then the operator log:
kubectl describe qc my-qdrant
kubectl get qb first -o jsonpath='{.status.error}{"\n"}'
kubectl logs -n qdrant-system deploy/qdrant-operator -f
Retries show up in the log as kopf TemporaryError lines with the reason and
the thirty-second delay. Permanent failures write the phase Failed and the
error into the status and stop.
QdrantCluster
| Symptom | Meaning | What to do |
|---|---|---|
Pending and stays there | The Helm release is applied but pods are not ready | kubectl get pods -l app.kubernetes.io/instance=qdrant-<name>, then describe the pod that is not ready |
| No phase and no pods | The first helm upgrade --install failed before any status was written; the operator retries | Look for helm upgrade failed in the log. Common causes: a chart version that does not exist for that version, a config key the chart rejects, missing monitoring.coreos.com CRDs with a ServiceMonitor enabled |
Pods Pending | No node fits or no volume can be bound | kubectl describe pod: check resources, nodeSelector, affinity and the storage class |
Pods CrashLoopBackOff | Qdrant rejects its configuration | Container logs; a typo under config lands in production.yaml verbatim |
Upgrading for a long time | A rolling update is stuck on one pod | The StatefulSet waits for each pod to be ready before the next; fix that pod |
Only Pending, Upgrading and Running are set by the operator; a Helm
error keeps the previous phase and is retried.
QdrantCollection
| Reason | Meaning |
|---|---|
CollectionReady | The collection, its indexes and aliases match the spec |
ImmutableFieldMismatch | vectors, shardNumber or shardingMethod differ from the live collection; phase Degraded, nothing changed |
| retried: cluster not found | clusterRef names a QdrantCluster that does not exist yet |
| retried: cluster not ready | /readyz on the Service does not answer 200 |
Failed with an HTTP error | Qdrant rejected a request; the message carries Qdrant's response. Usually a field name Qdrant does not know inside hnsw, optimizers, quantization, strictMode or params |
A 401 or 403 from Qdrant means the operator's API key does not match: it
uses apiKey.secretRef or the generated qdrant-<name>-apikey Secret, and
nothing else.
QdrantAccessKey
| Reason | Meaning |
|---|---|
TokenIssued | The Secret holds a current token |
JwtRbacDisabled | The cluster's apiKey.jwtRbac is false; phase Pending until it is turned on |
ApiKeyMissing | The cluster has neither apiKey.secretRef nor autoGenerate; nothing to sign with |
| retried: cluster or Secret not found | clusterRef or the API key Secret does not exist yet |
Qdrant answering 401 to a token that the operator reports as Ready
means the token was signed with a different key than Qdrant runs with: the
cluster's API key was rotated and Qdrant's pods have not restarted yet, or
the other way round. status.keyFingerprint changes when the operator sees
the new key; the pods pick it up on the roll helm upgrade triggers.
QdrantMigration
| Message | Meaning |
|---|---|
QdrantCluster ... not ready | The target does not answer /readyz; retried |
Collections not present in source | collections names something the source does not list |
Collection ... missing on target and createMissing is false | Create the target collection first or allow creation |
4xx from the source on points/scroll | Wrong or missing apiKeySecretRef, or a token without read access |
| TLS verification failed | A source behind a private CA needs caSecretRef |
Failed on upsert with a dimension error | The target collection exists with a different vector size; rename via collectionMapping or drop it |
A migration whose percentage runs past what you expect is counting against
Qdrant's approximate pointsTotal; the final pointsCopied is exact.
QdrantBackup
| Phase | Meaning |
|---|---|
Pending | Waiting for the cluster or the credentials Secret; retried every thirty seconds |
InProgress | Snapshots are being taken and uploaded |
Completed | manifest.json written; s3Path and totalSize set |
Failed | status.error holds the first collection's error, status.collections each one's |
Frequent causes of Failed:
- Connection refused or timeout to a pod URL. The operator reaches nodes
as
qdrant-<name>-<i>.qdrant-<name>-headless.<ns>.svc.cluster.local. A NetworkPolicy between the operator namespace and the cluster namespace, or areplicasvalue higher than the pods that exist, produces this. - TLS certificate verification failed. With
tls.enabled, the operator needsca.crtin the TLS Secret, or a certificate from a CA in the system trust store, and the certificate must cover the headless pod names. - No space left on device from Qdrant. The snapshot did not fit on the
node's volume. Enable
snapshotPersistenceor grow the data volume. - Access denied from the bucket. Check the credentials Secret, the
bucket policy, and for MinIO
endpointplusforcePathStyle: true.
Deleting a Failed backup still removes what was uploaded.
QdrantBackupSchedule
| Symptom | Meaning |
|---|---|
| No backup at the expected time | The slot is computed in UTC; check nextBackupTime. With Forbid, a still running backup blocks the slot, see activeBackup |
| One backup right after the operator restarted | A catch-up for the most recent missed slot. Set startingDeadlineSeconds to skip stale slots |
| Old backups disappearing | retentionPolicy pruned them, together with their objects in the bucket |
| Backups gone after deleting the schedule | They were owned by the schedule and garbage-collected |
QdrantRestore
| Message | Meaning |
|---|---|
QdrantBackup ... is InProgress, not Completed | Retried until the backup finishes |
Backup was taken from N nodes but target cluster has M | Node counts must match; create a target with N replicas |
Collections not present in backup | collections names something the manifest does not have |
Collection ... did not turn green in time | Indexing took more than one hour; the data is there, only the wait timed out |
A node returns 4xx on snapshots/recover | The node could not fetch the presigned URL. Nodes need network access to the bucket endpoint, and the URL expires after one hour |
Operator pod
The pod runs as uid 1000 with a read-only root filesystem and Helm's cache
directories under an emptyDir at /tmp. helm failures mentioning
permission denied point at a policy that replaced that volume. The pod
answers /healthz on port 8080; a restart loop there means kopf could not
start, and the first log lines say why, typically RBAC on the CRDs or
missing CRDs.
Turn the log up with --set operator.logLevel=DEBUG to see every helm
command line and every Qdrant request the operator makes.
QdrantCluster
qdrantclusters.qdrant.io, short name qc, namespaced. One Helm release of
the qdrant chart, named qdrant-<name>.
Spec
| Field | Default | Description |
|---|---|---|
version | required | Qdrant version and chart version, v1.16.3 or 1.16.3 |
replicas | 1 | StatefulSet replicas. More than one requires cluster.enabled |
image.repository | docker.io/qdrant/qdrant | The tag always follows version |
image.pullPolicy | IfNotPresent | Always, IfNotPresent or Never |
resources.requests, resources.limits | {} | cpu and memory |
persistence.size | 10Gi | Immutable |
persistence.storageClassName | cluster default | Immutable |
persistence.accessModes | [ReadWriteOnce] | |
snapshotPersistence.enabled | false | Separate volume for snapshots. Fixed at creation; a change fails the Helm upgrade |
snapshotPersistence.size | 10Gi | |
snapshotPersistence.storageClassName | cluster default | |
cluster.enabled | true | Distributed mode |
cluster.p2p.port | 6335 | |
cluster.p2p.enableTls | false | TLS on the inter-node port, reusing tls.secretRef |
service.type | ClusterIP | ClusterIP, NodePort or LoadBalancer |
service.annotations | {} | |
apiKey.secretRef.name, apiKey.secretRef.key | Bring-your-own full-access key. Exclusive with autoGenerate | |
apiKey.autoGenerate | false | Chart generates qdrant-<name>-apikey with key api-key |
apiKey.jwtRbac | false | Qdrant's service.jwt_rbac; needed by QdrantAccessKey. Requires secretRef or autoGenerate |
readOnlyApiKey | Same fields as apiKey; never used by the operator | |
tls.enabled | false | Requires tls.secretRef.name |
tls.secretRef.name | kubernetes.io/tls Secret; ca.crt in it verifies the operator's own calls | |
metrics.enabled | false | Together with serviceMonitor.enabled renders a ServiceMonitor |
metrics.serviceMonitor.enabled | false | |
metrics.serviceMonitor.interval | 30s | Scrape interval |
metrics.serviceMonitor.labels | {} | Added to the ServiceMonitor |
nodeSelector, tolerations, affinity | Pod passthroughs | |
config | {} | Merged over the operator's entries into the chart's config, Qdrant's production.yaml |
Admission rules: replicas > 1 needs cluster.enabled; persistence.size
and persistence.storageClassName cannot change; apiKey and
readOnlyApiKey take secretRef or autoGenerate, not both;
apiKey.jwtRbac needs one of them; tls.enabled needs tls.secretRef.name.
Status
| Field | Meaning |
|---|---|
phase | Pending, Upgrading or Running |
replicas, readyReplicas | Desired count and the StatefulSet's ready count |
version | spec.version as last applied |
helmRelease | qdrant-<name> |
endpoint | http(s)://qdrant-<name>.<namespace>.svc.cluster.local:6333 |
conditions[Progressing] | HelmReleaseApplied after each apply, RolloutComplete once ready |
conditions[Ready] | AllReplicasReady or ReplicasNotReady, message n/m replicas ready |
observedGeneration | Spec generation the last apply reflects |
Behavior
On create, spec update and operator start: helm upgrade --install qdrant-<name> qdrant --repo https://qdrant.github.io/qdrant-helm --version <version> --namespace <namespace> --create-namespace --values - with the
values from Helm values the operator renders on stdin.
The phase is Pending on a first install and Upgrading when the release
existed. Every thirty seconds the StatefulSet qdrant-<name> is read and
Running set once readyReplicas reaches replicas. On delete: helm uninstall --wait; PVCs are left behind.
Print columns: PHASE, REPLICAS, READY, VERSION, AGE.
QdrantCollection
qdrantcollections.qdrant.io, short name qcol, namespaced. One collection
inside a cluster, with its payload indexes and aliases.
Spec
| Field | Default | Description |
|---|---|---|
clusterRef.name | required | The QdrantCluster. Immutable |
clusterRef.namespace | own namespace | |
collectionName | metadata.name | Name inside Qdrant. Immutable |
vectors[] | Dense vector spaces, up to 64. One unnamed entry is the single unnamed vector | |
vectors[].name | Required when there is more than one entry | |
vectors[].size | required | Dimension. Immutable |
vectors[].distance | required | Cosine, Euclid, Dot or Manhattan. Immutable |
vectors[].datatype | Qdrant default | float32, float16 or uint8. Immutable |
vectors[].multivector.comparator | max_sim. Immutable | |
vectors[].onDisk | Qdrant default | Mutable |
vectors[].hnsw | {} | Per-vector HnswConfigDiff, passed through. Mutable |
vectors[].quantization | {} | Per-vector QuantizationConfig, passed through. Mutable |
sparseVectors[] | Up to 64, each with a required name | |
sparseVectors[].modifier | none or idf | |
sparseVectors[].index | {} | SparseIndexParams, passed through |
shardNumber | Qdrant default | Immutable |
shardingMethod | auto | auto or custom. Immutable |
replicationFactor, writeConsistencyFactor, readFanOutFactor | Qdrant default | Mutable |
onDiskPayload | Qdrant default | Mutable |
hnsw | {} | Collection-wide HnswConfigDiff |
optimizers | {} | OptimizersConfigDiff |
quantization | {} | Collection-wide QuantizationConfig |
wal | {} | WalConfigDiff, creation only |
strictMode | {} | StrictModeConfig |
payloadIndexes[] | Up to 256, keyed by field | |
payloadIndexes[].field | required | Payload key to index |
payloadIndexes[].type | required | keyword, integer, float, geo, text, bool, datetime or uuid |
payloadIndexes[].params | {} | Type-specific index parameters |
aliases[] | Up to 64 aliases that must point at this collection | |
deletionPolicy | Retain | Retain or Delete |
Admission rules: at least one of vectors and sparseVectors; an unnamed
vector must be the only vector; clusterRef and collectionName cannot
change.
Status
| Field | Meaning |
|---|---|
phase | Pending, Ready, Degraded or Failed |
collectionName | The name inside Qdrant |
health | Qdrant's collection status: green, yellow, grey or red |
pointsCount, indexedVectorsCount, segmentsCount | From the collection info |
payloadIndexes | Fields whose indexes this resource manages |
aliases | Aliases this resource manages |
error | The mismatch or failure message |
observedGeneration | Spec generation the status reflects |
conditions[Ready] | CollectionReady or ImmutableFieldMismatch |
Behavior
On create, spec update, operator start and once a minute: resolve the
cluster, require /readyz, PUT /collections/<name> when the collection
does not exist, then compare the live config with the spec. An immutable
mismatch sets Degraded and stops. A mutable mismatch issues
PATCH /collections/<name>. Payload indexes are created, replaced when their
type or parameters differ, and deleted only when this resource created them
and the spec dropped them. Aliases are handled the same way through
POST /collections/aliases.
A cluster that is missing or not ready is retried every thirty seconds.
Any other error sets Failed with the message.
On delete with deletionPolicy: Delete, DELETE /collections/<name>; a
missing collection or a missing cluster completes the deletion silently.
Print columns: CLUSTER, COLLECTION, PHASE, POINTS, HEALTH, AGE.
QdrantAccessKey
qdrantaccesskeys.qdrant.io, short name qak, namespaced. A Qdrant RBAC
token, signed with the cluster's API key and kept in a Secret.
Spec
| Field | Default | Description |
|---|---|---|
clusterRef.name | required | A QdrantCluster with apiKey.jwtRbac: true. Immutable |
clusterRef.namespace | own namespace | |
access | r or m, cluster-wide. Exclusive with collections | |
collections[] | Up to 256, keyed by name. Exclusive with access | |
collections[].name | required | Collection inside Qdrant |
collections[].access | required | r, rw or prw |
subject | unset | The sub claim |
ttl | never expires | Lifetime such as 720h or 1h30m |
renewBefore | a third of ttl | How long before expiry a new token is written. Needs ttl |
valueExists.collection | Token valid only while a matching point exists here | |
valueExists.matches[] | One to 32 {key, value} pairs the point must carry | |
secretName | metadata.name | Secret receiving the token. Immutable |
Admission rules: exactly one of access and collections; renewBefore
needs ttl; clusterRef and secretName cannot change.
Status
| Field | Meaning |
|---|---|
phase | Pending, Ready or Failed |
secretRef.name, secretRef.key | Where the token is; the key is token |
issuedAt, expiresAt, renewAt | Timeline of the current token |
keyFingerprint | First sixteen hex digits of the SHA-256 of the signing key |
error | Why the phase is not Ready |
observedGeneration | Spec generation the token reflects |
conditions[Ready] | TokenIssued, JwtRbacDisabled or ApiKeyMissing |
Behavior
On create, spec update, operator start and once a minute: read the cluster,
stay Pending when jwtRbac is off or it has no API key, read the API key,
and re-issue when the phase is not Ready, the Secret is missing, the spec
generation or key fingerprint changed, or renewAt has passed. The Secret
is Opaque with keys token and url, lives in the access key's namespace,
and is owned by the resource, so it is garbage-collected on delete. The
token is an HS256 JWT with claims sub, exp, access and value_exists,
each only when set.
Print columns: CLUSTER, ACCESS, PHASE, SECRET, EXPIRES, AGE.
QdrantBackup
qdrantbackups.qdrant.io, short name qb, namespaced. One backup of a
cluster: a snapshot per collection per node, in a bucket, with a manifest.
The spec is immutable.
Spec
| Field | Default | Description |
|---|---|---|
clusterRef.name | required | The QdrantCluster |
clusterRef.namespace | own namespace | |
storage.s3.bucket | required | |
storage.s3.prefix | "" | Key prefix; the backup lives under <prefix>/<name>/ |
storage.s3.region | us-east-1 | |
storage.s3.endpoint | AWS | Custom endpoint for MinIO and other S3-compatible stores |
storage.s3.forcePathStyle | false | Path-style URLs, required for MinIO |
storage.s3.credentialsSecretRef.name | required | Secret in the backup's namespace |
storage.s3.credentialsSecretRef.accessKeyIdKey | AWS_ACCESS_KEY_ID | |
storage.s3.credentialsSecretRef.secretAccessKeyKey | AWS_SECRET_ACCESS_KEY | |
collections[] | all | Collections to back up |
retentionDays | unset | Days after completion until the resource deletes itself |
Status
| Field | Meaning |
|---|---|
phase | Pending, InProgress, Completed or Failed |
startTime, completionTime | |
expiresAt | completionTime plus retentionDays |
s3Path | s3://<bucket>/<prefix>/<name> |
totalSize | Sum of the completed snapshots, human-readable |
collections[] | Per collection: name, status, size, snapshots[] with node, snapshotName and key, and error |
error | The first failed collection's error |
conditions[Complete] | BackupCompleted or BackupFailed, message Backed up n/m collections |
Behavior
On create only. Resolves the cluster and the credentials, retrying every
thirty seconds while either is missing, sets InProgress, then for each
collection and each node in ordinal order creates a snapshot, streams it to
<prefix>/<name>/<collection>/node-<i>/<snapshot> and deletes it on the
node. Writes manifest.json under <prefix>/<name>/ and sets Completed,
or Failed when any collection failed. With retentionDays, a timer every
five minutes deletes the resource once expiresAt has passed.
On delete: every object under <prefix>/<name>/ is removed. If the
credentials Secret is missing, a warning is logged and the objects stay.
Print columns: CLUSTER, PHASE, SIZE, AGE.
QdrantBackupSchedule
qdrantbackupschedules.qdrant.io, short name qbs, namespaced. Backups on
a cron schedule with retention.
Spec
| Field | Default | Description |
|---|---|---|
schedule | required | Five-field cron expression, UTC |
clusterRef, storage, collections | as on QdrantBackup | Copied into each backup |
suspend | false | Stop creating backups; retention continues |
concurrencyPolicy | Forbid | Allow, Forbid or Replace |
startingDeadlineSeconds | unset | Skip a missed slot older than this |
retentionPolicy.keepLast | unset | Keep the N newest finished backups |
retentionPolicy.keepDaily | unset | Keep the newest per day for N days |
retentionPolicy.keepWeekly | unset | Keep the newest per ISO week for N weeks |
retentionPolicy.keepMonthly | unset | Keep the newest per month for N months |
Status
| Field | Meaning |
|---|---|
phase | Active or Suspended |
lastScheduleTime | Slot the most recent backup was created for |
lastBackupName, lastBackupTime, lastBackupStatus | Newest finished backup, its completion time and phase |
nextBackupTime | Next slot after now |
activeBackup | Name of a backup still running |
recentBackups[] | Up to ten, newest first: name, creationTime, completionTime, status, size |
Behavior
On create, spec update, operator start and once a minute: list the backups
labelled qdrant.io/schedule=<name>, delete the finished ones the retention
policy does not keep, then, unless suspended, compute the latest slot before
now and create <name>-<YYYYmmdd-HHMMSS> for it when it is newer than
lastScheduleTime (or the schedule's creation time) and within
startingDeadlineSeconds. With Forbid, a running backup skips the slot;
with Replace, it is deleted first. Created backups carry the schedule as
their controller owner reference and are garbage-collected with it.
Print columns: SCHEDULE, CLUSTER, PHASE, LAST BACKUP, AGE.
QdrantRestore
qdrantrestores.qdrant.io, short name qr, namespaced. Recovery of a
backup into a cluster. The spec is immutable.
Spec
| Field | Default | Description |
|---|---|---|
backupRef.name | A Completed QdrantBackup. Exclusive with source | |
backupRef.namespace | own namespace | |
source.s3.bucket | Direct bucket source. Exclusive with backupRef | |
source.s3.path | Key prefix that holds manifest.json | |
source.s3.region, endpoint, forcePathStyle, credentialsSecretRef | as on QdrantBackup | Credentials from the restore's namespace |
targetClusterRef.name | required | Cluster to restore into; node count must equal the manifest's |
targetClusterRef.namespace | own namespace | |
collections[] | all in the manifest | Subset to restore |
collectionMapping | {} | Source name to target name |
priority | snapshot | snapshot, replica or no_sync |
waitForIndexing | true | Poll until the collection is green, up to one hour |
Admission rules: exactly one of backupRef and source; the spec cannot
change.
Status
| Field | Meaning |
|---|---|
phase | Pending, Downloading, Restoring, Completed or Failed |
startTime, completionTime | |
sourceBackup | The backup name, or the s3:// URI of the source |
restoredCollections[] | Per collection: name, originalName when remapped, status, size, pointsCount, error |
progress | collectionsTotal, collectionsCompleted, currentCollection, percentage |
error | The first failed collection's error |
conditions[Complete] | RestoreCompleted or RestoreFailed, message Restored n/m |
Behavior
On create only. Resolves the source, retrying every thirty seconds while
the backup is not Completed or a Secret is missing, reads the manifest,
fails when the node counts differ, then for each selected collection hands
every target node a presigned URL, valid one hour, for the snapshot of the
source node with the same ordinal via
PUT /collections/<target>/snapshots/recover?wait=true, and waits for
green when asked to. Completed, or Failed when any collection failed.
Print columns: SOURCE, TARGET, PHASE, AGE.
QdrantMigration
qdrantmigrations.qdrant.io, short name qmig, namespaced. A point-by-point
copy of collections from any Qdrant into a managed cluster. The spec is
immutable.
Spec
| Field | Default | Description |
|---|---|---|
source.clusterRef.name | A QdrantCluster. Exclusive with endpoint | |
source.clusterRef.namespace | own namespace | |
source.endpoint.url | http:// or https:// URL of any Qdrant. Exclusive with clusterRef | |
source.endpoint.apiKeySecretRef.name, .key | none | API key for the source, Secret in the migration's namespace |
source.endpoint.caSecretRef.name, .key | system trust | PEM bundle for a private CA |
targetClusterRef.name | required | The managed cluster to copy into |
targetClusterRef.namespace | own namespace | |
collections[] | all on the source | Collections to copy |
collectionMapping | {} | Source name to target name |
batchSize | 256 | Points per scroll and upsert, 1 to 10000 |
createMissing | true | Create absent target collections from the source configuration |
target.shardNumber, replicationFactor, writeConsistencyFactor | target defaults | Applied to collections the migration creates |
Admission rules: exactly one of source.clusterRef and source.endpoint;
the spec cannot change.
Status
| Field | Meaning |
|---|---|
phase | Pending, Running, Completed or Failed |
startTime, completionTime | |
source | <namespace>/<name> or the endpoint URL |
collections[] | name, targetName, status, pointsTotal, pointsCopied, error |
progress | collectionsTotal, collectionsCompleted, pointsTotal, pointsCopied, percentage |
error | The first failed collection's error |
conditions[Complete] | MigrationCompleted or MigrationFailed |
Behavior
On create only. Resolves both sides, retrying every thirty seconds while
the target cluster is missing or not ready or a Secret is absent, counts the
points of each selected collection, then per collection creates the target
collection and its payload indexes when missing and allowed, scrolls pages
with payload and vectors, and upserts them grouped by shard key with
wait=true. Progress is patched every twenty batches. Completed, or
Failed when any collection failed.
Print columns: SOURCE, TARGET, PHASE, PROGRESS, AGE.
Helm values the operator renders
Each QdrantCluster becomes one helm upgrade --install of the qdrant
chart from https://qdrant.github.io/qdrant-helm, at chart version
spec.version without its leading v. The values below are passed on
stdin; everything the spec does not cover keeps the chart's default for that
version.
| Spec field | Chart value |
|---|---|
replicas | replicaCount |
image.repository, image.pullPolicy | image.repository, image.pullPolicy. No image.tag, so the chart's appVersion applies |
resources | resources |
persistence.size, accessModes, storageClassName | persistence.size, persistence.accessModes, persistence.storageClassName |
snapshotPersistence | snapshotPersistence.enabled, .size, .storageClassName |
service.type, service.annotations | service.type, service.annotations |
apiKey.autoGenerate | apiKey: true or false |
apiKey.secretRef | apiKey: {valueFrom: {secretKeyRef: {name, key}}} |
readOnlyApiKey | readOnlyApiKey, same encoding |
apiKey.jwtRbac | config.service.jwt_rbac |
metrics | metrics.serviceMonitor.enabled (true only when both metrics.enabled and serviceMonitor.enabled), .scrapeInterval, .additionalLabels |
nodeSelector, tolerations, affinity | Same names |
cluster.enabled | config.cluster.enabled |
cluster.p2p.port, cluster.p2p.enableTls | config.cluster.p2p.port, config.cluster.p2p.enable_tls |
tls.enabled | config.service.enable_tls, and config.tls.cert / config.tls.key set to /qdrant/tls/tls.crt and /qdrant/tls/tls.key |
tls.secretRef.name | additionalVolumes: [{name: tls, secret: {secretName}}] and additionalVolumeMounts: [{name: tls, mountPath: /qdrant/tls, readOnly: true}] |
config | Deep-merged over the config entries above; the spec wins on conflicts |
Names the chart produces
For a QdrantCluster named my-qdrant in namespace prod:
| Object | Name |
|---|---|
| Helm release | qdrant-my-qdrant |
| StatefulSet | qdrant-my-qdrant |
| Pods | qdrant-my-qdrant-0, qdrant-my-qdrant-1, … |
| Client Service | qdrant-my-qdrant.prod.svc.cluster.local, ports 6333 HTTP, 6334 gRPC, 6335 p2p |
| Headless Service | qdrant-my-qdrant-headless.prod.svc.cluster.local |
| Pod DNS | qdrant-my-qdrant-<i>.qdrant-my-qdrant-headless.prod.svc.cluster.local |
| Generated API key Secret | qdrant-my-qdrant-apikey, key api-key |
| PersistentVolumeClaims | Created by the StatefulSet from the chart's claim template |
The operator relies on these names when it talks to Qdrant: the client
Service for listing collections and reconciling QdrantCollection
resources, the pod names for snapshots and recovery.
What the operator does not expose
Chart values without a spec field, among them ingress, podDisruptionBudget,
sidecarContainers, additionalVolumes beyond the TLS mount, env,
livenessProbe and friends, updateStrategy, serviceAccount and
podSecurityContext, keep their chart defaults. config reaches Qdrant's
configuration file only, not the chart's other values. If you need one of
them, open an issue with the use case.
Bucket layout
A QdrantBackup named nightly-20260924-020000 with prefix: prod/my-qdrant on a three-node cluster with two collections produces:
prod/my-qdrant/nightly-20260924-020000/
├── manifest.json
├── documents/
│ ├── node-0/documents-2026-09-24-02-00-03.snapshot
│ ├── node-1/documents-2026-09-24-02-00-09.snapshot
│ └── node-2/documents-2026-09-24-02-00-14.snapshot
└── images/
├── node-0/images-2026-09-24-02-00-20.snapshot
├── node-1/images-2026-09-24-02-00-31.snapshot
└── node-2/images-2026-09-24-02-00-40.snapshot
Snapshot file names are whatever Qdrant chose; the manifest is what a restore reads.
manifest.json
{
"backupName": "nightly-20260924-020000",
"cluster": {"name": "my-qdrant", "namespace": "prod"},
"nodeCount": 3,
"createdAt": "2026-09-24T02:00:01Z",
"collections": [
{
"name": "documents",
"snapshots": [
{
"nodeIndex": 0,
"key": "prod/my-qdrant/nightly-20260924-020000/documents/node-0/documents-2026-09-24-02-00-03.snapshot",
"snapshotName": "documents-2026-09-24-02-00-03.snapshot",
"sizeBytes": 734003200,
"checksum": "3f2a…"
}
]
}
]
}
| Field | Meaning |
|---|---|
backupName | The QdrantBackup name |
cluster | The source QdrantCluster |
nodeCount | Replicas of the source at backup time; a restore target must match |
createdAt | When the backup started |
collections[] | Completed collections only; a failed collection is absent |
snapshots[].nodeIndex | StatefulSet ordinal the snapshot came from, and the ordinal it is restored to |
snapshots[].key | Full object key inside the bucket |
snapshots[].sizeBytes | Bytes uploaded |
snapshots[].checksum | Qdrant's snapshot checksum, passed to recovery when present |
Lifecycle
Deleting the QdrantBackup deletes every object under the backup's root,
in batches of a thousand keys. Nothing else in the bucket is touched, so
several clusters and schedules can share a bucket under different prefixes.
A QdrantRestore with source.s3.path set to the root key works on any
manifest, including one whose QdrantBackup no longer exists or was taken in
another Kubernetes cluster.
Operator configuration
Chart values
| Value | Default | Description |
|---|---|---|
replicaCount | 1 | Keep at one; the Deployment uses Recreate |
image.repository | ghcr.io/4thel00z/qdrant-operator | |
image.tag | chart appVersion | |
image.pullPolicy | IfNotPresent | |
serviceAccount.create | true | |
serviceAccount.name | release full name | |
resources | 100m/128Mi requests, 500m/256Mi limits | Snapshot streaming is I/O bound and buffers one megabyte at a time |
operator.logLevel | INFO | loguru level: DEBUG, INFO, WARNING, ERROR |
operator.logFormat | json | json or text |
Environment variables
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL | INFO | |
LOG_FORMAT | json | json writes one JSON object per line; text is for a terminal |
KUBECONFIG | in-cluster | Used when no service account token is mounted |
HELM_CACHE_HOME, HELM_CONFIG_HOME, HELM_DATA_HOME | /tmp/helm/* in the chart | Where the bundled helm keeps its state on the read-only root filesystem |
Command line
The qdrant-operator entry point runs
kopf run -m qdrant_operator.handlers --all-namespaces --liveness=http://0.0.0.0:8080/healthz
and appends any arguments you pass, which override the defaults. Useful ones:
--namespace <ns> to watch one namespace, --verbose for kopf's own debug
output, --standalone to skip peering, --dev for a short peering priority
when running next to an in-cluster operator.
RBAC
The chart's ClusterRole grants:
| Resources | Verbs | Why |
|---|---|---|
qdrant.io kinds | all | Watch and update the seven custom resources; the schedule creates and deletes backups |
qdrant.io */status | get, patch, update | Status subresources |
customresourcedefinitions | list, watch | kopf checks the CRDs it serves |
clusterkopfpeerings.kopf.dev | all | kopf peering between operator instances |
events | create | kopf posts events on handled resources |
secrets | get, list, watch, create, update, patch, delete | API keys, TLS CA and bucket credentials; access key Secrets are written; the chart renders Secrets |
services, namespaces, configmaps, serviceaccounts, statefulsets, poddisruptionbudgets, ingresses, servicemonitors | all | Everything the qdrant chart may render |
Helm runs inside the operator pod with the pod's service account, so the operator needs every permission the chart's objects need.
Timers and retries
| What | Interval |
|---|---|
QdrantCluster readiness check | 30 s, first after 10 s |
QdrantCollection reconcile | 60 s |
QdrantAccessKey renewal check | 60 s |
QdrantBackupSchedule tick | 60 s |
QdrantBackup expiry check | 300 s, only with expiresAt set |
| Retry after a missing cluster, Secret or not-yet-completed backup | 30 s |
helm command timeout | 600 s |
| Qdrant snapshot, recovery, collection, index, scroll and upsert calls | 3600 s |
| Other Qdrant calls | 30 s |
| Presigned URL lifetime | 3600 s |
Wait for a restored collection to turn green | poll 5 s, give up after 3600 s |
Annotations and finalizer
kopf keeps its handler progress in annotations prefixed qdrant.io/ and the
last applied spec under qdrant.io/last-applied-spec. The finalizer on every
resource is qdrant.io/finalizer. Removing the finalizer by hand skips the
delete handler: the Helm release, the bucket objects or the Qdrant
collection then stay. Access key Secrets are removed by owner reference
either way.
Development
The operator is Python 3.12 on kopf, managed
with uv. The package is qdrant_operator.
Layout
| Path | Contents |
|---|---|
src/qdrant_operator/domain.py | Frozen dataclasses for every spec and status, from_dict/to_dict, Helm value derivation, cron and retention rules. No I/O, no framework imports |
src/qdrant_operator/ports.py | typing.Protocol interfaces: HelmPort, QdrantPort, StoragePort, KubernetesPort, TokenPort |
src/qdrant_operator/usecases.py | One dataclass per operation: ReconcileCluster, ObserveCluster, DeleteCluster, ExecuteBackup, DeleteBackupData, ExpireBackup, ExecuteRestore, ProcessSchedule, ReconcileCollection, DeleteCollection, IssueAccessKey, ExecuteMigration |
src/qdrant_operator/handlers.py | kopf handlers: event to dataclasses to use case to patch.status |
src/qdrant_operator/*_adapter.py | Helm CLI, Qdrant REST over httpx, S3 over aioboto3, Kubernetes over kubernetes-asyncio, JWT signing over PyJWT |
src/qdrant_operator/container.py | Wires adapters into use cases |
src/qdrant_operator/main.py | Entry point, loguru setup, kopf run |
manifests/crds/ | The CRDs; charts/qdrant-operator/crds/ must be an identical copy |
charts/qdrant-operator/ | The operator's own chart |
tests/ | Unit tests with in-memory fakes, CRD sync check, integration suite |
docs/ | This book |
Rules the code follows: handlers call use cases only, use cases see ports
only, adapters implement ports structurally. Status goes through
patch.status.update. Transient problems raise kopf.TemporaryError,
everything else writes Failed and raises kopf.PermanentError. Adapters
keep no connection state and build a client per call. No temp files:
snapshots stream node to bucket and bucket to node, Helm values go over
stdin.
Make targets
make dev # run against the current kubeconfig, text logs
make lint # ruff check, ruff format --check, pyright --strict, helm lint
make format
make test # unit tests
make test-integration # QDRANT_OPERATOR_INTEGRATION=1, needs a cluster and helm
make crds-sync # copy manifests/crds into the chart
make build # docker image
Tests
tests/fakes.py holds FakeHelm, FakeQdrant, FakeStorage and
FakeKubernetes, in-memory implementations of the ports that record calls;
there is no mock.patch. test_domain.py and test_usecases.py run
against those in well under a second and cover value derivation, cron and
retention arithmetic, the backup and restore flows, schedule concurrency,
collection reconciliation, token claims and renewal, and the migration copy
loop. test_crds.py fails when the chart's CRDs differ
from manifests/crds.
test_integration.py runs against a real cluster with helm on the path and
the CRDs applied: it installs a one-node cluster through ReconcileCluster,
waits for Running, port-forwards and queries Qdrant, uninstalls, and checks
that the API server enforces the admission rules. CI runs it on a kind
cluster on every push and pull request.
Releasing
Releases are cut by release-please from the conventional commit history. A
release PR keeps pyproject.toml, Chart.yaml, uv.lock and
CHANGELOG.md current; merging it tags vX.Y.Z, publishes the wheel and
sdist to PyPI, pushes the multi-arch image and the Helm chart to
ghcr.io/4thel00z, and attaches the CRDs to the GitHub release.
Documentation
mdbook serve docs --open
The book publishes to GitHub Pages from the book workflow on every push to
main.