A vector database stores embeddings derived from your private data: customer messages, product catalogs, internal documents. Leaving Qdrant on its default cleartext port with no authentication is the same risk profile as leaving Postgres open with the postgres user passwordless. Scanners such as Shodan index open database ports continuously, so an exposed instance gets noticed fast. Pair the steps below with a UFW host firewall and Fail2ban for brute-force IP banning, and the attack surface drops to roughly that of any other internet-facing service.
This guide locks down a Qdrant deployment end to end. Bind it to localhost so the public network never sees it directly. Front it with Nginx for TLS termination on both REST (HTTPS) and gRPC. Use a strong API key. Add JWT-based per-collection access tokens. Cap request rates with Nginx limit_req. We built it in May 2026 with a real Let’s Encrypt certificate on a public VM and re-ran the Qdrant, Nginx, JWT and rate-limit steps in October 2026 on Ubuntu 24.04 with Qdrant 1.19.2, Nginx 1.24 and qdrant-client 1.19.1, which caught four steps that no longer worked.
Threat model: what an open Qdrant looks like
A Qdrant container with default flags exposes two ports on every interface: 6333 (REST, HTTP/1.1, no TLS) and 6334 (gRPC, HTTP/2, no TLS). With no API key, an unauthenticated attacker can list collections, dump points, download snapshots, and create or delete data. The cleartext channel also exposes API keys and JWT tokens to anyone on the network path, which on a cloud VM includes every NAT box between your clients and your host.
Three concrete bad scenarios show up in real deployments:
- Direct dump via REST. An attacker hits
http://10.0.1.50:6333/collections, finds an unprotected collection, scrolls every point with the payload, and pulls embeddings plus raw text out of the catalog in an hour. - Cleartext key leak. You enable an API key but stay on HTTP. Every request travels with
api-key: ...in the header in plaintext. A passive observer on the wire picks it up once and reuses it forever. - Public gRPC. Less obvious than REST because curl does not speak gRPC, but every Qdrant client can connect to
10.0.1.50:6334without TLS and read or write at will.
The fix is layered: bind Qdrant to loopback, terminate TLS in front of it, authenticate every call, and rate-limit anonymous traffic. Each layer covers a different class of attacker.
Bind Qdrant to loopback and set a strong API key
The two foundations of the lock-down: never expose Qdrant ports on the public NIC, and put a real key in front of every request. Generate a 32-byte random key once and store it in a file outside the container. The chown matters only for images that run as UID 1000; the stock qdrant/qdrant image runs as root, so it is harmless there:
sudo mkdir -p /etc/qdrant /opt/qdrant/storage /opt/qdrant/snapshots
sudo chown -R 1000:1000 /opt/qdrant
openssl rand -base64 32 | tr -d '=+/' | cut -c1-40 \
| sudo tee /etc/qdrant/api-key.secret > /dev/null
sudo chmod 600 /etc/qdrant/api-key.secret
Now start Qdrant. The critical bit is the -p 127.0.0.1:PORT:PORT form: Docker only publishes the ports on the loopback interface, so nothing outside the host can reach 6333 or 16334 directly. That holds on Docker Engine 28.0 and newer; older engines let other machines on the same LAN reach ports published on 127.0.0.1, so check yours with docker version --format '{{.Server.Version}}'. On Ubuntu 24.04 the docker.io package from noble-updates is already 29.x, and Docker CE from Docker’s own repository works too (our Docker CE install guide covers it). If Qdrant already runs on this host, remove the old container first with docker rm -f qdrant and point the -v mounts at its existing storage, or your collections will look empty. The QDRANT__SERVICE__API_KEY environment variable enforces the key on every endpoint that mutates or reads data:
API_KEY=$(sudo cat /etc/qdrant/api-key.secret)
# Note: port 16334 for gRPC on loopback, Nginx will own external 6334
sudo docker run -d --name qdrant --restart=always \
-p 127.0.0.1:6333:6333 -p 127.0.0.1:16334:6334 \
-e QDRANT__SERVICE__API_KEY="$API_KEY" \
-e QDRANT__SERVICE__JWT_RBAC=true \
-v /opt/qdrant/storage:/qdrant/storage \
-v /opt/qdrant/snapshots:/qdrant/snapshots \
qdrant/qdrant:v1.19.2
Confirm that the loopback works and the public NIC does not:
# Loopback responds with the version banner
curl -sS http://localhost:6333/ -H "api-key: $API_KEY"
# {"title":"qdrant - vector search engine","version":"1.19.2","commit":"016542aa5deb6c66380bb137badf73d54f742bde"}
# The host's own LAN address is refused, nothing listens there
curl -m 3 -sS -o /dev/null -w 'HTTP=%{http_code}\n' http://10.0.1.50:6333/
# curl: (7) Failed to connect to 10.0.1.50 port 6333 after 0 ms: Couldn't connect to server
# HTTP=000
Use the server’s own address in place of 10.0.1.50. An instant Couldn't connect is the result you want: the kernel has no listener on that interface, so there is nothing to time out against. The internal gRPC port lives on 16334 because Nginx will bind the public-facing 6334 to terminate TLS. Pick any free loopback port; we use 16334 because the digit prefix makes the relationship to 6334 obvious.

Issue a Let’s Encrypt certificate
The default and simplest path is HTTP-01: certbot’s nginx plugin adds a temporary location for the challenge path to the server block that matches your domain, Let’s Encrypt fetches the token over plain HTTP on port 80, and the cert lands. This works with any DNS provider as long as your host has a real public IP and port 80 is reachable, so open 80, 443 and 6334 at the cloud firewall (the firewall section below shows the rules) and in UFW if you run it (sudo ufw allow 80,443,6334/tcp) before you request the certificate.
Point a DNS A record at the host (any provider works: Cloudflare, Route 53, Namecheap, your registrar’s own), then install Nginx and certbot:
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx
Create a minimal vhost that owns the domain so certbot has a server block to attach its challenge to. Open a new site file:
sudo vim /etc/nginx/sites-available/qdrant
Add a single port-80 server block, with your own domain in place of the example. The placeholder response only exists so the block answers until certbot rewrites it:
server {
listen 80;
server_name qdrant.example.com;
location / { return 200 'cfg qdrant'; }
}
Enable it, drop the default site, and reload Nginx:
sudo ln -sf /etc/nginx/sites-available/qdrant /etc/nginx/sites-enabled/qdrant
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Run certbot. The --nginx plugin rewrites the vhost in-place to add the SSL block, and the --redirect flag adds the HTTP-to-HTTPS redirect for free:
sudo certbot --nginx \
-d qdrant.example.com \
--non-interactive --agree-tos --redirect \
-m [email protected]
Certbot prints where it saved the certificate and key, the expiry date (90 days out), and a note that renewal is scheduled. The line to look for comes after that: because --nginx also installs the certificate, a successful run reports Successfully deployed certificate for qdrant.example.com to /etc/nginx/sites-enabled/qdrant. Without that line the full vhost in the next section has no certificate files to point at.
Alternative: DNS-01 for hosts without public port 80
If the host is on a private LAN, behind NAT without port-forward, or needs a wildcard cert, use a DNS-01 plugin instead. Certbot has plugins for the common DNS providers, and Ubuntu packages them; pick the one for yours:
| Provider | Plugin package |
|---|---|
| Cloudflare | python3-certbot-dns-cloudflare |
| Route 53 | python3-certbot-dns-route53 |
| DigitalOcean | python3-certbot-dns-digitalocean |
| Google Cloud DNS | python3-certbot-dns-google |
| Linode | python3-certbot-dns-linode |
| RFC2136 (BIND) | python3-certbot-dns-rfc2136 |
One worked example using Cloudflare, with an API token that has Zone:DNS:Edit permission on the zone. Use the DNS plugin as the authenticator and keep the nginx plugin as the installer, so certbot still writes the SSL options files the full vhost includes and reloads Nginx after every renewal. A plain certonly run skips both, and the full vhost then fails nginx -t because /etc/letsencrypt/options-ssl-nginx.conf doesn’t exist. Substitute the plugin name for your provider:
sudo apt install -y python3-certbot-dns-cloudflare
echo "dns_cloudflare_api_token = YOUR_TOKEN" | sudo tee /etc/letsencrypt/cf.ini > /dev/null
sudo chmod 600 /etc/letsencrypt/cf.ini
sudo certbot --authenticator dns-cloudflare --installer nginx \
--dns-cloudflare-credentials /etc/letsencrypt/cf.ini \
-d qdrant.example.com --redirect \
--non-interactive --agree-tos -m [email protected]
This replaces only the certbot --nginx command above. You still need Nginx, python3-certbot-nginx and the port-80 server block with your server_name, because the nginx installer looks for that block to deploy into. We used HTTP-01 for this build; the DNS-01 commands follow certbot’s plugin documentation. Certbot waits 10 seconds for the TXT record to propagate before it asks Let’s Encrypt to check, and --dns-cloudflare-propagation-seconds raises that if your provider is slow.
Nginx reverse proxy for REST and gRPC
The full vhost has three server blocks: one for plain HTTP that redirects to HTTPS, one for REST on 443, and one for gRPC on 6334. Open the site file certbot just edited:
sudo vim /etc/nginx/sites-available/qdrant
Replace everything in it, including the SSL lines certbot added, with the config below. The rate-limit zone at the top is shared between the REST and gRPC blocks so a noisy client cannot get around the cap by alternating protocols:
# /etc/nginx/sites-available/qdrant
limit_req_zone $binary_remote_addr zone=qdrant_rl:10m rate=5r/s;
server {
listen 80;
server_name qdrant.example.com;
server_tokens off;
return 301 https://$host$request_uri;
}
# REST over HTTPS
server {
listen 443 ssl http2;
server_name qdrant.example.com;
ssl_certificate /etc/letsencrypt/live/qdrant.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qdrant.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
client_max_body_size 128m;
proxy_read_timeout 120s;
location / {
limit_req zone=qdrant_rl burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:6333;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
}
# gRPC over HTTPS, separate block because grpc_pass needs end-to-end HTTP/2
server {
listen 6334 ssl http2;
server_name qdrant.example.com;
ssl_certificate /etc/letsencrypt/live/qdrant.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qdrant.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
server_tokens off;
grpc_read_timeout 120s;
grpc_send_timeout 120s;
client_max_body_size 128m;
location / {
limit_req zone=qdrant_rl burst=50 nodelay;
limit_req_status 429;
# Internal Qdrant gRPC on the loopback. Note grpc:// (cleartext to backend).
grpc_pass grpc://127.0.0.1:16334;
grpc_set_header X-Real-IP $remote_addr;
}
}
Three details deserve attention. First, the gRPC block uses grpc_pass, not proxy_pass: gRPC rides HTTP/2 frames that proxy_pass mangles. Second, the loopback Qdrant talks plain gRPC (grpc://, not grpcs://) because it never leaves the host. TLS happens at the Nginx boundary. Third, the burst budgets differ between REST and gRPC: REST gets 20 (interactive use), gRPC gets 50 (services tend to fan out in bursts).
Ubuntu 24.04’s Nginx 1.24 needs the listen ... ssl http2 form shown above. From Nginx 1.25.1 onward, which includes the 1.28 in Ubuntu 26.04, nginx -t still passes but prints [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead once for each of the two blocks. On those versions write listen 443 ssl and listen 6334 ssl, then add http2 on inside each server block. Don’t copy that newer form back to 24.04, because Nginx 1.24 rejects it with unknown directive "http2".
Deploy and reload:
sudo nginx -t
sudo systemctl reload nginx
sudo ss -tlnp | grep -E ':80|:443|:6334'
# nginx listens on 0.0.0.0:80, 0.0.0.0:443, 0.0.0.0:6334
The 6334 listener on 0.0.0.0 only works because Qdrant moved its internal gRPC to 16334. If you forget that step, Nginx fails to bind 6334 with address already in use, yet systemctl reload nginx still returns 0 and the old config keeps serving, so the only trace is an [emerg] line in /var/log/nginx/error.log. A full systemctl restart in the same state takes Nginx down with still could not bind(). Always check ss -tlnp after a reload that touches port mappings.
Verify the full chain
Walk through every layer with curl and openssl:
API_KEY=$(sudo cat /etc/qdrant/api-key.secret)
# 1. Plain HTTP redirects to HTTPS
curl -sS -o /dev/null -w '%{http_code} -> %{redirect_url}\n' \
http://qdrant.example.com/
# 301 -> https://qdrant.example.com/
# 2. HTTPS rejects requests with no api-key
curl -sS -o /dev/null -w 'HTTP=%{http_code}\n' \
https://qdrant.example.com/collections
# HTTP=401
# 3. HTTPS accepts the same request with a valid api-key
curl -sS -o /dev/null -w 'HTTP=%{http_code}\n' \
https://qdrant.example.com/collections -H "api-key: $API_KEY"
# HTTP=200
# 4. The version banner comes through the proxy (/ is public, no key needed)
curl -sS https://qdrant.example.com/
# {"title":"qdrant - vector search engine","version":"1.19.2","commit":"016542aa..."}
Inspect the TLS handshake to confirm the certificate verifies and the protocol negotiates TLS 1.3. Use -brief: the older habit of piping echo into s_client and grepping for Protocol often prints nothing on TLS 1.3, because s_client only prints that session summary if the server’s post-handshake session ticket arrives before it sees the end of input. On our October run it printed nothing:
echo | openssl s_client -brief -connect qdrant.example.com:443 \
-servername qdrant.example.com
# CONNECTION ESTABLISHED
# Protocol version: TLSv1.3
# Ciphersuite: TLS_AES_256_GCM_SHA384
# Peer certificate: CN = qdrant.example.com
# Hash used: SHA256
# Signature type: ECDSA
# Verification: OK
# Server Temp Key: X25519, 253 bits
# DONE
The May build, on Qdrant 1.18.1, rendered the same checks side by side with the hardening headers Nginx adds. That run used the older s_client form and happened to receive its session summary:

The Web UI works the same way: https://qdrant.example.com/dashboard loads over HTTPS with the valid certificate, and its calls need the API key like any other client. This capture is also from the May build:

For gRPC, the Python client needs https=True, the public hostname and one setting that is easy to miss: port=443. The client checks server compatibility over REST before it talks gRPC, and that check goes to port 6333 unless you say otherwise. Through this proxy 6333 is closed, so without port=443 the script still works but prints UserWarning: Failed to obtain server version. Unable to check client-server compatibility. on every start. The api-key travels in HTTP/2 metadata under the same name:
from qdrant_client import QdrantClient
import os
client = QdrantClient(
host="qdrant.example.com",
port=443,
grpc_port=6334,
prefer_grpc=True,
https=True,
api_key=os.environ["QDRANT_API_KEY"],
)
print(client.get_collections())
Ubuntu doesn’t package qdrant-client, and the 24.04 cloud images ship neither pip nor venv, so give the client its own virtual environment and pass the key in through the environment. Save the snippet above as grpc_check.py, then:
sudo apt install -y python3-venv
python3 -m venv ~/qdrant-venv
~/qdrant-venv/bin/pip install qdrant-client
export QDRANT_API_KEY="$API_KEY"
~/qdrant-venv/bin/python grpc_check.py
With no collections created yet, it prints an empty list and no compatibility warning:
collections=[]
From a client on the same host, the first query over gRPC and TLS took 21.4 ms including the handshake. The 50 queries after it ran at a median of 1.9 ms with a 95th percentile of 2.9 ms, because the TLS session stays open on the same connection. If the client can’t verify the certificate, for instance because it doesn’t trust a private CA, the call fails with StatusCode.UNAVAILABLE and CERTIFICATE_VERIFY_FAILED in the details, which is the first thing to check when gRPC works locally but not from another machine.
JWT for per-collection access tokens
The master API key is for the admin. Application code, build pipelines, and on-call dashboards do not need full cluster access; they need scoped tokens. Qdrant implements JWT-RBAC: HMAC-signed tokens with an access claim that says which collections the token may touch and whether it may write.
With QDRANT__SERVICE__JWT_RBAC=true already set on the Qdrant container, any HS256 JWT signed with the master API key is accepted. The plaintext signing material is the master API key string, so keep that key strictly server-side and only mint tokens from it. The tokens below are scoped to a collection called jwt_demo, so create it first with the master key, as a small 4-dimension test collection:
curl -sS -X PUT https://qdrant.example.com/collections/jwt_demo \
-H "api-key: $API_KEY" -H "Content-Type: application/json" \
-d '{"vectors":{"size":4,"distance":"Cosine"}}'
# {"result":true,"status":"ok","time":2.976137414}
Skip that step and every check further down returns 404 with Not found: Collection `jwt_demo` doesn't exist!, including the write that should have been refused, which makes a working setup look broken. Mint the tokens with Python’s jwt module. Ubuntu packages it as python3-jwt, and the 24.04 cloud images already include it. Using the package avoids the error: externally-managed-environment wall a plain pip install pyjwt hits on 24.04. The key file is readable by root only, so run Python with sudo and keep the tokens in shell variables for the tests:
sudo apt install -y python3-jwt
RO_TOKEN=$(sudo python3 -c 'import jwt; k=open("/etc/qdrant/api-key.secret").read().strip(); print(jwt.encode({"access":[{"collection":"jwt_demo","access":"r"}]}, k, algorithm="HS256"))')
RW_TOKEN=$(sudo python3 -c 'import jwt; k=open("/etc/qdrant/api-key.secret").read().strip(); print(jwt.encode({"access":[{"collection":"jwt_demo","access":"rw"}]}, k, algorithm="HS256"))')
echo "RO: ${RO_TOKEN:0:24}... RW: ${RW_TOKEN:0:24}..."
Use the token in the same api-key header as the master key. Qdrant decodes it, validates the signature, and applies the scope. Verifying enforcement matters more than the token format itself:
# Read with the RO token (must succeed)
curl -sS -o /dev/null -w 'GET (RO) HTTP=%{http_code}\n' \
https://qdrant.example.com/collections/jwt_demo \
-H "api-key: $RO_TOKEN"
# GET (RO) HTTP=200
# Write with the RO token (must be blocked)
curl -sS -o /dev/null -w 'PUT (RO) HTTP=%{http_code}\n' \
-X PUT https://qdrant.example.com/collections/jwt_demo/points \
-H "api-key: $RO_TOKEN" -H "Content-Type: application/json" \
-d '{"points":[{"id":1,"vector":[0.1,0.2,0.3,0.4]}]}'
# PUT (RO) HTTP=403
# Write with the RW token (must succeed)
curl -sS -o /dev/null -w 'PUT (RW) HTTP=%{http_code}\n' \
-X PUT "https://qdrant.example.com/collections/jwt_demo/points?wait=true" \
-H "api-key: $RW_TOKEN" -H "Content-Type: application/json" \
-d '{"points":[{"id":1,"vector":[0.1,0.2,0.3,0.4]}]}'
# PUT (RW) HTTP=200
A 403 on writes with the RO token is the proof that JWT-RBAC is wired up, and its body says exactly why: Forbidden: Write access to collection jwt_demo is required. The scope holds in the other directions too. Either token gets Forbidden: Access to collection other_col is required on any other collection, even the RW token can’t create or drop collections (Forbidden: Global access is required), and listing collections with a scoped token returns only jwt_demo. The same rules apply over gRPC, where the refused write comes back as StatusCode.PERMISSION_DENIED.
Add an exp claim with a Unix timestamp for short-lived tokens (CI jobs, scheduled scrapers). An expired token gets ExpiredSignature with HTTP 403, after a 30-second leeway. Rotating the master key invalidates every outstanding token at once: after we swapped the key and recreated the container, the old key returned 401 and a token signed with it returned InvalidSignature. If you can’t afford that cut-over, Qdrant’s alt_api_key setting accepts an old and a new key side by side during a rotation, although tokens signed with the old key still have to be reissued.
One trap in the claim format deserves a test of its own. A token with no access claim at all, for example one that only carries {"sub": "ci-bot"}, is treated as full manage access. We minted one to check, and it deleted a collection. Qdrant’s documentation states it plainly (“If this claim is not present, manage access is assumed”), so every token you mint should carry an explicit access claim.
Rate limiting with limit_req
Nginx’s limit_req_zone applies a leaky-bucket counter per source IP. The config above sets 5 r/s with a burst of 20: a client gets 21 requests through at once (the one the rate allows plus the burst of 20), then the bucket refills at 5/s. Once the bucket and burst are both empty, Nginx returns HTTP 429 immediately (because nodelay is set; without it, requests are queued).
Hit it 30 times in a tight loop to see the cap in action:
for i in $(seq 1 30); do
curl -sS -o /dev/null -w '%{http_code} ' \
https://qdrant.example.com/collections \
-H "api-key: $API_KEY"
done
echo
# 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
# 200 200 200 200 200 429 429 429 200 429
In the October run the first 25 succeeded: the 21 that the burst allows plus about four more as tokens refilled at 5/s while the loop ran. Requests 26 to 28 hit 429, the 29th landed after one more token refilled, and the 30th was refused again. The exact count moves with your round-trip time, so a slower link lets more through. Each refusal leaves a line like limiting requests, excess: 20.045 by zone "qdrant_rl" in /var/log/nginx/error.log, and a rate-limited gRPC client sees StatusCode.UNAVAILABLE with Received http2 header with status: 429. The capture below is from the May run, which used a small Python script for the token checks and let 27 requests through before the first 429. Tune the rate per environment: 5 r/s is reasonable for a dashboard, 50 r/s for a service mesh, and the burst should be roughly the largest concurrent-request count you expect from a legitimate caller.

Rate limiting is a coarse last line of defense, not a substitute for auth. A patient attacker with one valid API key can still drain the cluster, the cap just throttles them to a noticeable rate that monitoring picks up. Pair the limit with log alerts on 429 spikes from a single IP.
Cloud firewall, not just Nginx
Nginx terminates TLS but the cloud firewall is what stops port scans from ever reaching the host. Configure the inbound rules on the cloud’s network layer (AWS security group, GCP firewall rule, OCI security list) to allow 80, 443 and 6334 from the world and SSH on 22 only from your admin network. In this AWS example, 203.0.113.0/24 stands in for your office or VPN range:
# Allow inbound to security group cfg-prod-qdrant-sg
aws ec2 authorize-security-group-ingress \
--group-id sg-0a1b2c3d4e5f67890 --protocol tcp \
--port 22 --cidr 203.0.113.0/24 # SSH from admins only
for PORT in 80 443 6334; do
aws ec2 authorize-security-group-ingress \
--group-id sg-0a1b2c3d4e5f67890 --protocol tcp \
--port $PORT --cidr 0.0.0.0/0
done
Adding the admin rule doesn’t remove the 0.0.0.0/0 SSH rule most launch wizards create. Confirm your current address is inside the admin range, keep your session open, and then revoke the open rule:
aws ec2 revoke-security-group-ingress \
--group-id sg-0a1b2c3d4e5f67890 --protocol tcp \
--port 22 --cidr 0.0.0.0/0
Open a second SSH session before you close the first, so a typo in the range can’t lock you out. With the open rule gone, the SSH brute-force noise in your logs drops sharply. Closing 6333 at the cloud layer makes the loopback-bind double-safe: even if Docker’s port-publish was misconfigured, the firewall would not let traffic reach it.
Gotchas worth remembering
Five traps are worth knowing, and the first four came up again on the October re-run. Each one is silent enough to ship with a broken security posture if you skip the verification steps above:
- Docker port-publish on
127.0.0.1only is the actual loopback bind. Writing-p 6333:6333publishes on every interface. The correct form is-p 127.0.0.1:6333:6333. Verify withss -tlnp; the listener must show127.0.0.1:6333, not0.0.0.0:6333. - Nginx and Qdrant cannot both own 6334. A wildcard bind on
0.0.0.0:6334conflicts with any existing listener on that port, including one on127.0.0.1. If Qdrant publishes 6334 on loopback and Nginx tries to listen on the same port on all interfaces, the Nginx reload fails withbind() to 0.0.0.0:6334 failed (98: Address already in use). The reload still exits 0 and keeps the old config, so check the error log. Move Qdrant’s loopback gRPC to a different port (16334 in this guide). grpc_passneeds HTTP/2 end-to-end,proxy_passdoes not. The gRPC server block must saylisten 6334 ssl http2;AND usegrpc_pass. Withproxy_passinstead, the client getsStatusCode.UNKNOWNandReceived http2 header with status: 0, and the error log saysupstream sent no valid HTTP/1.0 header. Drophttp2from the listener and the TLS handshake itself fails withTLSV1_ALERT_NO_APPLICATION_PROTOCOL, which looks like a certificate problem but isn’t.- JWT-RBAC needs the env flag plus the JWT. Setting
QDRANT__SERVICE__JWT_RBAC=truewithout minting any tokens leaves the master api-key as the only working credential, which works but defeats the purpose. Mint scoped tokens for every non-admin caller, give each one an explicitaccessclaim (a token without one gets manage rights), and rotate them. - Certbot’s HTTP-01 needs port 80 open at the cloud firewall. A host that only allows 443 cannot complete the challenge. Either temporarily open 80 for the issuance window or switch to DNS-01 (per the table above). Renewals go through the same path about every 60 days, since certbot renews 30 days before expiry; if you closed 80 after the first issuance, renewals fail silently until a monitoring alert catches it.
Renewal and observability
The certbot.timer systemd unit runs the renewal twice daily. Verify it is active and dry-run a renewal to make sure the challenge still works (HTTP-01 reaching your host, or the DNS token for DNS-01):
systemctl status certbot.timer | head -4
sudo certbot renew --dry-run
For observability, tail the Nginx access log for 401 and 429 spikes:
sudo tail -F /var/log/nginx/access.log \
| awk '$9 == 401 || $9 == 429 {print strftime("%H:%M:%S"), $1, $7, $9}'
A spike of 401s from one IP is a brute-force attempt on the api-key. A spike of 429s is a misbehaving client (sometimes your own). Pipe both into your existing log aggregator and alert on a sustained rate above a small threshold. This filter only sees REST, though. A gRPC call with a bad key is logged by Nginx as status 200, because gRPC carries its real status in a trailer after the response headers, so brute force against port 6334 never shows up as a 401. For gRPC, Qdrant’s own audit log is the better source. It is enabled with QDRANT__AUDIT__ENABLED=true and QDRANT__AUDIT__TRUST_FORWARDED_HEADERS=true, and both Nginx locations should then set the forwarded headers from the real connection: grpc_set_header X-Forwarded-For $remote_addr; and grpc_set_header Forwarded ""; in the gRPC block, with the matching proxy_set_header lines in the REST block. Qdrant reads Forwarded first, then X-Forwarded-For, and ignores the X-Real-IP header the config above sets. Setting the values rather than appending to them matters, because Nginx otherwise passes the client’s own headers through and anyone could write a fake address into the audit log. Without it, gRPC entries record the Docker bridge address instead of the client. We did not test audit logging on this build.
The stack we built holds up against the realistic attacks on a self-hosted vector database: cleartext snooping, key replay, unauthenticated dumps, scope-creep on credentials, and brute-force abuse. None of those layers is a security panacea by itself, and you should re-verify them every time you change the deployment. Run the curl checks from the verification section after every release as a regression test for the security posture, not just the functional one. If you also terminate other services with Let’s Encrypt, our Let’s Encrypt walkthrough covers the renewal cron and DNS challenge variants that complement what we did here.