Production-ready n8n self-hosted setup. Docker Compose with PostgreSQL, Caddy for auto-HTTPS, external task runners, off-site backups, monitoring with alerts, and a tested restore procedure.
Self-hosting makes sense when n8n Cloud execution limits or pricing don’t work for you, when you need data residency in a specific jurisdiction, or when your workflows run long enough to hit cloud timeouts (RAG pipelines, bulk API syncs).
Docker Compose
mkdir -p /opt/n8n && cd /opt/n8ncompose.yaml
volumes: db_storage: n8n_storage: caddy_data: caddy_config:
networks: frontend: backend:
services: caddy: image: caddy:2.9 restart: always ports: - "80:80" - "443:443" environment: N8N_HOST: ${N8N_HOST} N8N_PORT: ${N8N_PORT} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - frontend depends_on: n8n: condition: service_started
postgres: image: postgres:18.4-bookworm restart: always environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} POSTGRES_NON_ROOT_USER: ${POSTGRES_NON_ROOT_USER} POSTGRES_NON_ROOT_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD} volumes: - db_storage:/var/lib/postgresql - ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh healthcheck: test: ["CMD-SHELL", "pg_isready -h localhost -U ${POSTGRES_NON_ROOT_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 10 start_period: 30s networks: - backend deploy: resources: limits: memory: 1g reservations: memory: 256m
n8n: image: docker.n8n.io/n8nio/n8n:${N8N_VERSION} restart: always stop_grace_period: 120s environment: NODE_OPTIONS: --max-old-space-size=1536 DB_TYPE: ${DB_TYPE} DB_POSTGRESDB_HOST: ${DB_POSTGRESDB_HOST} DB_POSTGRESDB_PORT: ${DB_POSTGRESDB_PORT} DB_POSTGRESDB_DATABASE: ${POSTGRES_DB} DB_POSTGRESDB_USER: ${POSTGRES_NON_ROOT_USER} DB_POSTGRESDB_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD} N8N_HOST: ${N8N_HOST} N8N_PORT: ${N8N_PORT} N8N_PROTOCOL: ${N8N_PROTOCOL} N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY} WEBHOOK_URL: ${N8N_PROTOCOL}://${N8N_HOST}/ N8N_RUNNERS_ENABLED: ${N8N_RUNNERS_ENABLED} N8N_RUNNERS_MODE: ${N8N_RUNNERS_MODE} N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN} N8N_RUNNERS_BROKER_LISTEN_ADDRESS: ${N8N_RUNNERS_BROKER_LISTEN_ADDRESS} N8N_PROXY_HOPS: ${N8N_PROXY_HOPS} GENERIC_TIMEZONE: ${GENERIC_TIMEZONE} TZ: ${GENERIC_TIMEZONE} N8N_LOG_LEVEL: ${N8N_LOG_LEVEL} EXECUTIONS_DATA_PRUNE: ${EXECUTIONS_DATA_PRUNE} EXECUTIONS_DATA_MAX_AGE: ${EXECUTIONS_DATA_MAX_AGE} EXECUTIONS_DATA_PRUNE_MAX_COUNT: ${EXECUTIONS_DATA_PRUNE_MAX_COUNT} N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: ${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS} N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED} volumes: - n8n_storage:/home/node/.n8n healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:5678/healthz || exit 1"] interval: 10s timeout: 5s retries: 5 start_period: 60s networks: - frontend - backend depends_on: postgres: condition: service_healthy deploy: resources: limits: memory: 2g reservations: memory: 512m
n8n-runner: image: docker.n8n.io/n8nio/runners:${N8N_VERSION} restart: always environment: NODE_OPTIONS: --max-old-space-size=768 N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN} N8N_RUNNERS_TASK_BROKER_URI: ${N8N_RUNNERS_TASK_BROKER_URI} networks: - backend depends_on: n8n: condition: service_healthy deploy: resources: limits: memory: 1g reservations: memory: 256mCaddyfile
{$N8N_HOST} { reverse_proxy n8n:{$N8N_PORT}
encode gzip zstd
header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" }}Caddy obtains and renews Let’s Encrypt certificates automatically. WebSocket proxying works out of the box. Security headers and compression included.
init-data.sh
Creates a non-root Postgres user with limited permissions. n8n connects via this user, not the superuser. Executes automatically on first container start (when the volume is empty).
#!/bin/bashset -e
if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}'; GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER}; GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER}; GRANT USAGE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};EOSQLfichmod +x /opt/n8n/init-data.sh.env
N8N_VERSION=2.21.7
# PostgresPOSTGRES_USER=postgresPOSTGRES_PASSWORD=<openssl rand -base64 32>POSTGRES_DB=n8nPOSTGRES_NON_ROOT_USER=n8nPOSTGRES_NON_ROOT_PASSWORD=<openssl rand -base64 32>
# n8n database connectionDB_TYPE=postgresdbDB_POSTGRESDB_HOST=postgresDB_POSTGRESDB_PORT=5432
# n8n applicationN8N_HOST=n8n.yourdomain.comN8N_PORT=5678N8N_PROTOCOL=httpsN8N_ENCRYPTION_KEY=<openssl rand -base64 32>N8N_LOG_LEVEL=infoN8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=trueN8N_DIAGNOSTICS_ENABLED=falseN8N_PROXY_HOPS=1
# Task runnersN8N_RUNNERS_ENABLED=trueN8N_RUNNERS_MODE=externalN8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679N8N_RUNNERS_AUTH_TOKEN=<openssl rand -base64 32>
# Execution retentionEXECUTIONS_DATA_PRUNE=trueEXECUTIONS_DATA_MAX_AGE=168EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
# TimezoneGENERIC_TIMEZONE=Europe/Berlin
# Monitoring (Telegram alerts)TELEGRAM_BOT_TOKEN=<your-bot-token>TELEGRAM_CHAT_ID=<your-chat-id>
# BackupsBACKUP_BUCKET=your-bucket-name/n8n-backupsNotes on config choices
Networks. frontend connects Caddy to n8n. backend connects n8n to Postgres and the runner. Caddy can’t reach Postgres directly - limits blast radius if Caddy gets compromised.
stop_grace_period: 120s on n8n. Default Docker timeout is 10s between SIGTERM and SIGKILL. n8n with long-running executions (RAG pipelines, bulk syncs) needs more time to finish gracefully. Also relevant for the backup script which stops n8n.
NODE_OPTIONS: --max-old-space-size=1536 on n8n. With a 2GB memory limit, explicitly cap V8 heap at 1.5GB. Leaves 512MB for native modules, buffers, OS overhead. Without this, Node.js may attempt to grow heap to the cgroup limit and get OOM-killed before GC kicks in. Same principle on runner with 1024MB for 1GB limit.
start_period on healthchecks. n8n runs database migrations on first start or after upgrades - can take minutes on large databases. Without start_period, Docker marks the container unhealthy during migrations and Caddy/runner refuse to connect.
Caddy depends_on: service_started (not service_healthy). Caddy starts independently and returns 502 while n8n boots. This keeps TLS working for diagnostics and allows maintenance pages.
Resource limits + reservations. Limits prevent a runaway workflow from eating all memory. Reservations guarantee minimum allocation under memory pressure - prevents OOM-kill ordering issues.
N8N_RUNNERS_ENABLED=true activates the task runner broker on the main n8n container. Without this, external runners can’t connect.
N8N_PROXY_HOPS=1 tells n8n to trust one proxy hop (Caddy) when reading X-Forwarded-For. Without this, audit logs and rate limiting see Docker’s internal IP.
docker.n8n.io/n8nio/runners is a separate image with its own entrypoint. Don’t use the n8n image with a command override.
N8N_RUNNERS_AUTH_TOKEN is the same variable name in .env and compose - no remapping between different names.
Pinned image versions. Caddy 2.9, Postgres 18.4-bookworm, n8n via N8N_VERSION. No floating tags in production.
Binary data storage. By default n8n stores file attachments in /home/node/.n8n/binaryData. For workflows that process files (PDFs, images, OCR) this directory grows fast and makes backups slow. If your workflows are file-heavy, configure N8N_DEFAULT_BINARY_DATA_MODE=s3 with an S3-compatible bucket. This moves binary data off-disk and simplifies backups to just the database and config. Text-only workflows (API integrations, JSON processing) don’t need this.
First start
cd /opt/n8ndocker compose up -ddocker compose logs -fOn first start: Postgres runs init-data.sh to create the non-root user, n8n runs database migrations, Caddy obtains TLS certificate. Takes 30-60 seconds. After that, open https://n8n.yourdomain.com and create the owner account.
Prerequisites:
- DNS A record for
N8N_HOSTpointing to the server - Ports 80 and 443 open in firewall (UFW:
sudo ufw allow 80,443/tcp) .envfilled with real values (generate secrets withopenssl rand -base64 32)- SSH hardened (key-only auth, root login disabled)
After creating the owner account, enable 2FA (TOTP) in n8n settings - it’s built-in and there’s no reason to skip it on a public instance.
Backups
Two things to back up: the PostgreSQL database and the n8n data directory (/home/node/.n8n - contains encryption keys and local files). Both go directly to a remote bucket.
Important: store N8N_ENCRYPTION_KEY separately from backups (password manager, separate vault). If an attacker gets both your database dump and the encryption key from the same bucket, all stored credentials are exposed. Don’t store .env in the backup bucket.
Trade-off: the backup script briefly stops n8n (~10-30 seconds) for a consistent snapshot of the data directory. During this window, active executions are interrupted and incoming webhooks get 502 from Caddy. For text-only workflows this is acceptable at 03:00. For critical 24/7 setups, skip the stop and accept a potentially inconsistent .n8n copy (the important files there are mostly static).
rclone setup
curl -O https://downloads.rclone.org/current/rclone-current-linux-amd64.debsudo dpkg -i rclone-current-linux-amd64.debCreate /opt/n8n/rclone.conf. Example for GCS (service account, bucket with uniform access):
[backup]type = google cloud storageservice_account_file = /opt/n8n/sa-backup.jsonbucket_policy_only = trueFor S3-compatible storage (Backblaze B2, AWS S3, MinIO):
[backup]type = s3provider = AWSaccess_key_id = <your-key>secret_access_key = <your-secret>region = eu-central-1Create the bucket manually in your cloud console. If using GCS with bucket_policy_only = true, enable uniform bucket-level access in bucket settings (not the default) - otherwise rclone gets permission denied.
/opt/n8n/backup.sh
#!/bin/bashset -euo pipefail
cd /opt/n8nset -a; source .env; set +a
BACKUP_DIR="/opt/n8n/backups"REMOTE="backup:${BACKUP_BUCKET}"RETENTION_DAYS=14DATE=$(date +%Y-%m-%d_%H%M)
mkdir -p "$BACKUP_DIR"
alert_failure() { curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ -d "text=n8n backup FAILED: $1 at $DATE"}
# Database dump (consistent - pg_dump uses MVCC snapshot, no stop needed)if ! docker compose exec -T postgres \ pg_dump -U "$POSTGRES_NON_ROOT_USER" -d "$POSTGRES_DB" --clean --if-exists | \ gzip > "$BACKUP_DIR/n8n_db_$DATE.sql.gz"; then alert_failure "pg_dump" exit 1fi
# n8n data directory - stop briefly for consistent snapshot# trap ensures n8n restarts even if script is killed (Ctrl+C, SIGTERM, error)trap 'docker compose start n8n 2>/dev/null || true' ERR INT TERMdocker compose stop n8ndocker compose cp n8n:/home/node/.n8n/. - | \ gzip > "$BACKUP_DIR/n8n_files_$DATE.tar.gz"docker compose start n8ntrap - ERR INT TERM
# Upload to remote bucketif ! rclone copy "$BACKUP_DIR" "$REMOTE" --config /opt/n8n/rclone.conf; then alert_failure "rclone upload" exit 1fi
# Remote retention - delete old backups from bucketif ! rclone delete "$REMOTE" --config /opt/n8n/rclone.conf --min-age ${RETENTION_DAYS}d; then alert_failure "rclone retention cleanup"fi
# Local cleanup - keep 3 days for quick restorefind "$BACKUP_DIR" -name "*.gz" -mtime +3 -deletechmod +x /opt/n8n/backup.shCron - daily at 03:00
echo "0 3 * * * root /opt/n8n/backup.sh >> /var/log/n8n-backup.log 2>&1" | \ sudo tee /etc/cron.d/n8n-backupRestore
Full restore from backup to a fresh server. Assumes rclone.conf, .env, compose.yaml, Caddyfile, init-data.sh are in place. Compose volumes (db_storage, n8n_storage) must be empty - if reusing a server, run docker compose down -v first.
cd /opt/n8nset -a; source .env; set +a
# Get recent backups from remote (last 2 days only)rclone copy "backup:${BACKUP_BUCKET}" /opt/n8n/backups \ --config /opt/n8n/rclone.conf \ --include "*.gz" \ --max-age 2d
# Find latest filesDB_BACKUP=$(ls -t backups/n8n_db_*.sql.gz | head -1)FILES_BACKUP=$(ls -t backups/n8n_files_*.tar.gz | head -1)
echo "Restoring from: $DB_BACKUP and $FILES_BACKUP"
# Verify gzip integritygunzip -t "$DB_BACKUP"gunzip -t "$FILES_BACKUP"
# Start postgres and wait until healthydocker compose up -d --wait --wait-timeout 60 postgres
# Restore databasegunzip -c "$DB_BACKUP" | \ docker compose exec -T postgres psql -U "$POSTGRES_NON_ROOT_USER" -d "$POSTGRES_DB"
# Create n8n container (stopped), restore data directorydocker compose create n8ndocker compose cp - n8n:/home/node/.n8n < <(gunzip -c "$FILES_BACKUP")
# Start everythingdocker compose up -d
# Verify n8n becomes healthy (not just "running")if timeout 180 bash -c 'until docker compose ps n8n --format "{{.Health}}" | grep -qx healthy; do sleep 5; done'; then echo "n8n is healthy - restore complete"else echo "FAILED - n8n did not become healthy in 180s. Check: docker compose logs n8n"fiIf you have never tested a restore on a throwaway instance, you don’t have backups.
Monitoring
/opt/n8n/healthcheck.sh
#!/bin/bash
cd /opt/n8nset -a; source .env; set +a
ALERT_MARKER="/tmp/n8n_alert_sent"
send_alert() { if [ -f "$ALERT_MARKER" ] && [ $(($(date +%s) - $(stat -c %Y "$ALERT_MARKER"))) -lt 3600 ]; then return fi curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ -d "text=$1" touch "$ALERT_MARKER"}
# Disk check on Docker data directoryDISK_USAGE=$(df /var/lib/docker | tail -1 | awk '{print $5}' | tr -d '%')
# Memory check using MemAvailable (kernel's actual estimate of free memory)MEM_AVAILABLE_KB=$(grep MemAvailable /proc/meminfo | awk '{print $2}')MEM_TOTAL_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')MEM_USED_PCT=$(( (MEM_TOTAL_KB - MEM_AVAILABLE_KB) * 100 / MEM_TOTAL_KB ))
if [ "$DISK_USAGE" -gt 85 ] || [ "$MEM_USED_PCT" -gt 90 ]; then send_alert "n8n server: disk=${DISK_USAGE}% mem=${MEM_USED_PCT}%"fi
# Check main n8n container specificallyif [ -z "$(docker compose ps -q n8n --status running)" ]; then docker compose up -d n8n send_alert "n8n main container was down, restarted"fi
# Clear alert marker when conditions normalizeif [ "$DISK_USAGE" -lt 80 ] && [ "$MEM_USED_PCT" -lt 85 ]; then rm -f "$ALERT_MARKER"fichmod +x /opt/n8n/healthcheck.shecho "*/5 * * * * root /opt/n8n/healthcheck.sh >> /var/log/n8n-healthcheck.log 2>&1" | \ sudo tee /etc/cron.d/n8n-healthcheckExternal uptime: n8n exposes GET /healthz (returns 200 when running). Point any uptime monitor at https://n8n.yourdomain.com/healthz - UptimeRobot, Hetrixtools, or Uptime Kuma. Alert on 3 consecutive failures.
Docker log rotation
/etc/docker/daemon.json (applies to all containers on this host):
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }}Restart Docker after changing. Existing containers need to be recreated (docker compose down && docker compose up -d) to pick up new log settings.
If this server runs other containers besides n8n, use per-service logging in compose.yaml instead:
services: n8n: logging: driver: json-file options: max-size: "10m" max-file: "3"Upgrading n8n
cd /opt/n8n./backup.sh# Edit .env: change N8N_VERSION to the new versiondocker compose pulldocker compose up -d
# Verify n8n becomes healthy (no host port exposed, check via container)if timeout 180 bash -c 'until docker compose ps n8n --format "{{.Health}}" | grep -qx healthy; do sleep 5; done'; then echo "Upgrade OK"else echo "FAILED - n8n not healthy in 180s. Check: docker compose logs n8n"fiIf the healthcheck fails after upgrade:
docker compose down# Revert N8N_VERSION in .env to previous valuedocker compose up -d --wait postgresgunzip -c backups/n8n_db_<pre-upgrade>.sql.gz | \ docker compose exec -T postgres psql -U "$POSTGRES_NON_ROOT_USER" -d "$POSTGRES_DB"docker compose up -dCheck the changelog before every upgrade. n8n breaks things between minor releases - webhook path changes, deprecated env vars, database migrations that can’t be rolled back.