You can run n8n on a small VPS in about half an hour: one compose.yaml with n8n, Postgres and Caddy, a domain pointed at the server, and Caddy fetches the HTTPS certificate on its own. The parts that bite are the ones the quick-start skips: the webhook URL behind a proxy, the encryption key, backups, and a webhook that anyone on the internet can call.
This post is the setup I'd use for a small self-hosted n8n AI agent server. I checked every setting against n8n, Docker and Caddy docs on 2026-10-08, and I validated the Compose file with docker compose config. I did not deploy it to a live VPS or run n8n in this session, so each "unrun" label below means exactly that.
If you're still choosing a tool, read n8n vs Make vs Zapier for AI workflows first. If you want a finished workflow to import after this, the n8n and Claude API tutorial builds a WhatsApp FAQ bot.
What do you need to self-host n8n?
Four things:
- A Linux VPS you can SSH into. I'll assume Ubuntu, one of the versions Docker's install page lists (24.04 LTS, 22.04 LTS and 26.04 LTS on the day I read it).
- A domain or subdomain with an A record pointing at the server's IP.
- Docker Engine and the Compose plugin (Docker's Ubuntu install page).
- Ports 80 and 443 reachable from the internet. Caddy needs both to get a certificate for a public domain.
On sizing, I can't cite an official n8n minimum. My own rule of thumb, unmeasured, is 2 vCPUs and 4 GB of RAM for n8n plus Postgres with a handful of light workflows, and more if you run heavy executions or large file nodes. The model itself isn't running on the box (the agent calls an API), so you don't need the RAM an Ollama setup does.
Need a server? Hostinger's KVM 2 is a reasonable fit: on hostinger.com/vps-hosting on 2026-10-09 it was listed at 2 vCPU, 8 GB RAM, 100 GB NVMe and 8 TB bandwidth, at $8.99 a month with a renewal price of $14.99 a month (the page says "for 2 years"; it doesn't state the initial term, so check checkout). Hostinger's page also lists weekly backups, manual snapshots and firewall management on every plan. You can get KVM 2 here. Some links on this page are affiliate links; they cost you nothing and I only link tools I'd mention anyway. Hetzner, DigitalOcean and Linode all work identically for everything below, and n8n Cloud is the better choice if you don't want to patch a server at all. My longer take on the provider is in the Hostinger VPS review.
Do the server basics first (SSH keys, firewall, a non-root user). The VPS hardening checklist for AI agent servers covers them, and the Docker firewall trap in that post affects this setup directly.
What does the Docker Compose file look like?
Create a folder, say ~/n8n, and put three files in it. First the .env. Generate the secrets on the server with openssl rand -hex 32, and do not commit this file anywhere.
N8N_DOMAIN=n8n.yourdomain.com
N8N_VERSION=<pin a stable version from the n8n docs or Docker Hub>
POSTGRES_PASSWORD=<openssl rand -hex 32>
N8N_ENCRYPTION_KEY=<openssl rand -hex 32>
TZ_NAME=Asia/Kolkata
Then Caddyfile. Caddy substitutes {$VAR} environment variables before it parses the file, per its Caddyfile docs.
{$N8N_DOMAIN} {
reverse_proxy n8n:5678
}
Then 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
n8n:
image: n8nio/n8n:${N8N_VERSION:?set N8N_VERSION in .env}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
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
# No "ports:" on purpose. Only Caddy is reachable from outside.
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:
n8n_data:
caddy_data:
caddy_config:
I ran docker compose config --quiet on this with a dummy .env: it parsed cleanly and expanded every variable. I also ran it with no .env and got the guard messages I wanted:
error while interpolating services.n8n.environment.N8N_ENCRYPTION_KEY: required variable N8N_ENCRYPTION_KEY is missing a value: set N8N_ENCRYPTION_KEY in .env
error while interpolating services.n8n.image: required variable N8N_VERSION is missing a value: set N8N_VERSION in .env
error while interpolating services.postgres.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env
Also a quick parser check showed caddy is the only service with a ports key. That matters more than it looks. It's the whole security model of the file.
A few choices worth explaining:
- No published port for n8n. n8n's own Docker Compose page maps
127.0.0.1:5678:5678and fronts it with Traefik. I put Caddy in the same Compose network instead, so n8n isn't published at all. Docker's docs say a published port without a host address binds to every address, and that published ports bypass ufw and firewalld rules, so "I'll just allow it in the firewall" doesn't protect you. - Postgres, not SQLite. SQLite is the default. The database docs list
postgresdbas the alternative, and theDB_POSTGRESDB_*names above come from that page. - Pinned version.
latestsurprises you at the worst time. When I read n8n's Docker page it listed a current stable of 2.42.5 and beta of 2.43.2 and calledstablethe production channel; look again before you pin, since that moves. - Task runners. n8n's official Postgres sample in the
n8n-hostingrepo adds a separaten8n-runnercontainer. I haven't run either layout. If your version asks for runner configuration, follow the n8n docs for that release rather than my file.
What is the correct n8n webhook URL behind a reverse proxy?
Your public address, with a trailing slash, set in an environment variable. n8n builds webhook URLs from its protocol, host and port, which behind a proxy gives you localhost:5678 or a wrong port. Two settings fix it:
N8N_WEBHOOK_URL: https://n8n.yourdomain.com/
N8N_PROXY_HOPS: 1
Here is the wrinkle I hit while reading. n8n's Docker Compose page still sets WEBHOOK_URL. The separate webhook-URL configuration page says N8N_WEBHOOK_URL replaces WEBHOOK_URL, which it calls deprecated from n8n 2.35.0, with a warning logged if you use the old name. I fetched that page twice and got the same wording both times, but it's a recent change, so the docs disagree with each other. My file uses the new name. If you pin an older release, use WEBHOOK_URL instead.
The check works regardless of version: open a workflow, add a Webhook node, and look at the production URL. If it shows your domain, you're done. If it shows localhost, the variable name isn't being read.
That page also says the last proxy in the chain should pass X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto. Caddy's reverse_proxy docs say it sets or augments all three by default and ignores client-supplied values, so the Caddyfile above needs nothing extra. Still test an IP-allowlisted webhook before you rely on it.
How do you start it and check it works?
All unrun, in order:
cd ~/n8n
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs -f caddy
Watch the Caddy log for a certificate being obtained. If it fails, the usual causes are DNS not yet propagated, port 80 or 443 blocked at the VPS firewall, or another process already using them. Then open https://n8n.yourdomain.com, create the owner account, and do it before anyone else finds the URL.
Before you build anything, take the encryption key out of the server's .env and put a copy in a password manager. n8n encrypts stored credentials with it. By default it generates one on first launch if you don't provide it, and keeps it under .n8n. You've set it explicitly, so a restored database will still decrypt.
How do you build one AI agent workflow on it?
A support-triage agent is a good first test because it touches everything that can go wrong: a public webhook, an LLM call, memory and a tool. The shape:
- Webhook node, method POST, path
triage, Authentication set to Header Auth with a long random value. n8n lists Basic, Header and JWT auth plus None. Use the production URL once the workflow is published; the test URL only works while the editor is listening. - AI Agent node with the prompt taken from the webhook body. n8n's docs say the agent type setting was deprecated in 1.82.0 and every AI Agent node now behaves as a Tools Agent, which needs at least one tool sub-node connected.
- Chat model sub-node with your provider credential. A Claude or OpenAI credential is created in n8n's Credentials page.
- Postgres Chat Memory sub-node. Table name is created if missing; Session Key identifies the conversation, so pass a customer or ticket ID from the webhook rather than a constant. Context Window Length sets how many past interactions are replayed.
- One tool, for example an HTTP Request tool that looks up an order status from your own API. Start with a read-only tool.
- Respond to Webhook node returning the agent's text.
A system message I'd start from. This is an untested template, not something I ran:
You triage inbound support messages for a small online shop.
Return JSON only: {"category": "billing|shipping|product|other", "urgency": 1-3, "reply_draft": "..."}.
Use the order_status tool only when the message contains an order ID.
If you are unsure, set category to "other" and urgency to 2. Never promise refunds.
Call it from your laptop to test (also unrun; replace the header name and value with what you configured):
curl -X POST https://n8n.yourdomain.com/webhook/triage \
-H "X-Api-Key: <your header auth value>" \
-H "Content-Type: application/json" \
-d '{"session": "ticket-1042", "message": "Where is order 88213? It was due Monday."}'
You should get a 403 without the header and a JSON answer with it. Make the 403 check part of your test; it's the one that tells you the endpoint isn't open. For more prompt patterns, see n8n AI agent system prompt templates. The Webhook node also has an IP allowlist option that returns 403 to other addresses, useful when the caller is a known service such as a payment provider.
How do you back up n8n?
Three things, because each protects something different:
| What | Why | How |
|---|---|---|
| Postgres dump | Workflows, credentials (encrypted), executions, chat memory | pg_dump |
| Workflow and credential exports | Readable, diffable, easy to restore one item | n8n export:* |
N8N_ENCRYPTION_KEY | Without it the credential rows are unreadable | Password manager |
A nightly dump, unrun:
docker compose exec -T postgres pg_dump -U n8n n8n | gzip > ~/backups/n8n-$(date +%F).sql.gz
The -T flag disables the pseudo-terminal so it works from cron. For exports, n8n's CLI docs give n8n export:workflow --backup --output=<dir> and n8n export:credentials --backup --output=<dir>, where --backup means --all --pretty --separate. In Docker the docs prefix commands with docker exec -u node. Credentials stay encrypted unless you add --decrypted, and the docs warn that flag writes secrets in plain text. Don't use it for routine backups.
Copy the dumps off the server. A backup that lives on the disk that dies isn't one. Hostinger's weekly backups and manual snapshots (per their plan page) are a useful extra layer, but I wouldn't make them your only copy, because weekly means up to a week of lost executions and chat history.
Restore is the part to rehearse once on a spare server before you need it: create the stack, restore the dump into an empty n8n database, and start n8n with the same N8N_ENCRYPTION_KEY. I haven't run that restore here.
How do you upgrade n8n safely?
Back up first, then change N8N_VERSION in .env, then:
docker compose pull
docker compose up -d
docker compose logs --tail 100 n8n
Read the release notes for the versions between yours and the target, because major jumps can change nodes and environment variables (the webhook variable rename above is an example). Test an agent workflow after every upgrade; AI nodes change faster than the core.
What goes wrong most often?
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook URL shows localhost | Variable name or proxy hops not applied for your version | Try WEBHOOK_URL; confirm in the Webhook node |
| Certificate never issues | DNS not pointing at the server, or 80/443 blocked | dig +short n8n.yourdomain.com, check provider firewall |
| n8n starts before the DB is ready | Missing healthcheck dependency | Keep the service_healthy condition |
| Credentials broken after restore | Different encryption key | Restore the original key |
| Agent loops or costs spike | No limit on tool calls or a chatty prompt | Cap iterations and set budget alerts at your LLM provider |
| Anyone can trigger the agent | Webhook auth set to None | Header auth plus allowlist where possible |
Is self-hosting n8n worth it?
It's worth it when you want control of the data, you run many executions, or you already maintain servers. It's not worth it if nobody will patch the box. A self-hosted agent server holds API keys for your LLM provider, your database and whatever tools you connect, so it's a target. Budget an hour a month for updates and checks, and run through the hardening checklist before you expose real credentials.



