Introduction
If you've built more than one automation workflow in the last two years, you've probably hit the same wall I did: the moment an LLM enters the picture, your monthly bill starts climbing, and your data starts leaving your infrastructure. Every webhook payload, every customer email, every internal document you send to a third-party API is now sitting on someone else's servers, subject to someone else's retention policy.
For a lot of use cases — internal tooling, customer support triage, document classification, lead enrichment — that trade-off simply isn't necessary anymore. Open-weight models have gotten good enough, and local inference tooling has matured to the point where you can run a genuinely useful automation pipeline on a single VPS or even a beefy home server, for the price of the electricity it consumes.
This guide walks through building exactly that: a self-hosted, zero-recurring-cost, privacy-first AI automation stack using three components:
- n8n — the workflow orchestration engine that listens for events, calls services, and moves data around.
- Ollama — a local inference server that runs quantized Llama-family (and other open-weight) models without needing a GPU cluster.
- Docker Compose — the glue that wires everything together into a reproducible, portable stack.
By the end, you'll have a working pipeline that accepts inbound webhooks, feeds unstructured text into a locally hosted model, extracts structured JSON from the response, and writes clean rows into PostgreSQL — all without a single byte of your data touching an external API.
This is not a "hello world" tutorial. We're going to cover Docker networking internals, volume persistence strategy, prompt engineering for reliable JSON extraction, and the operational mistakes that will bite you in production if you don't plan for them.
Why Self-Host an AI Pipeline in 2026?
Before diving into configuration, it's worth being explicit about the trade-offs, because self-hosting isn't free of cost — it just moves the cost from a recurring API bill to upfront infrastructure and maintenance effort.
Reasons to self-host:
- Data sovereignty. Sensitive data (PII, contracts, internal support tickets, health-adjacent text) never leaves your network boundary.
- Predictable costs. A $20/month VPS or a repurposed desktop replaces a token-metered bill that scales unpredictably with usage.
- No rate limits imposed by a third party. You're bound only by your own hardware.
- Offline resilience. The pipeline keeps running even if your internet connection drops, which matters for air-gapped or edge deployments.
- Model control. You choose exactly which model version runs, and it doesn't silently change underneath you.
Reasons this isn't always the right call:
- Local models (especially the CPU-friendly ones) are meaningfully behind frontier hosted models on complex reasoning tasks.
- You are now responsible for uptime, patching, and scaling — there's no vendor SLA.
- Throughput on CPU-only hardware is modest; this stack is best suited to moderate-volume automation, not high-throughput production inference.
For structured extraction tasks — which is what most automation pipelines actually need — a well-prompted 3B–8B parameter local model is often more than sufficient. That's the sweet spot this guide targets.
Architecture Overview
Here's the mental model before we touch any YAML:
- An external system (a CRM, a form service, an email parser, anything) sends a webhook to n8n.
- n8n's Webhook node receives the payload and passes it into the workflow.
- n8n makes an internal HTTP call to Ollama's REST API, sending the unstructured text along with a schema-constrained prompt.
- Ollama runs inference locally using a quantized Llama-based model and returns a JSON-formatted response.
- n8n validates and transforms that JSON.
- n8n writes the structured result into PostgreSQL using a parameterized insert.
All of this happens inside a single Docker Compose stack, on an isolated internal network, with only n8n's webhook port exposed to the outside world.
Internet ──▶ [n8n:5678] ──▶ [ollama:11434] (internal only)
│
└────────▶ [postgres:5432] (internal only)
That single exposed port is the entire attack surface. Everything else stays inside the Docker bridge network, invisible from outside the host.
Prerequisites
- A Linux host (Ubuntu 24.04 LTS or similar) — a VPS with at least 4 vCPUs / 8GB RAM, or a local machine.
- Docker Engine 27+ and Docker Compose v2 (the
docker composeplugin, not the legacydocker-composebinary). - Basic familiarity with YAML and REST APIs.
- Optional but recommended: a domain name and reverse proxy (Caddy or Traefik) if you plan to expose the webhook endpoint publicly with TLS.
Check your Docker version before proceeding:
docker --version
docker compose version
Step 1: Designing the docker-compose.yml Stack
Let's build the stack piece by piece, then assemble the full file.
The Network
Docker Compose creates a default network automatically, but for clarity and future extensibility, we'll define an explicit bridge network. This lets every service resolve the others by container name — n8n can reach Ollama at http://ollama:11434 without any manual DNS or /etc/hosts editing.
networks:
ai-automation-net:
driver: bridge
The Volumes
Persistence is the single most important thing to get right here. Ollama models are multi-gigabyte files — Llama 3.1 8B quantized to Q4_K_M is roughly 4.7GB. If you don't mount a persistent volume, every container restart triggers a full re-download.
volumes:
ollama_data:
driver: local
n8n_data:
driver: local
postgres_data:
driver: local
The Ollama Service
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
networks:
- ai-automation-net
# Uncomment below if running on a machine with an NVIDIA GPU
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
healthcheck:
test: ["CMD-SHELL", "ollama list || exit 1"]
interval: 30s
timeout: 10s
retries: 5
Note that we intentionally do not publish port 11434 to the host. Ollama should only be reachable from within the Docker network by other containers — there's no reason to expose your local inference server to the public internet.
The PostgreSQL Service
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: n8n_admin
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: automation_data
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- ai-automation-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n_admin -d automation_data"]
interval: 10s
timeout: 5s
retries: 5
Notice the ${POSTGRES_PASSWORD} — we never hardcode secrets directly in the compose file. That value comes from a .env file sitting next to docker-compose.yml, which you should add to .gitignore immediately.
The n8n Service
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
ports:
- "5678:5678"
environment:
- N8N_HOST=${N8N_HOST}
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${N8N_HOST}/
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=automation_data
- DB_POSTGRESDB_USER=n8n_admin
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER}
- N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD}
- GENERIC_TIMEZONE=UTC
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
ollama:
condition: service_healthy
networks:
- ai-automation-net
A few details worth calling out:
depends_onwithcondition: service_healthyensures n8n doesn't boot up and try to connect to Postgres or Ollama before they're actually ready — a common source of flaky first-run failures.N8N_ENCRYPTION_KEYmust be a stable, random string. If you lose or change it, n8n can no longer decrypt stored credentials.- We're using n8n's own Postgres database (
automation_data) both for n8n's internal state and as the destination for our pipeline's structured output, via a separate table. That's a pragmatic choice for a single-VPS setup — feel free to split into two Postgres instances if you want stricter isolation.
Full docker-compose.yml
Putting it all together:
version: "3.9"
networks:
ai-automation-net:
driver: bridge
volumes:
ollama_data:
n8n_data:
postgres_data:
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
networks:
- ai-automation-net
healthcheck:
test: ["CMD-SHELL", "ollama list || exit 1"]
interval: 30s
timeout: 10s
retries: 5
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: n8n_admin
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: automation_data
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- ai-automation-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n_admin -d automation_data"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
ports:
- "5678:5678"
environment:
- N8N_HOST=${N8N_HOST}
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${N8N_HOST}/
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=automation_data
- DB_POSTGRESDB_USER=n8n_admin
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER}
- N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD}
- GENERIC_TIMEZONE=UTC
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
ollama:
condition: service_healthy
networks:
- ai-automation-net
And the accompanying .env file:
N8N_HOST=automation.yourdomain.com
N8N_ENCRYPTION_KEY=replace_with_a_long_random_string
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=replace_with_a_strong_password
POSTGRES_PASSWORD=replace_with_a_strong_password
Bring the stack up:
docker compose up -d
docker compose ps
Step 2: Pulling and Testing a Local Model
Once the containers are healthy, pull a model into the Ollama container. For CPU-only VPS deployments, smaller models perform far better in terms of latency:
docker exec -it ollama ollama pull llama3.2:3b
If you're running on hardware with a GPU (or a beefier machine with 16GB+ RAM), Llama 3.1 8B gives noticeably better extraction accuracy:
docker exec -it ollama ollama pull llama3.1:8b
Test that inference works before wiring up n8n:
docker exec -it ollama ollama run llama3.2:3b "Summarize: The customer reported their invoice was billed twice."
You can also hit the REST API directly to confirm the network path n8n will use:
docker exec -it n8n wget -qO- http://ollama:11434/api/tags
If that returns a JSON list of installed models, your internal Docker DNS and networking are working correctly.
Step 3: Building the n8n Workflow
Now for the actual automation. We'll build a workflow that:
- Receives a webhook containing raw, unstructured text (e.g., a support ticket or contact form submission).
- Sends it to Ollama with a strict extraction prompt.
- Parses the model's JSON response.
- Inserts the result into PostgreSQL.
Node 1: Webhook Trigger
In n8n, add a Webhook node:
- HTTP Method:
POST - Path:
intake - Response Mode:
Last Node
This gives you an endpoint like https://automation.yourdomain.com/webhook/intake, ready to accept payloads such as:
{
"source": "contact_form",
"raw_text": "Hi, I've been charged twice for my March invoice and I'm pretty frustrated. Can someone fix this today? - Sarah T."
}
Node 2: HTTP Request to Ollama
Add an HTTP Request node configured as follows:
- Method:
POST - URL:
http://ollama:11434/api/generate - Body Content Type:
JSON
Body:
{
"model": "llama3.2:3b",
"prompt": "Extract structured data from the following customer message. Respond ONLY with valid JSON matching this exact schema: {\"customer_name\": string, \"category\": string (one of: billing, technical, general), \"sentiment\": string (one of: positive, neutral, negative), \"priority\": string (one of: low, medium, high), \"summary\": string}. Message: {{$json.raw_text}}",
"format": "json",
"stream": false,
"options": {
"temperature": 0.1
}
}
Two details matter enormously here:
"format": "json"forces Ollama to constrain its output to valid JSON, which drastically reduces parsing failures compared to hoping the model "behaves."- Low temperature (
0.1) makes extraction tasks far more deterministic. You want consistency here, not creativity.
Node 3: Parse and Validate
Add a Code node (JavaScript) to parse the model's response and guard against malformed output:
const raw = $input.first().json.response;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`Model returned invalid JSON: ${raw}`);
}
const allowedCategories = ["billing", "technical", "general"];
const allowedPriorities = ["low", "medium", "high"];
if (!allowedCategories.includes(parsed.category)) {
parsed.category = "general";
}
if (!allowedPriorities.includes(parsed.priority)) {
parsed.priority = "medium";
}
return [{ json: parsed }];
This validation step is essential. Local models, especially smaller ones, will occasionally drift outside your schema — defensive parsing keeps a single malformed response from crashing the entire workflow.
Node 4: Insert into PostgreSQL
First, create the destination table (run this once via psql or a database client):
CREATE TABLE IF NOT EXISTS support_tickets (
id SERIAL PRIMARY KEY,
customer_name TEXT,
category TEXT NOT NULL,
sentiment TEXT,
priority TEXT NOT NULL,
summary TEXT,
raw_text TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
Then add a Postgres node in n8n, using the built-in Insert operation (which parameterizes values automatically, avoiding SQL injection):
- Operation:
Insert - Table:
support_tickets - Columns mapped from:
customer_name,category,sentiment,priority,summary
n8n's native Postgres node handles parameter binding under the hood — avoid building raw SQL strings by concatenating LLM output directly, even though it's tempting for quick prototypes.
The Result
Send a test payload:
curl -X POST https://automation.yourdomain.com/webhook/intake \
-H "Content-Type: application/json" \
-d '{"raw_text": "Hi, I was charged twice for my March invoice. Please fix this today. - Sarah T."}'
Within a few seconds (depending on hardware), you should see a new row in support_tickets with a category of billing, a negative sentiment, and a high priority — extracted entirely by a model running on your own hardware, with zero API calls leaving the machine.
Real-World Use Cases
This same pattern generalizes well beyond support tickets:
- Lead qualification: Extract company size, intent, and urgency from inbound contact form submissions.
- Document triage: Classify uploaded PDFs or scanned text by department before routing to the right team.
- Log anomaly summarization: Feed error logs into a local model to generate human-readable incident summaries without sending logs to a third party.
- Internal knowledge tagging: Auto-tag internal wiki pages or tickets with topics for better searchability.
- Email triage for regulated industries: Healthcare or legal teams that can't legally send data to external APIs can still get LLM-assisted categorization.
🚀 Pro Tips
- Use
keep_aliveto avoid cold-start latency. By default, Ollama unloads a model from memory after 5 minutes of inactivity. Pass"keep_alive": "30m"in your API request body to keep it resident in RAM for bursty webhook traffic. - Pin image versions in production. Replace
:latesttags with specific versions (e.g.,n8nio/n8n:1.70.0) once your stack is stable, so an upstream update doesn't silently break your workflow. - Put a reverse proxy in front of n8n. Use Caddy or Traefik to terminate TLS and handle automatic Let's Encrypt certificates — never expose n8n's raw HTTP port directly to the internet.
- Warm up the model on container start. Add a small startup script that runs a throwaway inference call right after
ollamabecomes healthy, so your first real webhook isn't the one eating the cold-start penalty. - Use n8n's Error Workflow feature. Configure a dedicated error-handling workflow to catch malformed JSON or Ollama timeouts and route them to a dead-letter table for manual review instead of silently dropping data.
- Benchmark quantization levels. Q4_K_M is a solid default, but if extraction accuracy matters more than speed, try Q6_K or Q8_0 variants — Ollama's model library lists available quantizations per model.
- Back up your volumes, not just your compose file.
n8n_dataholds your workflows and credentials;postgres_dataholds your structured output. Automate a nightlydocker run --rm -v n8n_data:/data ...backup to an offsite location.
Common Mistakes to Avoid
- Skipping the persistent volume for Ollama. Without
ollama_data:/root/.ollama, everydocker compose down && uptriggers a multi-gigabyte re-download. - Exposing Ollama's port publicly. Port
11434has no built-in authentication. Never map it withports:— keep it internal-network-only. - Trusting raw LLM output for SQL. Always use parameterized inserts (via n8n's native database nodes or prepared statements), never string-concatenated queries built from model output.
- Ignoring context window limits. Smaller models often have shorter effective context windows than advertised. Truncate or chunk very long input text before sending it to the prompt.
- Not validating JSON schema conformance. Assume the model will occasionally hallucinate a field name or invalid enum value, and code defensively around it, as shown in the parsing step above.
- Running everything on
latesttags in production. This is the single most common cause of "it worked yesterday" incidents in self-hosted stacks. - Forgetting
depends_onhealth conditions. Without them, n8n can boot before Postgres finishes initializing, causing intermittent startup failures that look like flaky bugs. - Underestimating CPU-only inference latency. If you're processing high volumes of webhooks in near real-time, benchmark actual throughput before committing to a CPU-only deployment — you may need a queue-based buffering pattern instead.
Best Practices Checklist
- ✅ Isolate Ollama and Postgres on an internal Docker network — expose only n8n's webhook port.
- ✅ Use named volumes for all three services and back them up regularly.
- ✅ Store secrets in a
.envfile excluded from version control. - ✅ Enable n8n's basic auth (or better, put it behind SSO via your reverse proxy) before exposing it publicly.
- ✅ Use
"format": "json"and low temperature for extraction-style prompts. - ✅ Validate and sanitize model output before it touches your database.
- ✅ Pin container image versions once your stack stabilizes.
- ✅ Monitor container health with
docker compose psand configurehealthcheckblocks for every service. - ✅ Document your prompt schema alongside your workflow so future you (or teammates) understands the contract the model is expected to follow.
Conclusion
Self-hosting an AI automation pipeline isn't just a cost-saving exercise — it's a fundamentally different trust model. When n8n, Ollama, and PostgreSQL all live inside a single Docker Compose stack on hardware you control, sensitive data never has to leave your perimeter, and your automation logic doesn't degrade the moment a third-party API changes its pricing or rate limits.
The pattern covered here — webhook in, local inference, structured extraction, database out — is deliberately general. Swap the prompt schema and the destination table, and the same skeleton handles lead scoring, document classification, log summarization, or dozens of other structured-extraction tasks. The heavy lifting is in getting the Docker networking, volume persistence, and JSON validation right once; after that, adding new workflows is mostly a matter of designing new prompts.
Start small: one webhook, one model, one table. Get the observability and error handling solid before you scale up to higher volumes or swap in a larger model. Once the foundation is reliable, this stack scales remarkably well for a setup that costs nothing beyond your existing infrastructure.
References
- n8n Documentation
- Ollama Official Documentation
- Ollama API Reference
- Docker Compose File Reference
- PostgreSQL Official Documentation
- Meta Llama Model Cards
📌 Key Takeaways
- Zero recurring API costs: Running Llama-family models locally via Ollama eliminates per-token billing entirely — your only cost is the hardware you already own or rent.
- Data never leaves your network: With Ollama and PostgreSQL isolated on an internal Docker bridge network, sensitive text is processed and stored without touching a third-party API.
- Persistence and networking are the real challenges: Getting
docker-compose.ymlright — named volumes, internal-only service exposure, and proper health-check-gated startup order — matters more than the AI logic itself. - JSON mode plus defensive parsing equals reliability: Constraining model output with
"format": "json", low temperature, and post-processing validation is what turns a flaky demo into a production-grade extraction pipeline. - This pattern scales horizontally across use cases: The same webhook-to-model-to-database skeleton adapts to support triage, lead scoring, document classification, and more — just change the prompt and the destination schema.