Skip to content
Guides · Recipes

How to Run the Qdrant Vector Database in a Cloud Sandbox

Updated October 27, 2026 .md

Short answer: Run the official qdrant/qdrant Docker image in a Sandboxer microVM with an API key, bound to 127.0.0.1, then publish it with sbx host --localhost. On a small sandbox, 20,000 vectors of 384 dimensions loaded in 2 seconds and a filtered top-10 search took 4 ms inside the VM.

What we ran#

Everything below ran on production on October 27, 2026, in one small sandbox (2 vCPU, 4 GB): Qdrant 1.20.0 from the qdrant/qdrant:v1.20.0 image and the Python qdrant-client 1.20.0. The vectors are random numbers from NumPy, not embeddings from a model, so the timings measure Qdrant, not a model. Timings are single runs or medians of 20 to 30 queries. Yours will differ.

Step Result
sbx new --type small until ready 3.2 s
Pull the image, start Qdrant, wait for /readyz 5.0 s
Upsert 20,000 points (384 dims, 2 payload fields) in batches of 1,000 2.0 s
Collection green after the last upsert 2.5 s, 14,000 of 20,000 vectors in the HNSW index
Top-10 search, no filter, inside the VM 2.8 ms median, 6 of the 10 exact neighbors
Top-10 search with a category + price filter, inside the VM 4.1 ms median, 10 of 10 exact neighbors
Same kind of search from a laptop through the private hosted URL 128 ms median round trip
Snapshot the collection, then download it through the hosted URL 0.4 s to create, 74 MB, 28.7 s to download
sbx stop --wait, then sbx resume 2.2 s and 1.3 s, memory restored, Qdrant never restarted
Resume on a bigger size (boots from disk) Docker restarted Qdrant; both collections intact

This page is about reaching Qdrant over HTTPS from outside the VM. If you'd rather keep a database behind an SSH tunnel, see Postgres with pgvector, which uses ssh -L.

1. Start Qdrant with an API key and a restart policy#

Docker is preinstalled in every sandbox. Qdrant's own install docs use the qdrant/qdrant image; we pinned a version. The security guide sets the API key through the QDRANT__SERVICE__API_KEY environment variable:

sbx new --name qdrant --type small --no-env
id=sb_3hog0mh29o    # the id that sbx new printed

sbx exec $id --timeout 600 'openssl rand -hex 24 > ~/qdrant.key && chmod 600 ~/qdrant.key && \
  docker run -d --name qdrant --restart unless-stopped \
    -p 127.0.0.1:6333:6333 -v qdrant_storage:/qdrant/storage \
    -e QDRANT__SERVICE__API_KEY=$(cat ~/qdrant.key) qdrant/qdrant:v1.20.0 && \
  until curl -sf http://127.0.0.1:6333/readyz >/dev/null; do sleep 0.3; done'
sbx exec $id 'curl -s http://127.0.0.1:6333/readyz; echo
curl -s -o /dev/null -w "no key: %{http_code}\n" http://127.0.0.1:6333/collections
curl -s -H "api-key: $(cat ~/qdrant.key)" http://127.0.0.1:6333/'
all shards are ready
no key: 401
{"title":"qdrant - vector search engine","version":"1.20.0","commit":"222b2e44ab1c3f17e8246396ead6558e45041df7"}

What each part does:

  • -p 127.0.0.1:6333:6333 keeps the REST port on the VM's loopback. We publish it in step 3 with --localhost, so the only way in from outside is the hosted URL.
  • QDRANT__SERVICE__API_KEY makes Qdrant refuse requests without the key (401 above). The hosted URL already has its own token; the key is a second lock, and it also protects Qdrant from other processes in the VM.
  • -v qdrant_storage:/qdrant/storage puts the data in a Docker volume on the sandbox disk.
  • --restart unless-stopped brings Qdrant back when a resume boots from disk (step 5).

The key file stays in the sandbox. Copy it to your laptop with sbx scp $id:/home/user/qdrant.key . when you need it there.

2. Upsert 20,000 points and search with a filter#

Install the client in a virtual environment inside the sandbox. uv is preinstalled:

sbx exec $id --timeout 300 'bash -lc "uv venv -q .venv && uv pip install -q --python .venv/bin/python qdrant-client==1.20.0 numpy"'

Save this as load.py. It creates a collection with cosine distance, adds a payload index on category, upserts 20,000 points with two payload fields, waits for the collection to turn green, then searches with and without a filter:

import pathlib, statistics, time
import numpy as np
from qdrant_client import QdrantClient, models

key = pathlib.Path.home().joinpath("qdrant.key").read_text().strip()
client = QdrantClient(url="http://127.0.0.1:6333", api_key=key, https=False)
print("server", client.info().version)

N, DIM = 20_000, 384
rng = np.random.default_rng(7)
vectors = rng.random((N, DIM), dtype=np.float32)
categories = ["books", "music", "games", "tools", "garden"]

if client.collection_exists("products"):
    client.delete_collection("products")
client.create_collection(
    "products",
    vectors_config=models.VectorParams(size=DIM, distance=models.Distance.COSINE),
)
client.create_payload_index("products", "category", models.PayloadSchemaType.KEYWORD)

t0 = time.perf_counter()
for start in range(0, N, 1000):
    client.upsert(
        "products",
        points=models.Batch(
            ids=list(range(start, start + 1000)),
            vectors=vectors[start:start + 1000].tolist(),
            payloads=[{"category": categories[i % 5], "price": int(i % 100)} for i in range(start, start + 1000)],
        ),
        wait=True,
    )
print(f"upsert {N} points: {time.perf_counter() - t0:.1f} s")

t0 = time.perf_counter()
while True:
    info = client.get_collection("products")
    if info.status == models.CollectionStatus.GREEN:
        break
    time.sleep(0.2)
print(f"index ready after {time.perf_counter() - t0:.1f} s: status={info.status.value} points={info.points_count} indexed={info.indexed_vectors_count}")

flt = models.Filter(must=[
    models.FieldCondition(key="category", match=models.MatchValue(value="books")),
    models.FieldCondition(key="price", range=models.Range(lt=20)),
])
q = vectors[42].tolist()

def timed(**kw):
    times = []
    for _ in range(30):
        t = time.perf_counter()
        res = client.query_points("products", query=q, limit=10, **kw).points
        times.append((time.perf_counter() - t) * 1000)
    return res, statistics.median(times)

hits, ms = timed()
print(f"top-10, no filter: median {ms:.1f} ms; first hit id={hits[0].id} score={hits[0].score:.4f}")
hits, ms = timed(query_filter=flt)
print(f"top-10, category=books and price<20: median {ms:.1f} ms")
for h in hits[:3]:
    print(" ", h.id, round(h.score, 4), h.payload)
exact, _ = timed(query_filter=flt, search_params=models.SearchParams(exact=True))
print("overlap with exact search:", len({h.id for h in hits} & {h.id for h in exact}), "of 10")
sbx scp ./load.py $id:/home/user/
sbx exec $id --timeout 300 '.venv/bin/python -u load.py'
server 1.20.0
upsert 20000 points: 2.0 s
index ready after 2.5 s: status=green points=20000 indexed=14000
top-10, no filter: median 2.8 ms; first hit id=17341 score=0.7925
top-10, category=books and price<20: median 4.1 ms
  7705 0.7838 {'category': 'books', 'price': 5}
  9805 0.7776 {'category': 'books', 'price': 5}
  2005 0.7774 {'category': 'books', 'price': 5}
overlap with exact search: 10 of 10

The client also prints UserWarning: Api key is used with an insecure connection. Inside the VM on loopback that is expected; from outside you use HTTPS (step 3).

Two things in that output that surprise people#

green does not mean every vector is indexed. Our first version of this script waited for indexed_vectors_count to reach 20,000. It never did: the collection was green with 14,000 indexed, and the loop spun until sbx exec gave up. Qdrant builds an HNSW index only for segments bigger than the optimizer's indexing_threshold (10,000 KB by default, shown in the collection info). Smaller segments are searched by a plain scan. Wait for status == green, not for the indexed count to match the point count. See Qdrant's optimizer docs.

The unfiltered search missed the query's own point. The query is point 42's vector, so point 42 should come first with a score of 1.0. With the default search settings it did not appear at all. Save this as recall.py and compare with exact search and a larger hnsw_ef (search parameters):

import pathlib
from qdrant_client import QdrantClient, models
c = QdrantClient(url="http://127.0.0.1:6333", api_key=pathlib.Path.home().joinpath("qdrant.key").read_text().strip(), https=False)
q = c.retrieve("products", ids=[42], with_vectors=True)[0].vector
for label, params in [("hnsw ef=default", None), ("hnsw ef=256", models.SearchParams(hnsw_ef=256)), ("exact", models.SearchParams(exact=True))]:
    ids = [p.id for p in c.query_points("products", query=q, limit=10, search_params=params).points]
    print(f"{label:16} first={ids[0]} contains 42: {42 in ids}")
    if label == "exact": exact = set(ids)
    else: globals()[label] = set(ids)
print("default overlap with exact:", len(globals()["hnsw ef=default"] & exact), "of 10; ef=256:", len(globals()["hnsw ef=256"] & exact), "of 10")
hnsw ef=default  first=17341 contains 42: False
hnsw ef=256      first=42 contains 42: True
exact            first=42 contains 42: True
default overlap with exact: 6 of 10; ef=256: 10 of 10

Uniform random vectors are a hard case for approximate search, because every point is about the same distance from every other point. Real embeddings cluster, so recall is usually better. Run this file against your own data before you tune anything. The filtered search above was exact (10 of 10): the filter matched only about 800 points, and Qdrant's query planner can answer a filter that selective from the payload index without the HNSW graph, as its indexing docs describe.

3. Publish Qdrant on a private HTTPS URL#

Qdrant listens on 127.0.0.1 in the VM, so host the port with --localhost. The sandbox relays the connection to loopback. A new hosted port is private by default (hosting docs):

sbx host $id 6333 --localhost --title Qdrant
https://lively-global-kestrel-6333.box.sandboxer.com?_token=<token>

Now there are two locks: the hosted URL's token (checked by Sandboxer's proxy) and Qdrant's API key (checked by Qdrant). This is what each combination got:

Request Status
No token 401 from the proxy, never reaches Qdrant
GET ?_token=<token> 302, sets the __sbx_auth cookie
Cookie, no api-key header 401 from Qdrant: Must provide an API key or an Authorization bearer token
Cookie and api-key 200
GET /dashboard/ with the cookie 200, Qdrant's web UI (it asks for the API key)

In a browser, open the full token URL once, then go to /dashboard/ on the same host. The cookie lasts 30 days.

Query from your laptop with qdrant-client#

A script has to trade the token for the cookie first, then send the cookie on every request. qdrant-client takes extra headers through headers=. Save as remote.py on your laptop:

# QDRANT_URL_WITH_TOKEN=<private hosted URL> QDRANT_API_KEY=<key> python remote.py
import os, statistics, time
from urllib.parse import urlsplit
import httpx
from qdrant_client import QdrantClient, models

link = os.environ["QDRANT_URL_WITH_TOKEN"]
r = httpx.get(link, follow_redirects=False)            # trade the token for the cookie
cookie = r.headers["set-cookie"].split(";")[0]
base = "https://" + urlsplit(link).hostname

client = QdrantClient(url=base, port=443, api_key=os.environ["QDRANT_API_KEY"],
                      headers={"Cookie": cookie}, timeout=10)
print("server", client.info().version)
print("points", client.count("products").count)

q = client.retrieve("products", ids=[42], with_vectors=True)[0].vector
flt = models.Filter(must=[models.FieldCondition(key="category", match=models.MatchValue(value="books"))])
times = []
for _ in range(20):
    t = time.perf_counter()
    hits = client.query_points("products", query=q, query_filter=flt, limit=5).points
    times.append((time.perf_counter() - t) * 1000)
print([(h.id, round(h.score, 4)) for h in hits])
print(f"median {statistics.median(times):.0f} ms, min {min(times):.0f} ms over {len(times)} queries")
server 1.20.0
points 20000
[(7705, 0.7838), (18855, 0.7832), (3375, 0.7825), (9295, 0.7812), (14485, 0.7809)]
median 128 ms, min 128 ms over 20 queries

Keep port=443. Without it, qdrant-client adds its default port 6333 to the URL, and the call fails with [Errno 111] Connection refused. We tried it.

The search itself takes a few milliseconds. Almost all of the 128 ms is the network path from our laptop to the sandbox and back, which depends on where you are. For a loop of queries, run the client in the sandbox, next to Qdrant.

4. Back up a collection with a snapshot#

Qdrant's snapshot API writes a collection to one file. Create it and download it through the same hosted URL ($C is the __sbx_auth=… cookie, $K the API key):

B=https://lively-global-kestrel-6333.box.sandboxer.com
curl -s -X POST -H "Cookie: $C" -H "api-key: $K" "$B/collections/products/snapshots?wait=true"
curl -s -H "Cookie: $C" -H "api-key: $K" -o products.snapshot \
  "$B/collections/products/snapshots/products-4813605789839310-2026-10-27-10-06-18-0.snapshot"
{"result":{"name":"products-4813605789839310-2026-10-27-10-06-18-0.snapshot","creation_time":"2026-10-27T10:06:18","size":74451968,"checksum":"062b7bda…"},"status":"ok","time":0.171302321}

Creating it took 0.4 s; the 74 MB download took 28.7 s on our connection. To restore inside the VM without uploading anything, point Qdrant at the file it already wrote:

sbx exec $id --timeout 120 'curl -s -X PUT -H "api-key: $(cat ~/qdrant.key)" -H "content-type: application/json" \
  -d "{\"location\":\"file:///qdrant/snapshots/products/products-4813605789839310-2026-10-27-10-06-18-0.snapshot\"}" \
  "http://127.0.0.1:6333/collections/restored/snapshots/recover?wait=true"'
{"result":true,"status":"ok","time":0.262814712}

A count on restored returned 20,000. This is also how you move a collection to a new sandbox: download the snapshot, upload it to the new one, recover.

5. What survives a stop and resume#

sbx stop $id --wait
sbx resume $id
sbx exec $id 'docker ps --format "{{.Names}} {{.Status}}"'
qdrant Up 11 minutes

The resume reported memoryRestored: true. Qdrant was never restarted, and remote.py worked again with the same hosted URL and cookie.

A resume boots from disk instead when no machine with the same CPU type is free, or when you change the size. We forced that with sbx resume $id --type default (memoryRestored: false, resumeReason: "resized"). Docker's restart policy started Qdrant again (qdrant Up 1 second), both collections still held 20,000 points, and the --localhost hosted URL answered remote.py with the same results. Moving to a smaller size isn't supported yet: create a new sandbox and move the data with a snapshot. See snapshots.

6. Clean up#

sbx delete $id --yes

A small sandbox costs $0.018/hour while it runs and nothing while it is stopped.

FAQ#

Do I still need a Qdrant API key if the hosted URL is private?#

We recommend it. The private URL's token is one secret that anyone holding the full link can use. The API key is a second secret that Qdrant checks itself, and it also stops other processes in the sandbox, such as an agent's code, from writing to your collections.

Why is my collection green but indexed_vectors_count lower than points_count?#

Qdrant only builds an HNSW index for segments larger than indexing_threshold (10,000 KB by default). Smaller segments are searched by a full scan. In our run, 14,000 of 20,000 vectors were indexed and the collection was green. Wait for green, not for the counts to match.

Why does qdrant-client fail with "Connection refused" on the hosted URL?#

It adds port 6333 to a URL that has no port. Pass port=443. The hosted URL serves HTTPS on 443 only.

Can I use the gRPC port through the hosted URL?#

We tested the REST API on port 6333 only. The hosted URL serves HTTPS with HTTP/2, but we didn't run Qdrant's gRPC API (port 6334) through it, so we can't say it works.

Is a small sandbox big enough?#

For our 20,000 points of 384 dimensions, yes: the whole run used the 2 vCPU, 4 GB small size. By default Qdrant preloads its on-disk vectors into memory (checked October 27, 2026), so 384 float32 values cost about 1.5 KB per point before the index. Size up when your collection grows toward the sandbox's memory, or use Qdrant's on-disk and quantization options.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide