SerhiiLabs

Self-hosting n8n: production Docker setup with backups and monitoring

2,521 words 12 min read
Categories n8n-automation

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

Terminal window
mkdir -p /opt/n8n && cd /opt/n8n

compose.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: 256m

Caddyfile

{$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/bash
set -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};
EOSQL
fi
Terminal window
chmod +x /opt/n8n/init-data.sh

.env

Terminal window
N8N_VERSION=2.21.7
# Postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<openssl rand -base64 32>
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=n8n
POSTGRES_NON_ROOT_PASSWORD=<openssl rand -base64 32>
# n8n database connection
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres
DB_POSTGRESDB_PORT=5432
# n8n application
N8N_HOST=n8n.yourdomain.com
N8N_PORT=5678
N8N_PROTOCOL=https
N8N_ENCRYPTION_KEY=<openssl rand -base64 32>
N8N_LOG_LEVEL=info
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
N8N_DIAGNOSTICS_ENABLED=false
N8N_PROXY_HOPS=1
# Task runners
N8N_RUNNERS_ENABLED=true
N8N_RUNNERS_MODE=external
N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
N8N_RUNNERS_AUTH_TOKEN=<openssl rand -base64 32>
# Execution retention
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=168
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
# Timezone
GENERIC_TIMEZONE=Europe/Berlin
# Monitoring (Telegram alerts)
TELEGRAM_BOT_TOKEN=<your-bot-token>
TELEGRAM_CHAT_ID=<your-chat-id>
# Backups
BACKUP_BUCKET=your-bucket-name/n8n-backups

Notes 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

Terminal window
cd /opt/n8n
docker compose up -d
docker compose logs -f

On 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_HOST pointing to the server
  • Ports 80 and 443 open in firewall (UFW: sudo ufw allow 80,443/tcp)
  • .env filled with real values (generate secrets with openssl 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

Terminal window
curl -O https://downloads.rclone.org/current/rclone-current-linux-amd64.deb
sudo dpkg -i rclone-current-linux-amd64.deb

Create /opt/n8n/rclone.conf. Example for GCS (service account, bucket with uniform access):

[backup]
type = google cloud storage
service_account_file = /opt/n8n/sa-backup.json
bucket_policy_only = true

For S3-compatible storage (Backblaze B2, AWS S3, MinIO):

[backup]
type = s3
provider = AWS
access_key_id = <your-key>
secret_access_key = <your-secret>
region = eu-central-1

Create 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/bash
set -euo pipefail
cd /opt/n8n
set -a; source .env; set +a
BACKUP_DIR="/opt/n8n/backups"
REMOTE="backup:${BACKUP_BUCKET}"
RETENTION_DAYS=14
DATE=$(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 1
fi
# 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 TERM
docker compose stop n8n
docker compose cp n8n:/home/node/.n8n/. - | \
gzip > "$BACKUP_DIR/n8n_files_$DATE.tar.gz"
docker compose start n8n
trap - ERR INT TERM
# Upload to remote bucket
if ! rclone copy "$BACKUP_DIR" "$REMOTE" --config /opt/n8n/rclone.conf; then
alert_failure "rclone upload"
exit 1
fi
# Remote retention - delete old backups from bucket
if ! 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 restore
find "$BACKUP_DIR" -name "*.gz" -mtime +3 -delete
Terminal window
chmod +x /opt/n8n/backup.sh

Cron - daily at 03:00

Terminal window
echo "0 3 * * * root /opt/n8n/backup.sh >> /var/log/n8n-backup.log 2>&1" | \
sudo tee /etc/cron.d/n8n-backup

Restore

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.

Terminal window
cd /opt/n8n
set -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 files
DB_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 integrity
gunzip -t "$DB_BACKUP"
gunzip -t "$FILES_BACKUP"
# Start postgres and wait until healthy
docker compose up -d --wait --wait-timeout 60 postgres
# Restore database
gunzip -c "$DB_BACKUP" | \
docker compose exec -T postgres psql -U "$POSTGRES_NON_ROOT_USER" -d "$POSTGRES_DB"
# Create n8n container (stopped), restore data directory
docker compose create n8n
docker compose cp - n8n:/home/node/.n8n < <(gunzip -c "$FILES_BACKUP")
# Start everything
docker 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"
fi

If you have never tested a restore on a throwaway instance, you don’t have backups.

Monitoring

/opt/n8n/healthcheck.sh

#!/bin/bash
cd /opt/n8n
set -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 directory
DISK_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 specifically
if [ -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 normalize
if [ "$DISK_USAGE" -lt 80 ] && [ "$MEM_USED_PCT" -lt 85 ]; then
rm -f "$ALERT_MARKER"
fi
Terminal window
chmod +x /opt/n8n/healthcheck.sh
echo "*/5 * * * * root /opt/n8n/healthcheck.sh >> /var/log/n8n-healthcheck.log 2>&1" | \
sudo tee /etc/cron.d/n8n-healthcheck

External 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

Terminal window
cd /opt/n8n
./backup.sh
# Edit .env: change N8N_VERSION to the new version
docker compose pull
docker 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"
fi

If the healthcheck fails after upgrade:

Terminal window
docker compose down
# Revert N8N_VERSION in .env to previous value
docker compose up -d --wait postgres
gunzip -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 -d

Check 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.