A self-hosted RAG stack means three services on one server: Ollama turns text into vectors, Qdrant stores and searches them, and n8n wires ingestion and question answering together. Your documents never leave the box unless you choose a hosted model for the final answer. That choice is the main decision in this post.
I validated the Compose file below with docker compose config, and I ran the ingest-and-search script on 15 real lessons from this site's repo. I used stand-in embeddings for that run, so the retrieval scores are not representative of a real model. I did not start Ollama, Qdrant or n8n here, so the workflow steps are labelled unrun. Vendor facts, including Qdrant's security page, were checked against the vendors' own pages on 2026-10-09.
For the concepts, read how to build a RAG chatbot over PDFs and chunking strategies compared first. This post is about running the pieces on a server.
What does the stack look like?
| Service | Job | Reachable from outside? |
|---|---|---|
| Caddy | HTTPS for the n8n editor and webhooks | Yes (80, 443) |
| n8n | Two workflows: ingest and ask | Only through Caddy |
| Postgres | n8n's database | No |
| Qdrant | Vector storage and search | No |
| Ollama | Embeddings, optionally chat | No |
Two flows run through it. Ingest: file or URL, then split into chunks, then embed with Ollama, then insert into Qdrant. Ask: webhook or chat message, then embed the question, search Qdrant, hand the top chunks and the question to a chat model, return the answer with sources.
What does the Docker Compose file look like?
This builds on the n8n on a VPS post, which explains the Caddy, webhook URL and encryption key choices in depth. The .env, with secrets from openssl rand -hex 32:
N8N_DOMAIN=n8n.yourdomain.com
N8N_VERSION=<pin a stable version>
QDRANT_VERSION=<pin a vX.Y.Z tag>
OLLAMA_VERSION=<pin from Docker Hub>
POSTGRES_PASSWORD=<openssl rand -hex 32>
QDRANT_API_KEY=<openssl rand -hex 32>
N8N_ENCRYPTION_KEY=<openssl rand -hex 32>
TZ_NAME=Asia/Kolkata
The Caddyfile is the same two lines as before:
{$N8N_DOMAIN} {
reverse_proxy n8n:5678
}
And compose.yaml:
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: n8n
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
timeout: 5s
retries: 10
qdrant:
image: qdrant/qdrant:${QDRANT_VERSION:?set QDRANT_VERSION in .env}
restart: unless-stopped
environment:
QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:?set QDRANT_API_KEY in .env}
volumes:
- qdrant_data:/qdrant/storage
ollama:
image: ollama/ollama:${OLLAMA_VERSION:?set OLLAMA_VERSION in .env}
restart: unless-stopped
environment:
OLLAMA_KEEP_ALIVE: 30m
OLLAMA_NUM_PARALLEL: 1
OLLAMA_MAX_LOADED_MODELS: 2
volumes:
- ollama:/root/.ollama
n8n:
image: n8nio/n8n:${N8N_VERSION:?set N8N_VERSION in .env}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
qdrant:
condition: service_started
ollama:
condition: service_started
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:?set N8N_ENCRYPTION_KEY in .env}
N8N_HOST: ${N8N_DOMAIN:?set N8N_DOMAIN in .env}
N8N_PORT: 5678
N8N_PROTOCOL: https
N8N_PROXY_HOPS: 1
N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/
N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}/
GENERIC_TIMEZONE: ${TZ_NAME:-Asia/Kolkata}
TZ: ${TZ_NAME:-Asia/Kolkata}
volumes:
- n8n_data:/home/node/.n8n
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- n8n
ports:
- "80:80"
- "443:443"
environment:
N8N_DOMAIN: ${N8N_DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
pg_data:
qdrant_data:
ollama:
n8n_data:
caddy_data:
caddy_config:
I ran docker compose config --quiet with a dummy .env: it exited 0 with no output. A short parser check confirmed caddy is the only service with a ports key. That's the point of the layout. The Qdrant README says plainly that docker run -p 6333:6333 qdrant/qdrant starts "an insecure deployment without authentication, open to all network interfaces," so this file never publishes 6333 and also sets an API key. Qdrant's security page says the key goes in the api-key request header (an Authorization: Bearer form also works), can be set with the QDRANT__SERVICE__API_KEY environment variable, and that sending it over an unencrypted channel is insecure. Qdrant also supports read-only keys. The same page names /qdrant/storage as the container's storage mount. Test that a restart keeps your data before you trust the volume with real documents.
Start it and pull the embedding model (unrun):
docker compose up -d
docker compose exec ollama ollama pull nomic-embed-text
Ollama's library page lists nomic-embed-text at 274 MB with a 2K context window. The n8n Ollama Embeddings node page lists it at 768 dimensions (and all-minilm at 384). Remember that 768, because your Qdrant collection must be created with exactly that size.
How big should chunks be, and what do they cost?
Small enough that the embedding model sees all of every chunk. The 2K context means about 2,000 tokens. Ollama's /api/embed has a truncate parameter that defaults to true, which silently cuts the end off over-long inputs. A chunk that gets truncated is a chunk whose tail you can't retrieve. So keep chunks well under the limit. I use about 900 characters with 150 characters of overlap, roughly 225 tokens by the usual four-characters-per-token rule of thumb.
I ran a chunker over the 15 intermediate lessons in this site's repo (content/learn/intermediate). Real output:
files=15 chunks=187
chunk chars min=300 median=810 max=2301
That's 12.5 chunks per file. The 2,301-character maximum shows my paragraph-boundary splitter lets a single long paragraph through unsplit, which is a bug you should handle in production (split on sentences when a paragraph is oversized). Raw storage for vectors, by script:
| Documents | Chunks (12 per doc) | Raw 768-dim float32 vectors |
|---|---|---|
| 100 | 1,200 | 3.5 MiB |
| 1,000 | 12,000 | 35.2 MiB |
| 10,000 | 120,000 | 351.6 MiB |
Each vector is 768 x 4 = 3,072 bytes. Qdrant adds index and payload overhead on top, and I did not measure it, so treat the table as a floor. The takeaway holds anyway: for a few thousand documents, the vector store is small. Memory pressure comes from the chat model.
How do you ingest documents? (the script I ran)
Before you click together n8n nodes, test the logic in a script. This one chunks files, embeds them, upserts into Qdrant and answers queries with the top three hits. The core, condensed from the file I ran:
def chunk(text, size=900, overlap=150):
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, cur = [], ""
for p in paras:
if cur and len(cur) + len(p) + 2 > size:
chunks.append(cur)
cur = cur[-overlap:] + "\n\n" + p
else:
cur = (cur + "\n\n" + p) if cur else p
if cur:
chunks.append(cur)
return chunks
def ollama_embed(texts, base="http://localhost:11434", model="nomic-embed-text"):
body = json.dumps({"model": model, "input": texts}).encode()
req = urllib.request.Request(base + "/api/embed", body, {"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
return json.load(r)["embeddings"]
The /api/embed call matches Ollama's API doc: model and input (a string or a list) go in, and a list of vectors comes back under embeddings. I ran everything except ollama_embed. In its place I used --stub, a hashed bag-of-words embedding of 256 dimensions, so the Qdrant plumbing (create collection, upsert, query, payloads) ran against qdrant-client's in-memory mode. Results:
embedded in 0.04s, dim=256
stored 187 points
Q: how do few-shot examples work
0.418 few-shot-prompting.mdx#0 '---\ntitle: "Few-Shot Prompting: Teaching AI by Example"\ndescription: "'
0.395 few-shot-prompting.mdx#3 'most always outperforms zero-shot for structured tasks.\n\n---\n\n## Why F'
0.347 few-shot-prompting.mdx#2 'sentiment as Positive, Negative, or Neutral.\n\nReview: "Fast shipping a'
Q: what is retrieval augmented generation
0.299 few-shot-prompting.mdx#4 'ing on few-shot prompting that most people get wrong:\n\n**The format co'
0.292 prompt-testing-and-evaluation.mdx#3 '. **Collect real inputs**: If your prompt is in production, sample act'
Two honest lessons from a toy run. First, the top hit for the few-shot question is chunk 0, which is YAML frontmatter, not content. Real ingestion should strip frontmatter, navigation and boilerplate before chunking, or you'll retrieve junk that happens to share words with the question. Second, the question "what is retrieval augmented generation" missed the RAG lesson completely with the stand-in embedding. That's expected from a word-hash trick and says nothing about nomic-embed-text, but it's a reminder: always read the top chunks for a handful of real questions before you trust the pipeline. With a real model, run the same script without --stub. I haven't, so I can't tell you its scores.
How do you build the n8n workflows?
Both are unrun, and node names are as I know them from n8n's AI nodes. Check the node panel in your version.
Ingest workflow. A trigger (manual, a folder watcher, or a schedule), a node that reads the file text, then the Qdrant Vector Store node in Insert Documents mode, with a document loader and a text splitter feeding it and the Ollama Embeddings sub-node set to nomic-embed-text. n8n's Qdrant node page lists four operation modes: Get Many, Insert Documents, Retrieve Documents as a vector store for a chain or tool, and Retrieve Documents as a tool for an AI agent. Add metadata (source filename, a document ID) so you can delete or replace one document later. Without that, re-running ingestion duplicates every chunk.
Ask workflow. A Webhook or chat trigger, an AI Agent or a retrieval chain, a chat model, and the Qdrant node in the retrieve-as-tool mode with the same embeddings sub-node. The Ollama credential's base URL is http://ollama:11434 inside this Compose network (the n8n credential page says the default is http://localhost:11434, which won't work from another container). The Qdrant credential takes the URL http://qdrant:6333 and your API key.
A starting system message, an untested template:
Answer using only the context chunks provided. Quote the source filename in brackets after each claim.
If the chunks do not contain the answer, say "I can't find that in the documents" and stop.
Do not use outside knowledge, even if you are sure.
Then a three-question test, which matters more than any config: one question whose answer is in a single chunk, one that needs two chunks, and one whose answer isn't in the documents at all. The third should produce the refusal. If it invents an answer, tighten the prompt and lower the number of retrieved chunks before changing anything else. To put one login and key system in front of the models, see the Open WebUI and LiteLLM gateway setup. For better retrieval later, see hybrid search with BM25 and embeddings and reranking.
Which chat model should answer?
This is where the stack either feels good or feels broken. Embeddings are cheap: a small model, short inputs. Generation on a CPU-only VPS is slow, and long retrieved context makes it slower, because the server must process the whole prompt before it writes a word (the maths is in the Ollama sizing guide).
| Setup | Documents leave the server? | Speed | Server size |
|---|---|---|---|
| Local embeddings, hosted chat model | Only the retrieved chunks and question | Fast | 8 GB is enough |
| Local embeddings, local 8B chat model | No | Slow on CPU | 16 GB |
| Hosted embeddings and chat | Everything | Fast | Smallest |
My pick for most readers is the first row: embed locally, send only the top few chunks to a hosted model. If the privacy requirement is that no text leaves the machine, take the second row and accept the speed, and keep context to three or four short chunks.
What goes wrong most often?
| Symptom | Cause | Fix |
|---|---|---|
| Qdrant rejects inserts with a dimension error | Collection size doesn't match the embedding model (768 for nomic-embed-text) | Recreate the collection with the right size |
| Answers worsen after changing embedding model | Old and new vectors mixed in one collection | New collection, re-embed everything |
| Same passage appears five times | Ingestion re-run without deduplication | Delete by document ID before re-inserting |
| Good chunks never retrieved | Chunk cut mid-thought, or truncated by the embedding model | Smaller chunks, sentence-aware splitting |
| n8n can't reach Ollama or Qdrant | Using localhost inside a container | Use service names on the Compose network |
| All data lost after rebuild | No volume or no backup | Named volumes plus Qdrant snapshots and a Postgres dump |
Back up three things: the Postgres database (n8n workflows), the N8N_ENCRYPTION_KEY, and the Qdrant data. Qdrant can also be rebuilt from your source documents, which is another reason to keep originals somewhere that isn't this server.
Which server should you rent?
For embeddings local and chat hosted, 2 vCPU and 8 GB RAM is a reasonable start by my arithmetic above, not a tested figure. Hostinger's KVM 2 (2 vCPU, 8 GB, 100 GB NVMe) was listed at $8.99 a month promotional and $14.99 renewal on their VPS page on 2026-10-09; the KVM 4 tier (4 vCPU, 16 GB, 200 GB) was $12.99 promotional and $28.99 renewal, and you can get KVM 4 here if you want a local 8B model too. Some links on this page are affiliate links; they cost you nothing and I only link tools I'd mention anyway. Hetzner, DigitalOcean and a spare home machine all work, and Qdrant Cloud or a managed vector store is the better choice if nobody wants to maintain a database. Before real documents go in, work through the VPS hardening checklist.



