DEV Community

Cover image for Backing Up Qdrant Snapshots Correctly: From emptyDir to NFS Persistent Backups
Guatu
Guatu

Posted on • Originally published at guatulabs.dev

Backing Up Qdrant Snapshots Correctly: From emptyDir to NFS Persistent Backups

The Qdrant snapshot API returns 200 OK with a JSON body containing a filename, a size, and a creation timestamp. That response confirms a tar archive got written somewhere inside the container. It says nothing about whether that somewhere survives a pod restart, and with the manifest most people start from, it doesn't.

That's the whole problem in one sentence. The API is honest about what it did. It has no opinion about where your storage lives, and neither does your monitoring, which is why a broken Qdrant backup can report success for a very long time before anyone finds out.

Who should care

If you're running Qdrant in Kubernetes as the vector store behind a RAG pipeline, an agent memory layer, or a semantic search index, the embeddings in it are expensive. Not expensive to store, expensive to regenerate. Re-embedding a few hundred thousand chunks means re-chunking the source documents, re-running the embedding model, and hoping the model version you used originally is still pinned somewhere. A restore from a 20 MB snapshot file takes ninety seconds. A rebuild takes an afternoon and a stack of GPU hours.

This post walks the progression from the naive setup to one that actually holds: emptyDir → PersistentVolumeClaim → external NFS target, plus the retention logic that keeps the whole thing from eating your cluster.

Stage 1: the emptyDir trap

Here's the deployment shape you'll find in a dozen blog posts and at least two Helm chart examples:

volumes:
  - name: qdrant-storage
    emptyDir: {}
containers:
  - name: qdrant
    image: qdrant/qdrant:latest
    volumeMounts:
      - name: qdrant-storage
        mountPath: /qdrant/storage
Enter fullscreen mode Exit fullscreen mode

Nothing about that manifest is wrong for a demo. It's wrong for anything you care about, and the failure mode is worse than "I lose my data on restart" because of where Qdrant puts snapshots by default.

Qdrant writes snapshots to ./snapshots relative to its working directory, which in the official image resolves to /qdrant/snapshots. That path is not /qdrant/storage. So even if you got the storage volume right and skipped emptyDir for the data, an unmounted snapshots path means your snapshots land on the container's writable layer, which is destroyed on every pod recreation. Node drains during a cluster upgrade, an OOM kill, a rollout from a changed config map, all of it wipes the snapshot directory silently.

The check takes one command:

kubectl exec -n vectordb deploy/qdrant -- df -h /qdrant/storage /qdrant/snapshots
Enter fullscreen mode Exit fullscreen mode

If either path shows overlay as the filesystem, that directory is ephemeral. If /qdrant/snapshots shows the same device as /qdrant/storage, you have a different problem, which is stage 2.

Stage 2: the PVC that isn't a backup

The obvious fix is to put both paths on a PersistentVolumeClaim. Data survives restarts, snapshots survive restarts, everyone goes home. Except now your backups live on the same block device as the thing they're backing up.

I wrote about this failure shape in Your Vector DB Snapshots Are Landing on the Same Disk That Will Fail, and it's the single most common mistake in self-hosted vector database setups. A snapshot on the same volume protects you against exactly one class of event: application-level corruption. Someone drops a collection, an ingestion job writes garbage vectors, a schema migration goes sideways. Fine, restore from the local snapshot.

It protects you against nothing else. Replica corruption, a Longhorn volume that won't attach, a filesystem that mounts read-only after an unclean shutdown, an accidental kubectl delete pvc with a Delete reclaim policy. In every one of those cases the snapshot goes down with the ship.

There's a second, sneakier problem with co-locating snapshots on the data PVC: capacity. Qdrant snapshots are not incremental and they are not deduplicated. Creating a snapshot of a collection requires roughly as much free space as the collection itself, because Qdrant is tarring up the segment files. A 20 GiB PVC holding 12 GiB of vectors cannot take a snapshot. You get a failed request and a partially written archive that still occupies space until something cleans it up.

Nothing cleans it up. Qdrant has no built-in retention for collection snapshots. Every call to POST /collections/{name}/snapshots creates a new file with a fresh timestamp and leaves every previous one exactly where it was. Wire that to an hourly CronJob, forget about it for a few weeks, and you end up with north of a hundred snapshot archives on a volume sized for the live data. The PVC fills, Qdrant's write path fails, and the failure looks like an application bug rather than a storage one.

Stage 3: snapshots leave the cluster's failure domain

The pattern that holds separates three things that people tend to collapse into one:

Layer Lives on Protects against
Qdrant data Longhorn RWO PVC nothing, this is the live copy
Qdrant snapshot (transient) same PVC, deleted after export app-level corruption, fast rollback
Exported snapshot external NFS share disk failure, volume loss, cluster loss

The transient snapshot is the important nuance. You still create the snapshot locally, because that's the only interface Qdrant gives you, but you treat the local copy as a temporary artifact with a lifetime of a few minutes rather than as the backup.

Do not mount NFS as Qdrant's storage path

Before the manifests, the caveat that will save you a week: Qdrant's storage directory needs a local block device. Qdrant memory-maps segment files and uses RocksDB for payload storage, and neither of those behaves well over NFS. File locking semantics differ, mmap over NFS has a very different consistency model, and the performance drop on HNSW search is severe enough that you'll notice it in p99 latency immediately. Qdrant's own documentation recommends against network storage for the data path.

NFS is fine as a destination for finished snapshot archives. Those are sequential writes of an immutable tar file, which is the workload NFS is actually good at.

The data volume

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: qdrant-data
  namespace: vectordb
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: longhorn
  resources:
    requests:
      storage: 40Gi   # 2x expected collection size, snapshots need headroom
Enter fullscreen mode Exit fullscreen mode

That sizing rule matters. If your collections total 15 GiB, a 20 GiB PVC will fail to snapshot. Size for double, or move the snapshots path to its own smaller volume.

The NFS target

An NFS-backed PV, statically provisioned, mounted only by the backup job:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: qdrant-backups-nfs
spec:
  capacity:
    storage: 200Gi
  accessModes: [ReadWriteMany]
  persistentVolumeReclaimPolicy: Retain   # never let K8s delete the backups
  nfs:
    server: 10.0.0.50
    path: /export/backups/qdrant
  mountOptions:
    - nfsvers=4.1
    - hard          # block on server outage instead of returning EIO mid-write
    - timeo=600
    - retrans=2
Enter fullscreen mode Exit fullscreen mode

hard over soft is deliberate. A soft mount returns an I/O error after the timeout, which means a partially written snapshot archive that looks like a file and restores like a coaster. A hard mount blocks, the CronJob hits its activeDeadlineSeconds, and you get a clean failure you can alert on.

Retain on the reclaim policy is the other non-negotiable. If someone deletes the PVC, you want the PV to go to Released and the data on the NFS server to stay exactly where it is.

The export job

The CronJob runs in its own pod and never touches Qdrant's PVC. That's not just hygiene, it's a hard constraint: a Longhorn RWO volume can only be attached to one node at a time, so a backup pod scheduled somewhere else physically cannot mount it. Everything moves over the HTTP API instead.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: qdrant-snapshot-export
  namespace: vectordb
spec:
  schedule: "0 */6 * * *"
  concurrencyPolicy: Forbid
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      activeDeadlineSeconds: 3600
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: export
              image: curlimages/curl:8.11.0
              command: ["/bin/sh", "/scripts/export.sh"]
              env:
                - name: QDRANT_URL
                  value: "http://qdrant.vectordb.svc.cluster.local:6333"
                - name: QDRANT_API_KEY
                  valueFrom:
                    secretKeyRef: { name: qdrant-api, key: api-key }
              volumeMounts:
                - { name: backups, mountPath: /backups }
                - { name: scripts, mountPath: /scripts }
          volumes:
            - name: backups
              persistentVolumeClaim: { claimName: qdrant-backups }
            - name: scripts
              configMap: { name: qdrant-export-script }
Enter fullscreen mode Exit fullscreen mode

concurrencyPolicy: Forbid prevents a slow export from stacking on top of the next scheduled run, which is how you accidentally create three concurrent snapshots and fill the data PVC.

The script itself does four things: create, download, verify, delete the local copy.

#!/bin/sh
set -eu

AUTH="api-key: ${QDRANT_API_KEY}"
DEST="/backups/$(date -u +%Y-%m-%d)"
mkdir -p "$DEST"

COLLECTIONS=$(curl -sf -H "$AUTH" "$QDRANT_URL/collections" \
  | grep -o '"name":"[^"]*"' | cut -d'"' -f4)

for C in $COLLECTIONS; do
  # wait=true blocks until the archive is fully written
  SNAP=$(curl -sf -X POST -H "$AUTH" \
    "$QDRANT_URL/collections/$C/snapshots?wait=true" \
    | grep -o '"name":"[^"]*"' | cut -d'"' -f4)

  curl -sf -H "$AUTH" \
    "$QDRANT_URL/collections/$C/snapshots/$SNAP" -o "$DEST/$SNAP"

  # a truncated tar is worse than no backup, so check before pruning
  if ! tar -tf "$DEST/$SNAP" >/dev/null 2>&1; then
    echo "corrupt archive for $C, keeping remote snapshot" >&2
    exit 1
  fi
  sha256sum "$DEST/$SNAP" >> "$DEST/SHA256SUMS"

  curl -sf -X DELETE -H "$AUTH" \
    "$QDRANT_URL/collections/$C/snapshots/$SNAP"
done
Enter fullscreen mode Exit fullscreen mode

The tar -tf check is the part people skip. A snapshot that downloaded halfway because the NFS server hiccuped produces a file with a plausible name and a plausible-ish size, and you will not find out it's unreadable until the day you need it. Validating the archive before deleting the source means a failed export leaves the local snapshot in place and the job retries.

Retention, on the NFS side

Qdrant won't prune for you and neither will NFS. A second, tiny CronJob handles it:

# keep 14 days of dailies, plus the first snapshot of each month forever
find /backups -maxdepth 1 -type d -mtime +14 \
  ! -name "$(date -u +%Y-%m)-01" -exec rm -rf {} +
Enter fullscreen mode Exit fullscreen mode

Six-hourly snapshots at 14 days retention on a 3 GiB collection set works out to about 170 GiB before compression, which is why the PV above is sized at 200 GiB. Run the arithmetic for your own collection sizes before you pick a schedule, because the number gets ugly fast with hourly snapshots.

Why the separation actually matters

The word to keep in your head is blast radius. Every backup design is really a claim about which failures it survives, and the honest way to evaluate one is to name the failure and trace where the bytes are.

A Longhorn recurring snapshot job is not a substitute here, and the reason is worth understanding. Longhorn snapshots are block-level, they live inside the same replicas as the volume data, and they form a chain. They're excellent for fast rollback and they cost almost nothing to create. They are not off-box copies. If a replica's underlying disk dies, the snapshot chain on that disk dies with it. Longhorn backups (the ones that target S3 or NFS) are the off-box tier, and those are a different resource type entirely. Conflating the two is common enough that I'd call it the default misunderstanding. Longhorn Volume Health: The Gap Between 'Healthy' and Actually Working goes deeper on how the replica state and the dashboard's idea of "healthy" diverge.

There's a capacity wrinkle too. Deleting a Longhorn snapshot does not immediately return the space. The snapshot gets marked for removal and the actual coalescing happens when the purge runs against each replica, so you'll see a volume that reports 40 GiB of usage against 12 GiB of live data for a while after cleanup. Recent Longhorn versions also cap snapshots per volume (250 by default), and a retain value on a recurring job that's too generous will hit that ceiling and start failing jobs. Retention that looked reasonable when the collection was small stops being reasonable at ten times the size.

Application-level snapshots solve a problem block-level snapshots can't touch: portability. A Qdrant snapshot archive restores into any Qdrant instance, on any storage class, on any cluster, with a single API call:

curl -X POST -H "api-key: $KEY" \
  -F "snapshot=@/backups/2026-07-29/docs-1753747200.snapshot" \
  "http://qdrant.example.local:6333/collections/docs/snapshots/upload?priority=snapshot"
Enter fullscreen mode Exit fullscreen mode

A Longhorn volume backup restores into a Longhorn cluster. That's a meaningfully smaller set of options on the day you're rebuilding.

Distributed mode changes the math

One caveat that catches people scaling from a single pod to a StatefulSet: in a distributed Qdrant deployment, a collection snapshot taken against one node contains only the shards that live on that node. Hitting the API through a Service round-robins your request to a random pod and gives you a partial backup that looks complete.

For multi-node setups you need to enumerate the pods and snapshot each one, addressing them by their StatefulSet DNS names rather than the Service:

for i in 0 1 2; do
  NODE="qdrant-$i.qdrant-headless.vectordb.svc.cluster.local:6333"
  curl -sf -X POST -H "$AUTH" "$NODE/collections/$C/snapshots?wait=true"
done
Enter fullscreen mode Exit fullscreen mode

Restore is correspondingly per-node and needs the same shard topology on the target. If you're running distributed Qdrant as the memory layer for an agent system, that recovery procedure is worth writing down and rehearsing before you need it, which is exactly the kind of thing I end up doing in infrastructure and AI agent consulting work.

Cleaning up what's already there

If you're retrofitting this onto a running deployment, there's probably a pile of accumulated snapshots and a few orphaned PVCs. Find the snapshots first:

kubectl exec -n vectordb deploy/qdrant -- \
  sh -c 'du -sh /qdrant/snapshots && ls -1 /qdrant/snapshots | wc -l'
Enter fullscreen mode Exit fullscreen mode

Then the detached volumes, which are the ones nobody remembers creating:

kubectl get pvc -A -o json | jq -r '
  .items[] | select(.status.phase == "Bound")
  | "\(.metadata.namespace)/\(.metadata.name) \(.spec.resources.requests.storage)"'
Enter fullscreen mode Exit fullscreen mode

Cross-reference against pods that actually mount them. Anything bound with no consumer is either a deliberate cold spare or dead weight, and in my experience it's usually dead weight left behind by a Helm release someone uninstalled without --cascade. Snapshot the volume before you delete it, confirm nothing breaks for a week, then remove it. Space won't come back instantly for the reasons above.

What I'd tell someone starting over

Test the restore before you trust the backup. An untested backup is a hypothesis. Pick a snapshot at random once a month, upload it into a throwaway collection named restore-drill, run a search against it, compare the point count to the source, then delete it. Three minutes of work that converts a guess into a fact.

Alert on backup freshness, not on job success. A CronJob that exits 0 while writing zero bytes is a green checkmark and an empty directory. The metric that matters is the age of the newest file on the NFS share, and if it exceeds two schedule intervals, page someone.

Size the data PVC assuming snapshots will temporarily double your footprint, because they will. This is the single most common way a Qdrant deployment falls over, and the error you get back is generic enough that it sends people looking in entirely the wrong place.

NFS is the middle tier, not the last one. It's off-box and it's simple, but it's still one server in one rack. The natural next step is pushing those archives to S3-compatible object storage, either through the same job or by pointing Qdrant's snapshots_config at S3 directly if your version supports it (check the docs for your specific release, this landed relatively recently and the config shape has moved). Velero + MinIO covers the object storage side of that, and it composes cleanly with everything above.

The thing that stuck with me most from working through this: the Qdrant snapshot API is a perfectly good primitive that quietly assumes you've solved a problem it can't see. It writes a file. Where that file lands, how long it lives, and whether anything ever reads it back are decisions the API can't make for you, and they're the only decisions that determine whether you have a backup at all.

Top comments (0)