The email from our client’s data protection officer was three lines long, very polite, and basically ended our GPT-4o mini era.
The client, a Hamburg logistics company, pushes about 1,800 supplier invoices a month through n8n: extraction, classification, and a handful of stubborn edge cases we’d been routing to GPT-4o. Their October OpenAI bill came to €212. Not catastrophic. But the DPO’s question stuck with me anyway: did supplier names, IBANs, and payment terms really need to cross the Atlantic for work this boring?
Quick context in case you’re new here. I’m Damian, and building automation pipelines for small and mid-sized businesses is pretty much my whole job. We run nearly everything on n8n (if you’re still picking a platform, our detailed comparison of Zapier, Make, and n8n covers the tradeoffs), so the question got concrete fast: could the same workflows point at a self-hosted Qwen 2.5 model instead of a US API, for under fifty dollars a month?
Why we stopped reaching for the API first
Honest opinion before anything else: GPT-4o mini is cheap. If token cost at low volume is your only problem, stay with the API. Our reasons for looking elsewhere were three, and cost was honestly the weakest of them.
- Data residency. German invoices are full of personal and financial data, and once a US provider enters the chain you’re into standard contractual clauses, DPAs, and a DPO with uncomfortable questions. A model on a German server ends that conversation.
- Predictability. We bill fixed monthly retainers. A metered API bill is a business risk; a flat thirty-something euro server is a line item.
- Batch jobs don’t care about latency. The invoice pipeline runs against a queue at night. A model that takes forty seconds per document is fine when nobody’s waiting.
I’ll also admit I’d tried self-hosting before and failed. A year earlier I squeezed a 7B model onto my old tower with its aging GTX 1060, watched it grind out single-digit tokens per second with the fans screaming, and concluded this was hobbyist territory. Turns out I’d just picked the wrong hardware. Dedicated server cores with fast RAM are a different story entirely, which I only learned because that three-line email made me look again.
And Qwen 2.5 specifically isn’t a fringe bet. Alibaba runs these models in production at a scale most Western labs can only dream about, the weights are open, and the 14B instruct version handles German better than plenty of models twice its size. That was the hypothesis, anyway. So we tested it.
The $47/month Hetzner setup
Specs, and why two small boxes beat one big one
Our first instinct was one big server. Wrong instinct. The model wants RAM; n8n wants a database. Co-locating them means every extraction makes the workflow engine stutter, and a 14B model plus Postgres on 16GB is a tight squeeze. So we split the work:
- Model box: Hetzner CCX23, 4 dedicated vCPU, 16GB RAM, €24.29. Runs Ollama with
qwen2.5:14b-instruct-q4_K_M, a roughly 9GB quantization that leaves headroom for context. - Workflow box: Hetzner CX22, 2 vCPU, 4GB RAM, €3.79. Runs n8n and Postgres.
- Backups: a small Storage Box, €3.81.
That’s about €32 net, €38 with VAT, which lands anywhere from $44 to $47 depending on the exchange rate and the week the dollar is having. The dollar could still make a liar out of me, but not by much.
The docker-compose files
On the model box, the compose file is almost embarrassingly short:
services:
ollama:
image: ollama/ollama
ports:
- 11434:11434
volumes:
- ollama:/root/.ollama
restart: unless-stopped
volumes:
ollama:Then pull the model once and you’re done: docker compose exec ollama ollama pull qwen2.5:14b-instruct-q4_K_M.
The workflow box is the standard n8n-plus-Postgres setup, trimmed to the parts that matter:
services:
n8n:
image: n8nio/n8n:latest
ports:
- 5678:5678
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_PASSWORD=${PG_PASSWORD}
volumes:
- n8n:/home/node/.n8n
depends_on:
- postgres
postgres:
image: postgres:16
environment:
- POSTGRES_PASSWORD=${PG_PASSWORD}
volumes:
- pg:/var/lib/postgresql/data
volumes:
n8n:
pg:Pointing the n8n HTTP node at the model
Inside n8n it’s a single HTTP Request node. Method POST, URL http://YOUR_MODEL_BOX_IP:11434/api/chat, JSON body:
{
"model": "qwen2.5:14b-instruct-q4_K_M",
"messages": [
{ "role": "system", "content": "You extract structured invoice data. Output JSON only." },
{ "role": "user", "content": "={{ $json.documentText }}" }
],
"format": "json",
"stream": false,
"options": { "temperature": 0, "num_ctx": 8192 }
}Three settings matter more than they look. format: json forces Ollama to return valid JSON, which quietly kills a whole category of parsing errors. temperature: 0, because we want the boring answer, not a creative one. And num_ctx deserves a warning: Ollama’s default context is small, a dense German invoice with a 30-row line-item table blows right past it, and the model then silently truncates the middle of the document and extracts from an invoice it can only half see. That one cost me a weekend. Learn it from me instead.
Set the node timeout generously (we use 120 seconds) and switch on retry-on-fail, because a CPU-bound model has slow days.
Benchmarking Qwen 2.5 against GPT-4o mini on 200 German invoices
We pulled 200 invoices from the client’s archive, fed both models the identical extracted text, and checked five fields per document: Rechnungsnummer, Rechnungsdatum, Lieferant, IBAN, and Bruttobetrag. That’s 1,000 field checks, exact matches only, no partial credit.
| Field | GPT-4o mini | Qwen 2.5 14B, first pass | Qwen 2.5 14B, after prompt fix |
|---|---|---|---|
| Rechnungsnummer | 198/200 | 195/200 | 197/200 |
| Rechnungsdatum | 197/200 | 191/200 | 197/200 |
| Lieferant | 195/200 | 191/200 | 194/200 |
| IBAN | 199/200 | 197/200 | 198/200 |
| Bruttobetrag | 187/200 | 164/200 | 185/200 |
| Overall | 97.6% | 93.8% | 97.1% |
The Bruttobetrag row is basically the whole story of this post.
Speed, for completeness: GPT-4o mini answered in a median of 1.3 seconds per invoice. Qwen on the CCX23 took a median of 41 seconds, so the full 200-invoice batch runs about two and a half hours. For a 3am queue job that’s irrelevant. For anything interactive, it’s disqualifying.
I want to be honest about the order things happened in. That 93.8% first-pass score, with amounts sitting at 82%, landed on a Thursday, and for about two days I was ready to scrap the whole experiment. I’d been the one telling the client self-hosting was worth a shot, and the first hard data said otherwise. I let it sit over the weekend without touching anything, which is my version of not panicking. On Monday I stopped staring at the aggregate and started reading individual failures. That’s when the picture changed.
The four failure modes (and the prompt fix that solved three of them)
Read one by one, the failures fell into four patterns:
1. The decimal comma massacre. German amounts look like 1.234,56. Qwen sometimes echoed that format straight into JSON, sometimes half-converted it, so 1.234,56 came out as 1234.56 on one invoice and 1.234 on the next, and our downstream float parsing did whatever it felt like. Twenty-one of the thirty-six amount errors were exactly this.
2. Day/month flips. Asked for ISO dates without instruction, Qwen occasionally read 05.06.2025 as June 5th, the American interpretation. Five invoices came back with day and month swapped.
3. The phantom IBAN. On scanned invoices where the IBAN was illegible, Qwen sometimes produced a plausible-looking one anyway. My least favorite moment of the whole project: I caught one on a landscaping supplier’s invoice, ran the same PDF through GPT-4o mini out of spite, and got an equally hallucinated IBAN back. I felt marginally better and significantly more paranoid.
4. Murdered umlauts. Most Lieferant errors were Muller instead of Müller, Grunwald instead of Grünwald. Prompt changes did nothing, which was the clue. The culprit was our OCR layer, Tesseract running with the default English language pack, which was my mistake to create and my weekend to fix. German language pack, problem gone. I’d spent two days blaming an AI model for a config flag.
The fix for the first three was the most boring prompt I’ve ever written. No persona, no few-shot magic, just rules and one example:
You extract data from German invoices.
- Input dates use the German format DD.MM.YYYY. Output dates as YYYY-MM-DD.
- Output amounts as plain numbers: period as decimal separator, no
thousand separators. Example: 1.234,56 becomes 1234.56.
- If a field is not visible in the document, output null. Never guess,
never infer, never complete patterns.
Return JSON with this schema: {invoice_number, invoice_date, supplier, iban, gross_amount}That took the overall score from 93.8% to 97.1% and put amounts within two invoices of GPT-4o mini. Which supports a point I keep making to clients: most “model failures” in production are specification failures. The model was doing something defensible with ambiguous instructions. Prompts fix that right up until they don’t, which is why you still want the layer after the prompt.
Our human-in-the-loop rule before Qwen touches the CRM
Our rule, stated plainly because I think every team self-hosting a model should copy it: no self-hosted model writes to the client’s CRM directly. Qwen fills a draft record, then a chain of dumb validators decides what happens to it.
- IBAN checksum (the mod-97 test, about five lines of code)
- VAT ID format check (DE plus nine digits)
- Date sanity (not in the future, inside the supplier’s usual date range)
- Arithmetic: Bruttobetrag equals Nettobetrag plus USt, within one cent
- Null check: any required field null means review, no exceptions
Pass everything and the workflow writes to Pipedrive on its own. Fail anything, and the invoice lands in a Google Sheets review queue with the extraction sitting next to it, and a human approves or corrects with one click. About 11% of invoices take the review route right now, and clearing them costs the client’s office manager roughly fifteen minutes a day.
Is 11% annoying? A bit. But it’s the difference between trusting a model and verifying one, and after the phantom IBAN I’m not going back to the first option. This is the same validation-first mindset behind everything we build, written up properly in our workflow reliability guide. The model is a component. The pipeline is the product.
When a Qwen n8n workflow makes sense (and when it doesn’t)
Depends on the job, honestly. If you need sub-second responses, or you process fifty documents a month, use an API and don’t make your life harder. If you’ve got high-volume batch jobs, EU data residency requirements, or fixed-price contracts where a metered bill is a business risk, this stack is genuinely production-ready. We’re three months in: zero incidents, one scare that turned out to be a full disk caused by n8n execution logs.
Newer Qwen versions exist by now, sure. That’s kind of the point of open weights: when a better one drops, we pull it and rerun the benchmark, and the pipeline doesn’t care which model answers.
One caveat worth stating: this is deliberately not an agentic setup. It’s one extraction model inside a deterministic workflow, and for document work I think that’s the right shape. If you’re eyeing the other end of the spectrum, how multi-agent systems actually work is worth reading before anything autonomous gets near a CRM. And if you’re still mapping where AI fits in a smaller business at all, our no-hype AI automation playbook for SMBs is a better starting point than any server provisioning.
The client’s OpenAI bill is down from €212 to about €19, the server is a flat $47, the DPO is happy, and I finally have numbers to point at when someone asks why we don’t just use ChatGPT for everything. Good enough for me.
More from the server room soon,
Damian
