Self-hosting n8n properly
A setup that survives a restart, a reboot and an upgrade. Nothing is held back and there is nothing to sign up for. We sell hosting, so read the last section with the appropriate amount of salt, and take the rest at face value: it works, we run it, and most of the troubleshooting pages on this site exist because the shortcut version of it does not.
What you are actually signing up for
Worth saying before the code rather than after it. Running this yourself means you own five jobs, forever:
- A machine that is awake, reachable, and not doing three other things at once.
- A certificate that renews, and an address that does not change.
- Backups you have actually restored from at least once.
- Upgrades, read and applied deliberately, because they change the database one way.
- Being the person who gets up when it falls over at an inconvenient time.
Plenty of people enjoy those, or already do them for something else on the same machine. If that is you, the rest of this page is everything you need.
1. The compose file
Two parts: n8n, and a real database for it to live in. The single file database that n8n starts with is fine for an afternoon of trying things and is a bad place for a year of work.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=n8n
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:1.70.0 # pin it. never :latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
# Where the data lives
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
# The one value that decrypts every credential you will ever save
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# What address the outside world sees
- N8N_HOST=${N8N_HOST}
- N8N_PROTOCOL=https
- N8N_PORT=5678
- WEBHOOK_URL=https://${N8N_HOST}/
- N8N_EDITOR_BASE_URL=https://${N8N_HOST}/
- N8N_PROXY_HOPS=1
# Housekeeping that stops it filling the disk
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=336
- EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
- N8N_DEFAULT_BINARY_DATA_MODE=filesystem
# Say the timezone out loud, or everything schedules in UTC
- GENERIC_TIMEZONE=Europe/Berlin
- TZ=Europe/Berlin
volumes:
- n8n_data:/home/node/.n8n
ports:
- "127.0.0.1:5678:5678" # only the proxy talks to it
volumes:
db_data:
n8n_data: 2. The secrets file next to it
Generate the encryption key once with openssl rand -hex 32, then put it
somewhere you will still have in a year. It is the only thing that can decrypt the
credentials you save, so losing it is not recoverable by anybody.
# .env, next to the compose file. Never commit this.
POSTGRES_PASSWORD=change-this-to-something-long
N8N_ENCRYPTION_KEY=paste-the-output-of-openssl-rand-hex-32
N8N_HOST=n8n.example.com 3. Something in front with a certificate
n8n should not be the thing facing the internet. Caddy is the shortest version of this because the certificate is automatic and renews itself.
n8n.example.com {
reverse_proxy 127.0.0.1:5678
} Point the DNS record at the machine first, then start Caddy. If the address is not resolving yet, the certificate cannot be issued and the failure looks unrelated.
4. Start it
docker compose up -d
docker compose logs -f n8n
Open your address, create the owner account on the first screen, and build something small
to prove the loop works end to end. Then restart everything with docker compose restart and check your workflow is still there. Do that now,
while you have nothing to lose, rather than in six months when you do.
5. Backups, which is the part people skip
Three pieces, and all three are needed. A database dump without the encryption key restores a list of credentials nobody can read.
#!/usr/bin/env bash
set -euo pipefail
stamp=$(date +%F)
mkdir -p ./backups
# 1. The database, which is your workflows and your execution history
docker compose exec -T postgres pg_dump -U n8n n8n | gzip > "./backups/n8n-db-${stamp}.sql.gz"
# 2. The n8n directory, which holds settings and any files on disk
docker run --rm -v n8n_n8n_data:/data -v "$PWD/backups":/backup alpine \
tar czf "/backup/n8n-files-${stamp}.tar.gz" -C /data .
# 3. The encryption key. A backup without it restores rows nobody can read.
cp .env "./backups/env-${stamp}.bak" Run it on a schedule, keep the files somewhere that is not the same machine, and restore one into a scratch setup once. A backup you have never restored is a hypothesis.
The other backup worth having is n8n's own: a workflow that exports every workflow on a schedule and pushes the files to a repository you own. It is the copy that is genuinely portable.
6. Upgrades, deliberately
n8n migrates its database on the way up, automatically, and those migrations only run forwards. That is why the upgrade is quick and why going back is not: the old version sees a database from the future and refuses to start. Downgrading is not undoing an upgrade, it is restoring a backup taken before it.
# 1. back up first, every time
./backup.sh
# 2. change the pinned tag in the compose file, one major version at a time
# 3. pull and restart
docker compose pull n8n
docker compose up -d n8n
docker compose logs -f n8n Read what changed between your version and the one you are moving to, and look for the nodes you actually use. Skipping four versions at once means four sets of breaking changes arriving together with nothing to tell you which one broke you.
7. The settings that stop it degrading
They are already in the compose file above, and they are worth understanding rather than pasting:
- Pruning. Every run is saved by default, including successful ones and everything they passed around. Without pruning, the database grows without limit and everything touching it gets slower. Two weeks is a reasonable amount of history.
- Files on disk rather than in memory. Downloads and attachments are held in memory otherwise, and a handful of large ones inside a single run is the fastest way to run out.
- The timezone. Unset means UTC, which means your daily digest arrives at a surprising time and you spend an evening working out why.
- One proxy hop. Without it, login cookies and the client addresses in your logs are subtly wrong.