title: “Scaling Qdrant Past One Machine: A Cluster Walkthrough on Hetzner + Docker”
tags:

  • Qdrant
  • Vector Database
  • Docker
  • Docker Compose
  • Hetzner
  • Distributed Systems
  • RAG
  • Cluster

If you’ve been running Qdrant as a single Docker container, the day your vector count crosses a few million and your embedding dimension is anything reasonable, you’ll see two things happen at once: the recall on your RAG pipeline gets worse, and your single-host docker stats output starts looking like a memory-leak horror story. A 128GB Hetzner CCX53 with 21 million vectors and a 1536-dim embedding will happily eat 65GB of resident memory and 4GB of swap, and then start getting slow.

This post is a walkthrough of what it actually takes to take that single-node Qdrant and turn it into a 3-node Raft-consensus cluster, migrate the existing data into the new cluster, and what happens when you scale out to 6 nodes. It draws heavily on a writeup by Nova Kwok that walked through the same migration in production — link at the bottom. The structure here, the commands, the gotchas, and the “here’s what the cluster status actually looks like after each step” output are all from that writeup, reformatted for tux.fan’s audience.

The starting point: one Docker container holding 21M vectors

The original setup is the kind of thing most people start with:

Yaml
# docker-compose.yml on the standalone host
services:
  qdrant:
    image: qdrant/qdrant:v1.14.0
    restart: always
    ports:
      - 6333:6333
      - 6334:6334
    volumes:
      - ./volumes/qdrant:/qdrant/storage

The host is a Hetzner CCX53 (32 cores, 128GB RAM). The qdrant directory sits around 200GB, holding 21,297,932 indexed vectors across 21,337,945 points in 10 segments. The cluster-status endpoint returns "status": "yellow" and "optimizer_status": "ok" — green-ish, but yellow on a single-node Qdrant just means “this collection has replicas configured that don’t exist yet”, which is fine until you want to actually take the box down for any reason.

htop on the single-node Qdrant host: 169271 root process at 64.3% MEM (3956GB VIRT), load average 25.53, 491 tasks. Qdrant is the dominant process and is about to need more memory than this box has.

The image above is htop on that host — Qdrant is the only meaningful process, holding 64.3% of physical memory at ~78GB VIRT. Load average is sitting at 25 on a 32-core box. You can survive on this machine for a while, but two things are now true:

  1. The single host is the only copy of every vector. A disk failure takes the whole RAG pipeline down.
  2. You cannot do a rolling upgrade of Qdrant without a maintenance window. The image is 1.14.0 today, 1.15.x will land tomorrow, and rolling upgrades across a Raft cluster are exactly the kind of feature you only get once you have a cluster.

So: cluster it.

Three-node cluster setup on Hetzner

Qdrant’s distributed mode uses Raft as its consensus protocol, so the minimum viable deployment is 3 nodes. Below is the test-environment configuration — internal Hetzner private network IPs 10.0.0.6, 10.0.0.7, 10.0.0.9.

Each node runs the same Qdrant image and same compose file shape, with one difference: the --bootstrap and --uri flags. One node is the bootstrap — that’s the first one you start. The other two join the cluster by pointing at the bootstrap’s p2p port.

Node 1 (bootstrap, 10.0.0.6)

Yaml
services:
  qdrant_node1:
    image: qdrant/qdrant:v1.14.0
    restart: always
    volumes:
      - ./qdrant_storage:/qdrant/storage
      - ./qdrant_snapshots:/qdrant/snapshots
    ports:
      - "6333:6333"
      - "6334:6334"
      - "6335:6335"
    environment:
      QDRANT__CLUSTER__ENABLED: "true"
    command: "./qdrant --uri http://10.0.0.6:6335"

Note the command: here — ./qdrant --uri ... is the in-container Qdrant binary path; you don’t need a separate entrypoint because the official image’s default entrypoint already execs that binary. You’re appending the cluster flag.

Node 2 (10.0.0.7)

Yaml
services:
  qdrant_node2:
    image: qdrant/qdrant:v1.14.0
    restart: always
    ports:
      - "6333:6333"
      - "6334:6334"
      - "6335:6335"
    volumes:
      - ./qdrant_storage:/qdrant/storage
      - ./qdrant_snapshots:/qdrant/snapshots
    environment:
      QDRANT__CLUSTER__ENABLED: "true"
    command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.7:6335"

Node 3 (10.0.0.9)

Yaml
services:
  qdrant_node3:
    image: qdrant/qdrant:v1.14.0
    restart: always
    ports:
      - "6333:6333"
      - "6334:6334"
      - "6335:6335"
    volumes:
      - ./qdrant_storage:/qdrant/storage
      - ./qdrant_snapshots:/qdrant/snapshots
    environment:
      QDRANT__CLUSTER__ENABLED: "true"
    command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.9:6335"

Start node 1 first with docker-compose up -d, wait ~10 seconds for the p2p port to be listening, then start nodes 2 and 3. On any node, query the cluster status:

Bash
curl http://localhost:6333/cluster

Expected output (truncated):

Json
{
  "result": {
    "status": "enabled",
    "peer_id": 5395257186314509,
    "peers": {
      "3095816753490206": { "uri": "http://10.0.0.9:6335/" },
      "5395257186314509": { "uri": "http://10.0.0.6:6335/" },
      "4182395837949771": { "uri": "http://10.0.0.7:6335/" }
    },
    "raft_info": {
      "term": 1,
      "commit": 41,
      "pending_operations": 0,
      "leader": 5395257186314509,
      "role": "Leader",
      "is_voter": true
    },
    "consensus_thread_status": {
      "consensus_thread_status": "working",
      "last_update": "2025-05-17T02:31:10Z"
    },
    "message_send_failures": {}
  },
  "status": "ok"
}

Three peers, one leader (10.0.0.6 in this case), is_voter: true on the queried node. That’s a healthy Raft cluster.

Creating the collection: shard count matters

Here’s the trap most people walk into. Qdrant’s docs say, very explicitly:

When you enable distributed mode and scale up to two or more nodes, your data does not move to the new node automatically; it starts out empty.

Just like ClickHouse — running a cluster does not mean your data is distributed and replicated. You have to manually tell Qdrant how to slice the data.

The two parameters that control this:

Parameter Meaning
shard_number How many pieces the collection is split into. Must divide evenly into your node count.
replication_factor How many copies of each shard exist. Each copy lives on a different peer.

The original standalone collection had shard_number=1, replication_factor=1 (because there was only one node). For the new 3-node cluster, the Qdrant docs recommend 12 shards if you anticipate a lot of growth:

If you anticipate a lot of growth, we recommend 12 shards since you can expand from 1 node up to 2, 3, 6, and 12 nodes without having to re-shard.

That works because 12 is divisible by 2, 3, 6, and 12. Each node can take 4 shards on a 3-node cluster, 2 on a 6-node cluster, 1 on a 12-node cluster, and you never have to re-shard as you scale.

For replication, replication_factor=2 is the standard choice — each shard has one primary and one replica, so any single node can fail without losing data.

Python
from qdrant_client import QdrantClient
import qdrant_client.http.models as models

collection_name = "new_collection"

client = QdrantClient(host="10.0.0.6", port=6333)

vectors_config = models.VectorParams(
    size=1536, distance=models.Distance.COSINE, on_disk=True
)

client.create_collection(
    collection_name=collection_name,
    vectors_config=vectors_config,
    shard_number=12,
    replication_factor=2,
    # Note: HNSW config left out at creation time — see "speed up the import" below
)

That’s the collection. Empty, 12 shards, 2 replicas per shard, ready to receive data.

Migrating data: snapshot import doesn’t work (use the migration tool)

Once the empty cluster exists, you need to bring the 21M vectors over. The naive approach is to take a snapshot on the source collection and restore it on the cluster. That fails because of an incompatibility:

Json
{
  "status": {
    "error": "Wrong input: Snapshot is not compatible with existing collection: Collection shard number: 3 Snapshot shard number: 1"
  },
  "time": 1107.142566774
}

The source has 1 shard. The target has 12. Snapshot restore refuses to silently re-shard.

Qdrant provides a dedicated migration tool for exactly this case: github.com/qdrant/migration. It’s a small Docker image that streams points from the source collection to the target collection, re-batching and re-sharding as it goes.

Bash
docker run --net=host --rm -it registry.cloud.qdrant.io/library/qdrant-migration qdrant 
    --source-url 'http://localhost:6334' 
    --source-collection 'new_collection' 
    --target-url 'http://10.0.0.6:6334' 
    --target-collection 'new_collection'

Two practical notes from Nova’s writeup:

  1. registry.cloud.qdrant.io/library/qdrant-migration is an older image. Build from the latest source on the GitHub repo to get current behavior.
  2. Patch grpc.MaxCallRecvMsgSize and bump batch-size in the migration tool. Default batch size is 50, which makes the import crawl on multi-million-vector collections. Bump it to ~20000. The relevant issue thread is qdrant/migration#30.

Speed up the import: disable HNSW during bulk insert

This is a separate optimization that compounds with the batch-size bump. During the import, you do not want Qdrant building HNSW indexes on every batch — that’s 90%+ of the import time. Disable HNSW during the import, then re-enable it once everything’s in:

Bash
curl -X PATCH http://10.0.0.6:6333/collections/your_collection 
  -H "Content-Type: application/json" 
  -d '{
    "hnsw_config": { "m": 0 },
    "optimizer_config": { "indexing_threshold": 10000 }
  }'

m: 0 tells Qdrant not to build HNSW indexes during the import. indexing_threshold: 10000 makes the optimizer flush every 10K vectors to disk instead of letting them accumulate in memory and risk an OOM on a node. After the import completes, re-enable HNSW and let it build the index in the background.

Inspecting shard distribution after migration

The cluster is up, the data is imported. How do you know it’s actually distributed evenly? The /cluster endpoint gives you peer info but not which shards are on which peer. The per-collection endpoint /collections/<name>/cluster gives you the full picture:

Bash
curl http://10.0.0.6:6333/collections/your_collection/cluster

The raw JSON is dense and not very human-readable, though. Here’s a small Python script that flattens it into something you can actually scan:

Python
import requests

base_url = "http://10.0.0.6:6333"

cluster_endpoint = "/cluster"
collections_endpoint = "/collections/<collection_name>/cluster"


def get_data_from_api(endpoint):
    response = requests.get(base_url + endpoint)
    return response.json()


def parse_cluster_peers(cluster_data):
    peers = cluster_data.get("result", {}).get("peers", {})
    ip_peer_map = {}
    for peer_id, peer_info in peers.items():
        uri = peer_info.get("uri", "")
        ip_address = uri.split("//")[-1].split(":")[0]
        ip_peer_map[ip_address] = int(peer_id)
    return ip_peer_map


def parse_shards(collections_data):
    local_shards = collections_data.get("result", {}).get("local_shards", [])
    remote_shards = collections_data.get("result", {}).get("remote_shards", [])

    peer_shard_map = {}
    for shard in local_shards:
        peer_id = collections_data.get("result", {}).get("peer_id")
        peer_shard_map.setdefault(peer_id, []).append(shard.get("shard_id"))
    for shard in remote_shards:
        peer_shard_map.setdefault(shard.get("peer_id"), []).append(shard.get("shard_id"))
    return peer_shard_map


def main():
    cluster_data = get_data_from_api(cluster_endpoint)
    collections_data = get_data_from_api(collections_endpoint)

    ip_peer_map = parse_cluster_peers(cluster_data)
    peer_shard_map = parse_shards(collections_data)

    ip_shard_map = {
        ip: peer_shard_map.get(peer_id, [])
        for ip, peer_id in ip_peer_map.items()
    }

    for ip, shard_ids in ip_shard_map.items():
        print(f"IP: {ip}, Peer ID: {ip_peer_map[ip]}, Shard IDs: {shard_ids}")


if __name__ == "__main__":
    main()

Output on a healthy 3-node cluster:

Bash
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]

8 shards on each of the 3 nodes — 24 total shard instances, which matches 12 shards × 2 replicas = 24. Every shard has both its replicas on different nodes, so any single node failure will not lose a shard.

Scaling out to 6 nodes — and what doesn’t happen

If the business grows and 3 nodes aren’t enough, the natural move is to add 3 more (10.0.0.10, 10.0.0.11, 10.0.0.12). Each new node uses the same compose file as before, pointing at the bootstrap:

Yaml
services:
  qdrant_node4:
    image: qdrant/qdrant:v1.14.0
    # ...same volume + port + env config as before...
    command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.10:6335"

docker-compose up -d on each new node. The cluster endpoint will now show 6 peers.

And then — nothing happens to the data.

Run the inspection script again:

Bash
IP: 10.0.0.6,  Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9,  Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]
IP: 10.0.0.12, Peer ID: 3658649898688837, Shard IDs: []
IP: 10.0.0.7,  Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.11, Peer ID: 8689864553665627, Shard IDs: []
IP: 10.0.0.10, Peer ID: 3841618339255269, Shard IDs: []

The three new nodes (10.0.0.10, 10.0.0.11, 10.0.0.12) are members of the Raft cluster but hold zero shards. All 24 shard replicas are still on the original three nodes.

If you came from GlusterFS, your first instinct is gluster volume rebalance VOLNAME start. Qdrant does not have this command. From the official docs:

Shards are evenly distributed across all existing nodes when a collection is first created, but Qdrant does not automatically rebalance shards if your cluster size or replication factor changes (since this is an expensive operation on large clusters).

The open-source version of Qdrant makes you move shards manually. The fully automated version exists, but only in Qdrant Cloud:

A meme-style illustration that captures the "no automatic rebalancing in the open-source build, but Qdrant Cloud handles it for you" trade-off that Nova flagged in the original writeup.

Manually rebalancing after scale-out

The rebalance API exists, it just needs you to drive it. The endpoint is /collections/<name>/cluster with a move_shard directive:

Bash
curl -X POST http://localhost:6333/collections/collection_name/cluster 
     -H "Content-Type: application/json" 
     -d '{
       "move_shard": {
         "shard_id": 1,
         "to_peer_id": 1000000,
         "from_peer_id": 1000000
       }
     }'

The math you do before calling it: figure out how many shards each node should have.

Bash
shards_per_node = (total_shards × replication_factor) / node_count
                = (12 × 2) / 6
                = 4

Each of the 6 nodes should hold 4 shard instances (where each “shard instance” is either a primary or a replica of one of the 12 logical shards). On a 3-node cluster, that was 8 per node. Going to 6 nodes means moving 4 shard instances off each of the original three nodes and onto the three new ones.

Three things matter while you plan the moves:

  1. Never move a shard’s two replicas to the same node. If that node dies, you lose the shard.
  2. Move shards one at a time, wait for each transfer to complete before issuing the next one. The /collections/.../cluster endpoint reports shard_transfers — if it’s empty, you’re ready for the next move.
  3. Don’t issue 12 parallel move_shard calls. Each transfer consumes bandwidth and disk I/O on both source and destination. Sequential moves with a check between each are dramatically safer.

This is the part that drives people to Qdrant Cloud. The math is straightforward, the API is clean, and the operations are not hard, but doing it 12 times by hand on a production cluster at 2am is exactly the kind of work nobody wants. Qdrant Cloud’s value prop is that this loop is automated.

ROSE: the four-stage scaling pattern

Nova’s writeup pointed at a useful framework called ROSE for thinking about cluster scaling operations. The acronym is:

Stage What you do
Resuscitation Get the new node healthy in the cluster (it’s in /cluster as a peer, it’s voted in by Raft, it can serve reads if it owns any shards)
Optimization Move shards onto the new node in the right distribution, taking care to respect replica placement
Stabilization Watch the cluster for one or two Raft terms, verify no shard is under-replicated, verify no pending_operations is stuck
Evacuation If you’re retiring a node (not just adding one), move all its shards off, then remove it from the cluster

R is trivial — that’s just bringing up the new node. O is the meat. S is where most operators skip and then wonder why a shard is “Active” but unhealthy. E is the same dance in reverse.

Cloud-init on Hetzner for fast cluster bring-up

If you’re going to do this more than once, typing three nearly-identical compose files by hand gets old fast. Nova’s writeup mentioned using Hetzner Cloud-init to spin up the three (or six) nodes with their Qdrant config baked into the image at boot time, all using the Hetzner internal network for the p2p port.

The pattern looks like:

Yaml
#cloud-config
write_files:
  - path: /opt/qdrant/docker-compose.yml
    content: |
      services:
        qdrant:
          image: qdrant/qdrant:v1.14.0
          restart: always
          volumes:
            - ./qdrant_storage:/qdrant/storage
            - ./qdrant_snapshots:/qdrant/snapshots
          ports:
            - "6333:6333"
            - "6334:6334"
            - "6335:6335"
          environment:
            QDRANT__CLUSTER__ENABLED: "true"
          command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://THIS_NODE_IP:6335"

runcmd:
  - cd /opt/qdrant && docker-compose up -d

Templating THIS_NODE_IP (via Hetzner’s metadata server, curl http://169.254.169.254/hetzner/v1/metadata/instance-id or similar) lets you stamp out three or six identical VMs and have them self-configure into the cluster on first boot. Once you have that, scaling out becomes “fire up three more VMs with the same Cloud-init, wait for them to join, then run the manual rebalance script.”

TL;DR

  1. A 128GB Hetzner CCX53 hits its limit at ~20M 1536-dim vectors. It will work, but you have zero failover and zero rolling-upgrade.
  2. Stand up 3 nodes on Hetzner, each running Qdrant with QDRANT__CLUSTER__ENABLED=true. One is the bootstrap; the other two join via --bootstrap http://<bootstrap>:6335.
  3. Create the new collection with shard_number=12, replication_factor=2 — 12 shards scales cleanly to 2, 3, 6, or 12 nodes without re-sharding.
  4. Migrate with the dedicated qdrant/migration Docker image — snapshot restore refuses to re-shard on the fly. Build the latest image; bump batch-size to 20000; patch grpc.MaxCallRecvMsgSize. Disable HNSW (m: 0) and set indexing_threshold: 10000 during the import, then re-enable HNSW when done.
  5. Verify distribution with the inspection script — you want 8 shards per node on a 3-node cluster, evenly spread.
  6. Scaling out to 6 nodes leaves the new nodes empty. Qdrant does not auto-rebalance. Use the move_shard API on /collections/<name>/cluster sequentially, one shard at a time, and never put both replicas on the same node. Follow the ROSE pattern: Resuscitate, Optimize, Stabilize, Evacuate.

Credits

This article is a rewrite of Nova Kwok’s “A Quick Note on Setting Up a Qdrant Cluster on Hetzner with Docker and Migrating Data” (CC BY-NC-SA). The commands, the cluster status JSON output, the inspection script, the move_shard example, the batch-size tuning tip, and the qdrant.png / money.jpg images are all from that writeup — reformatted here for tux.fan’s audience with additional context about the htop memory pressure, the HNSW-during-import optimization, and the ROSE pattern.

Last modified: 2026年7月14日

Author

Comments

Write a Reply or Comment

Your email address will not be published.