Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

KindShort nameWhat it manages
QdrantClusterqcOne Helm release of the qdrant chart: replicas, version, storage, service, API keys, TLS, metrics, scheduling
QdrantCollectionqcolA collection inside a cluster: vectors, sharding, payload indexes, aliases
QdrantAccessKeyqakA role-based access token signed with the cluster's API key, kept in a Secret and renewed before expiry
QdrantBackupqbOne backup: a snapshot of every node per collection, streamed into a bucket with a manifest
QdrantBackupScheduleqbsBackups on a cron schedule with CronJob-style concurrency and grandfather-father-son retention
QdrantRestoreqrRecovery of a backup into a cluster with the same node count, node by node
QdrantMigrationqmigA 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

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

PhaseMeaning
PendingFirst Helm install applied, StatefulSet not yet fully ready
UpgradingSpec changed on an existing release, rollout in progress
RunningreadyReplicas 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

SecretNamespaceUsed for
apiKey.secretRef / qdrant-<name>-apikeythe cluster'sHeader on every operator call to Qdrant
tls.secretRefthe cluster'sServed by Qdrant; ca.crt verifies the operator's calls
storage.s3.credentialsSecretRefthe backup's, schedule's or restore's ownBucket 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.

FieldValuesMeaning
accessrRead everything on the cluster
mManage everything, the same as the API key
collections[].accessrRead this collection
rwRead and write points, manage the collection
prwRead 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

FieldMeaning
phasePending while blocked, Ready with a token in the Secret, Failed on an error
secretRefName and key of the token
issuedAt, expiresAt, renewAtThe current token's timeline
keyFingerprintPrefix 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

FieldOn change
vectors[].size, distance, datatype, multivectorImmutable. The resource turns Degraded
shardNumber, shardingMethodImmutable. The resource turns Degraded
vectors[].onDisk, hnsw, quantizationPatched per vector
sparseVectorsPatched
replicationFactor, writeConsistencyFactor, readFanOutFactor, onDiskPayloadPatched
hnsw, optimizers, quantization, strictModePatched
walApplied at creation, never compared afterwards
payloadIndexes, aliasesReconciled, 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 endpoint and forcePathStyle: true.
  • A Secret in the backup's namespace with the access key pair. The default keys are AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY; other key names go in credentialsSecretRef.accessKeyIdKey and secretAccessKeyKey.
  • 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. snapshotPersistence on 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

  1. 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.
  2. The phase moves to InProgress with startTime.
  3. For each collection, for each node in ordinal order: POST /collections/<c>/snapshots?wait=true on that node, then GET the snapshot as a stream into a multipart upload at <prefix>/<backup>/<collection>/node-<i>/<snapshot>, then DELETE the snapshot on the node. The node-local snapshot is deleted even when the upload fails.
  4. manifest.json is written at <prefix>/<backup>/ listing the completed collections, their snapshots, sizes and checksums, and the node count.
  5. The phase becomes Completed, or Failed when any collection failed. status.collections carries each collection's outcome and status.error the 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

PolicyWhen 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
AllowCreate the new backup alongside
ReplaceDelete 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

FieldMeaning
lastScheduleTimeThe cron slot the most recent backup was created for
lastBackupName, lastBackupTime, lastBackupStatusThe most recent finished backup, its completion time and phase
nextBackupTimeThe next slot after now
activeBackupA backup that is Pending or InProgress
recentBackupsThe 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

  1. The phase moves to Downloading while the manifest is read.
  2. collections selects a subset; a name missing from the manifest fails the restore. Without collections, every collection in the manifest is restored.
  3. For each collection, phase Restoring with progress updated first, then for each snapshot in the manifest: a presigned GET URL valid for one hour, and PUT /collections/<target>/snapshots/recover?wait=true on the node with the same ordinal, passing the URL, priority and the checksum.
  4. With waitForIndexing, the collection is polled every five seconds until its status is green, for up to one hour. Without it, the restore records the point count and moves on.
  5. Completed, or Failed when any collection failed. status.restoredCollections lists 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

priorityMeaning
snapshot (default)The snapshot wins; existing data for the collection on that node is replaced
replicaExisting replicas win; the snapshot fills in what is missing
no_syncLoad 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

  1. The source and target are resolved; the target must answer /readyz or the migration is retried every thirty seconds.
  2. collections selects the collections, or every collection the source lists. Each one's point count is read, approximately, and the phase moves to Running with a per-collection entry in the status.
  3. For each collection, when the target collection does not exist and createMissing is 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, unless target overrides them.
  4. Points are scrolled from the source in pages of batchSize with payload and vectors, and upserted on the target with wait=true, grouped by shard key. Progress is written every twenty batches.
  5. Completed, or Failed when any collection failed. status.collections carries 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

QdrantRestoreQdrantMigration
SourceA backup in a bucketA live Qdrant
Node countMust match the backupAny
ShardingKept as at backup timeTarget decides, or target overrides
SpeedSnapshot files, fastPoint by point over HTTP
ConsistencyAs of the snapshotLive, see above

Status

FieldMeaning
phasePending, Running, Completed or Failed
source<namespace>/<name> of the source cluster, or the endpoint URL
collections[]Per collection: name, targetName, status, pointsTotal, pointsCopied, error
progresscollectionsTotal, 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

SymptomMeaningWhat to do
Pending and stays thereThe Helm release is applied but pods are not readykubectl get pods -l app.kubernetes.io/instance=qdrant-<name>, then describe the pod that is not ready
No phase and no podsThe first helm upgrade --install failed before any status was written; the operator retriesLook 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 PendingNo node fits or no volume can be boundkubectl describe pod: check resources, nodeSelector, affinity and the storage class
Pods CrashLoopBackOffQdrant rejects its configurationContainer logs; a typo under config lands in production.yaml verbatim
Upgrading for a long timeA rolling update is stuck on one podThe 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

ReasonMeaning
CollectionReadyThe collection, its indexes and aliases match the spec
ImmutableFieldMismatchvectors, shardNumber or shardingMethod differ from the live collection; phase Degraded, nothing changed
retried: cluster not foundclusterRef names a QdrantCluster that does not exist yet
retried: cluster not ready/readyz on the Service does not answer 200
Failed with an HTTP errorQdrant 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

ReasonMeaning
TokenIssuedThe Secret holds a current token
JwtRbacDisabledThe cluster's apiKey.jwtRbac is false; phase Pending until it is turned on
ApiKeyMissingThe cluster has neither apiKey.secretRef nor autoGenerate; nothing to sign with
retried: cluster or Secret not foundclusterRef 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

MessageMeaning
QdrantCluster ... not readyThe target does not answer /readyz; retried
Collections not present in sourcecollections names something the source does not list
Collection ... missing on target and createMissing is falseCreate the target collection first or allow creation
4xx from the source on points/scrollWrong or missing apiKeySecretRef, or a token without read access
TLS verification failedA source behind a private CA needs caSecretRef
Failed on upsert with a dimension errorThe 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

PhaseMeaning
PendingWaiting for the cluster or the credentials Secret; retried every thirty seconds
InProgressSnapshots are being taken and uploaded
Completedmanifest.json written; s3Path and totalSize set
Failedstatus.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 a replicas value higher than the pods that exist, produces this.
  • TLS certificate verification failed. With tls.enabled, the operator needs ca.crt in 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 snapshotPersistence or grow the data volume.
  • Access denied from the bucket. Check the credentials Secret, the bucket policy, and for MinIO endpoint plus forcePathStyle: true.

Deleting a Failed backup still removes what was uploaded.

QdrantBackupSchedule

SymptomMeaning
No backup at the expected timeThe slot is computed in UTC; check nextBackupTime. With Forbid, a still running backup blocks the slot, see activeBackup
One backup right after the operator restartedA catch-up for the most recent missed slot. Set startingDeadlineSeconds to skip stale slots
Old backups disappearingretentionPolicy pruned them, together with their objects in the bucket
Backups gone after deleting the scheduleThey were owned by the schedule and garbage-collected

QdrantRestore

MessageMeaning
QdrantBackup ... is InProgress, not CompletedRetried until the backup finishes
Backup was taken from N nodes but target cluster has MNode counts must match; create a target with N replicas
Collections not present in backupcollections names something the manifest does not have
Collection ... did not turn green in timeIndexing took more than one hour; the data is there, only the wait timed out
A node returns 4xx on snapshots/recoverThe 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

FieldDefaultDescription
versionrequiredQdrant version and chart version, v1.16.3 or 1.16.3
replicas1StatefulSet replicas. More than one requires cluster.enabled
image.repositorydocker.io/qdrant/qdrantThe tag always follows version
image.pullPolicyIfNotPresentAlways, IfNotPresent or Never
resources.requests, resources.limits{}cpu and memory
persistence.size10GiImmutable
persistence.storageClassNamecluster defaultImmutable
persistence.accessModes[ReadWriteOnce]
snapshotPersistence.enabledfalseSeparate volume for snapshots. Fixed at creation; a change fails the Helm upgrade
snapshotPersistence.size10Gi
snapshotPersistence.storageClassNamecluster default
cluster.enabledtrueDistributed mode
cluster.p2p.port6335
cluster.p2p.enableTlsfalseTLS on the inter-node port, reusing tls.secretRef
service.typeClusterIPClusterIP, NodePort or LoadBalancer
service.annotations{}
apiKey.secretRef.name, apiKey.secretRef.keyBring-your-own full-access key. Exclusive with autoGenerate
apiKey.autoGeneratefalseChart generates qdrant-<name>-apikey with key api-key
apiKey.jwtRbacfalseQdrant's service.jwt_rbac; needed by QdrantAccessKey. Requires secretRef or autoGenerate
readOnlyApiKeySame fields as apiKey; never used by the operator
tls.enabledfalseRequires tls.secretRef.name
tls.secretRef.namekubernetes.io/tls Secret; ca.crt in it verifies the operator's own calls
metrics.enabledfalseTogether with serviceMonitor.enabled renders a ServiceMonitor
metrics.serviceMonitor.enabledfalse
metrics.serviceMonitor.interval30sScrape interval
metrics.serviceMonitor.labels{}Added to the ServiceMonitor
nodeSelector, tolerations, affinityPod 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

FieldMeaning
phasePending, Upgrading or Running
replicas, readyReplicasDesired count and the StatefulSet's ready count
versionspec.version as last applied
helmReleaseqdrant-<name>
endpointhttp(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
observedGenerationSpec 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

FieldDefaultDescription
clusterRef.namerequiredThe QdrantCluster. Immutable
clusterRef.namespaceown namespace
collectionNamemetadata.nameName inside Qdrant. Immutable
vectors[]Dense vector spaces, up to 64. One unnamed entry is the single unnamed vector
vectors[].nameRequired when there is more than one entry
vectors[].sizerequiredDimension. Immutable
vectors[].distancerequiredCosine, Euclid, Dot or Manhattan. Immutable
vectors[].datatypeQdrant defaultfloat32, float16 or uint8. Immutable
vectors[].multivector.comparatormax_sim. Immutable
vectors[].onDiskQdrant defaultMutable
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[].modifiernone or idf
sparseVectors[].index{}SparseIndexParams, passed through
shardNumberQdrant defaultImmutable
shardingMethodautoauto or custom. Immutable
replicationFactor, writeConsistencyFactor, readFanOutFactorQdrant defaultMutable
onDiskPayloadQdrant defaultMutable
hnsw{}Collection-wide HnswConfigDiff
optimizers{}OptimizersConfigDiff
quantization{}Collection-wide QuantizationConfig
wal{}WalConfigDiff, creation only
strictMode{}StrictModeConfig
payloadIndexes[]Up to 256, keyed by field
payloadIndexes[].fieldrequiredPayload key to index
payloadIndexes[].typerequiredkeyword, 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
deletionPolicyRetainRetain 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

FieldMeaning
phasePending, Ready, Degraded or Failed
collectionNameThe name inside Qdrant
healthQdrant's collection status: green, yellow, grey or red
pointsCount, indexedVectorsCount, segmentsCountFrom the collection info
payloadIndexesFields whose indexes this resource manages
aliasesAliases this resource manages
errorThe mismatch or failure message
observedGenerationSpec 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

FieldDefaultDescription
clusterRef.namerequiredA QdrantCluster with apiKey.jwtRbac: true. Immutable
clusterRef.namespaceown namespace
accessr or m, cluster-wide. Exclusive with collections
collections[]Up to 256, keyed by name. Exclusive with access
collections[].namerequiredCollection inside Qdrant
collections[].accessrequiredr, rw or prw
subjectunsetThe sub claim
ttlnever expiresLifetime such as 720h or 1h30m
renewBeforea third of ttlHow long before expiry a new token is written. Needs ttl
valueExists.collectionToken valid only while a matching point exists here
valueExists.matches[]One to 32 {key, value} pairs the point must carry
secretNamemetadata.nameSecret receiving the token. Immutable

Admission rules: exactly one of access and collections; renewBefore needs ttl; clusterRef and secretName cannot change.

Status

FieldMeaning
phasePending, Ready or Failed
secretRef.name, secretRef.keyWhere the token is; the key is token
issuedAt, expiresAt, renewAtTimeline of the current token
keyFingerprintFirst sixteen hex digits of the SHA-256 of the signing key
errorWhy the phase is not Ready
observedGenerationSpec 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

FieldDefaultDescription
clusterRef.namerequiredThe QdrantCluster
clusterRef.namespaceown namespace
storage.s3.bucketrequired
storage.s3.prefix""Key prefix; the backup lives under <prefix>/<name>/
storage.s3.regionus-east-1
storage.s3.endpointAWSCustom endpoint for MinIO and other S3-compatible stores
storage.s3.forcePathStylefalsePath-style URLs, required for MinIO
storage.s3.credentialsSecretRef.namerequiredSecret in the backup's namespace
storage.s3.credentialsSecretRef.accessKeyIdKeyAWS_ACCESS_KEY_ID
storage.s3.credentialsSecretRef.secretAccessKeyKeyAWS_SECRET_ACCESS_KEY
collections[]allCollections to back up
retentionDaysunsetDays after completion until the resource deletes itself

Status

FieldMeaning
phasePending, InProgress, Completed or Failed
startTime, completionTime
expiresAtcompletionTime plus retentionDays
s3Paths3://<bucket>/<prefix>/<name>
totalSizeSum of the completed snapshots, human-readable
collections[]Per collection: name, status, size, snapshots[] with node, snapshotName and key, and error
errorThe 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

FieldDefaultDescription
schedulerequiredFive-field cron expression, UTC
clusterRef, storage, collectionsas on QdrantBackupCopied into each backup
suspendfalseStop creating backups; retention continues
concurrencyPolicyForbidAllow, Forbid or Replace
startingDeadlineSecondsunsetSkip a missed slot older than this
retentionPolicy.keepLastunsetKeep the N newest finished backups
retentionPolicy.keepDailyunsetKeep the newest per day for N days
retentionPolicy.keepWeeklyunsetKeep the newest per ISO week for N weeks
retentionPolicy.keepMonthlyunsetKeep the newest per month for N months

Status

FieldMeaning
phaseActive or Suspended
lastScheduleTimeSlot the most recent backup was created for
lastBackupName, lastBackupTime, lastBackupStatusNewest finished backup, its completion time and phase
nextBackupTimeNext slot after now
activeBackupName 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

FieldDefaultDescription
backupRef.nameA Completed QdrantBackup. Exclusive with source
backupRef.namespaceown namespace
source.s3.bucketDirect bucket source. Exclusive with backupRef
source.s3.pathKey prefix that holds manifest.json
source.s3.region, endpoint, forcePathStyle, credentialsSecretRefas on QdrantBackupCredentials from the restore's namespace
targetClusterRef.namerequiredCluster to restore into; node count must equal the manifest's
targetClusterRef.namespaceown namespace
collections[]all in the manifestSubset to restore
collectionMapping{}Source name to target name
prioritysnapshotsnapshot, replica or no_sync
waitForIndexingtruePoll until the collection is green, up to one hour

Admission rules: exactly one of backupRef and source; the spec cannot change.

Status

FieldMeaning
phasePending, Downloading, Restoring, Completed or Failed
startTime, completionTime
sourceBackupThe backup name, or the s3:// URI of the source
restoredCollections[]Per collection: name, originalName when remapped, status, size, pointsCount, error
progresscollectionsTotal, collectionsCompleted, currentCollection, percentage
errorThe 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

FieldDefaultDescription
source.clusterRef.nameA QdrantCluster. Exclusive with endpoint
source.clusterRef.namespaceown namespace
source.endpoint.urlhttp:// or https:// URL of any Qdrant. Exclusive with clusterRef
source.endpoint.apiKeySecretRef.name, .keynoneAPI key for the source, Secret in the migration's namespace
source.endpoint.caSecretRef.name, .keysystem trustPEM bundle for a private CA
targetClusterRef.namerequiredThe managed cluster to copy into
targetClusterRef.namespaceown namespace
collections[]all on the sourceCollections to copy
collectionMapping{}Source name to target name
batchSize256Points per scroll and upsert, 1 to 10000
createMissingtrueCreate absent target collections from the source configuration
target.shardNumber, replicationFactor, writeConsistencyFactortarget defaultsApplied to collections the migration creates

Admission rules: exactly one of source.clusterRef and source.endpoint; the spec cannot change.

Status

FieldMeaning
phasePending, Running, Completed or Failed
startTime, completionTime
source<namespace>/<name> or the endpoint URL
collections[]name, targetName, status, pointsTotal, pointsCopied, error
progresscollectionsTotal, collectionsCompleted, pointsTotal, pointsCopied, percentage
errorThe 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 fieldChart value
replicasreplicaCount
image.repository, image.pullPolicyimage.repository, image.pullPolicy. No image.tag, so the chart's appVersion applies
resourcesresources
persistence.size, accessModes, storageClassNamepersistence.size, persistence.accessModes, persistence.storageClassName
snapshotPersistencesnapshotPersistence.enabled, .size, .storageClassName
service.type, service.annotationsservice.type, service.annotations
apiKey.autoGenerateapiKey: true or false
apiKey.secretRefapiKey: {valueFrom: {secretKeyRef: {name, key}}}
readOnlyApiKeyreadOnlyApiKey, same encoding
apiKey.jwtRbacconfig.service.jwt_rbac
metricsmetrics.serviceMonitor.enabled (true only when both metrics.enabled and serviceMonitor.enabled), .scrapeInterval, .additionalLabels
nodeSelector, tolerations, affinitySame names
cluster.enabledconfig.cluster.enabled
cluster.p2p.port, cluster.p2p.enableTlsconfig.cluster.p2p.port, config.cluster.p2p.enable_tls
tls.enabledconfig.service.enable_tls, and config.tls.cert / config.tls.key set to /qdrant/tls/tls.crt and /qdrant/tls/tls.key
tls.secretRef.nameadditionalVolumes: [{name: tls, secret: {secretName}}] and additionalVolumeMounts: [{name: tls, mountPath: /qdrant/tls, readOnly: true}]
configDeep-merged over the config entries above; the spec wins on conflicts

Names the chart produces

For a QdrantCluster named my-qdrant in namespace prod:

ObjectName
Helm releaseqdrant-my-qdrant
StatefulSetqdrant-my-qdrant
Podsqdrant-my-qdrant-0, qdrant-my-qdrant-1, …
Client Serviceqdrant-my-qdrant.prod.svc.cluster.local, ports 6333 HTTP, 6334 gRPC, 6335 p2p
Headless Serviceqdrant-my-qdrant-headless.prod.svc.cluster.local
Pod DNSqdrant-my-qdrant-<i>.qdrant-my-qdrant-headless.prod.svc.cluster.local
Generated API key Secretqdrant-my-qdrant-apikey, key api-key
PersistentVolumeClaimsCreated 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…"
        }
      ]
    }
  ]
}
FieldMeaning
backupNameThe QdrantBackup name
clusterThe source QdrantCluster
nodeCountReplicas of the source at backup time; a restore target must match
createdAtWhen the backup started
collections[]Completed collections only; a failed collection is absent
snapshots[].nodeIndexStatefulSet ordinal the snapshot came from, and the ordinal it is restored to
snapshots[].keyFull object key inside the bucket
snapshots[].sizeBytesBytes uploaded
snapshots[].checksumQdrant'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

ValueDefaultDescription
replicaCount1Keep at one; the Deployment uses Recreate
image.repositoryghcr.io/4thel00z/qdrant-operator
image.tagchart appVersion
image.pullPolicyIfNotPresent
serviceAccount.createtrue
serviceAccount.namerelease full name
resources100m/128Mi requests, 500m/256Mi limitsSnapshot streaming is I/O bound and buffers one megabyte at a time
operator.logLevelINFOloguru level: DEBUG, INFO, WARNING, ERROR
operator.logFormatjsonjson or text

Environment variables

VariableDefaultDescription
LOG_LEVELINFO
LOG_FORMATjsonjson writes one JSON object per line; text is for a terminal
KUBECONFIGin-clusterUsed when no service account token is mounted
HELM_CACHE_HOME, HELM_CONFIG_HOME, HELM_DATA_HOME/tmp/helm/* in the chartWhere 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:

ResourcesVerbsWhy
qdrant.io kindsallWatch and update the seven custom resources; the schedule creates and deletes backups
qdrant.io */statusget, patch, updateStatus subresources
customresourcedefinitionslist, watchkopf checks the CRDs it serves
clusterkopfpeerings.kopf.devallkopf peering between operator instances
eventscreatekopf posts events on handled resources
secretsget, list, watch, create, update, patch, deleteAPI keys, TLS CA and bucket credentials; access key Secrets are written; the chart renders Secrets
services, namespaces, configmaps, serviceaccounts, statefulsets, poddisruptionbudgets, ingresses, servicemonitorsallEverything 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

WhatInterval
QdrantCluster readiness check30 s, first after 10 s
QdrantCollection reconcile60 s
QdrantAccessKey renewal check60 s
QdrantBackupSchedule tick60 s
QdrantBackup expiry check300 s, only with expiresAt set
Retry after a missing cluster, Secret or not-yet-completed backup30 s
helm command timeout600 s
Qdrant snapshot, recovery, collection, index, scroll and upsert calls3600 s
Other Qdrant calls30 s
Presigned URL lifetime3600 s
Wait for a restored collection to turn greenpoll 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

PathContents
src/qdrant_operator/domain.pyFrozen 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.pytyping.Protocol interfaces: HelmPort, QdrantPort, StoragePort, KubernetesPort, TokenPort
src/qdrant_operator/usecases.pyOne dataclass per operation: ReconcileCluster, ObserveCluster, DeleteCluster, ExecuteBackup, DeleteBackupData, ExpireBackup, ExecuteRestore, ProcessSchedule, ReconcileCollection, DeleteCollection, IssueAccessKey, ExecuteMigration
src/qdrant_operator/handlers.pykopf handlers: event to dataclasses to use case to patch.status
src/qdrant_operator/*_adapter.pyHelm CLI, Qdrant REST over httpx, S3 over aioboto3, Kubernetes over kubernetes-asyncio, JWT signing over PyJWT
src/qdrant_operator/container.pyWires adapters into use cases
src/qdrant_operator/main.pyEntry 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.