n8n self-hosted: Docker Compose setup and configuration
n8n self-hosted Docker setup with Compose: PostgreSQL, your own domain with HTTPS, the encryption key, webhooks, queue mode, backups and upgrades without data loss.
Below: a single-VPS layout with n8n, PostgreSQL and task runners in Docker Compose behind Caddy or nginx. Everything was checked against the n8n docs and source code and is current as of September 2026: stable is 2.41.4, beta is 2.42.1, and n8n 3.0 is scheduled for October.
What you'll need
- A ForestsNet Cloud VPS running Ubuntu 22.04/24.04 or Debian 11/12: 1 vCPU and 2 GB RAM for a personal instance, 2 vCPU and 4 GB for a production one.
- Docker with the Compose v2 plugin — see Docker and Docker Compose.
- A subdomain such as
n8n.example.comwith an A record pointing at the server — see pointing a domain at your server. - Caddy or nginx on the host — see Caddy: automatic HTTPS or Nginx: install and your first site.
- SSH key login — see how to harden SSH.
Step 1. Decide whether self-hosting fits
What you get
n8n is a workflow automation tool: a workflow is built from nodes and started by a webhook, a schedule or an event from another service, with a Code node for JavaScript and Python, AI agents and MCP on top. A self-hosted instance has no limit on workflows or executions, and credentials stay on your disk. Updates, backups and security are yours too.
The license
n8n ships under the Sustainable Use License: fair-code, not OSI open source. You can use it for free to automate your own company and personal projects, build workflows for clients on your instance and charge for building and maintaining them, as long as clients don't create or edit workflows themselves. Without a commercial agreement with n8n you can't offer it as a service where customers build their own workflows, including through your own UI, the API or an AI agent, or resell it under your brand.
What Community Edition lacks
Queue mode with workers is included in the free edition. Missing are SSO via SAML and LDAP, projects with roles, environments and Git, external secret stores, log streaming, S3 storage for binary data, multi-main and enforced 2FA for all users. The limit teams notice first: workflows and credentials are visible only to the instance owner and their creator, with no sharing. A free registration by email adds folders, debug in editor and custom execution data.
Step 2. Prepare the server
Only ports 22, 80 and 443 need to be reachable — see the ufw firewall: managing ports.
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enableStep 3. Create the .env file with secrets
sudo mkdir -p /opt/n8n && cd /opt/n8n
sudo tee .env > /dev/null <<EOF
N8N_VERSION=2.41.4
N8N_DOMAIN=n8n.example.com
GENERIC_TIMEZONE=Europe/Berlin
POSTGRES_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
N8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)
EOF
sudo chmod 600 .envN8N_ENCRYPTION_KEY encrypts every credential in the database. If you don't set it, n8n generates a key on first start and stores it in /home/node/.n8n/config inside the volume: lose the volume, and the credentials in a surviving database can no longer be decrypted. Copy .env somewhere off the server right away.
Step 4. Describe the services in compose.yml
Create /opt/n8n/compose.yml:
x-n8n-env: &n8n-env
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${N8N_DOMAIN}
N8N_PROTOCOL: https
N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}/
N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/
N8N_PROXY_HOPS: 1
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
N8N_RUNNERS_MODE: external
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
EXECUTIONS_DATA_MAX_AGE: 168
EXECUTIONS_DATA_PRUNE_MAX_COUNT: 20000
services:
postgres:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: n8n
PGDATA: /var/lib/postgresql/data
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment: *n8n-env
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
runners:
image: n8nio/runners:${N8N_VERSION}
restart: unless-stopped
environment:
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
depends_on:
- n8n
volumes:
db_data:
n8n_data:What matters here:
- The version is pinned.
n8nio/n8nandn8nio/runnersmust run the same version, and with thelatesttag anydocker compose pullbrings a new version along with database migrations. - The PostgreSQL data directory is set explicitly.
postgres:18moved it, and without thePGDATAline the database ends up outside the volume. - Public URLs.
N8N_WEBHOOK_URLis the base for webhooks: without it the editor showshttp://localhost:5678/webhook/...;N8N_EDITOR_BASE_URLis used for emails and OAuth. The old nameWEBHOOK_URLhas been deprecated since 2.35. - Proxy.
N8N_PROXY_HOPS: 1makes n8n take the client IP fromX-Forwarded-For. If Cloudflare proxies traffic in front of nginx, set it to2. - Time zone. Without
GENERIC_TIMEZONE, schedules run onAmerica/New_Yorktime. - Task runners. Since 2.0, Code node scripts run outside the n8n process. By default that's a child process with n8n's own privileges (internal mode, now deprecated); the
runnerscontainer keeps code away from the database and the key and is required for Python.
Start the stack and check readiness:
cd /opt/n8n
sudo docker compose up -d
sudo docker compose ps
curl -s http://127.0.0.1:5678/healthz/readiness/healthz/readiness returns {"status":"ok"} once the database is connected and migrations have finished. Logs: sudo docker compose logs -f n8n. For external monitoring, point a checker such as Uptime Kuma at https://n8n.example.com/healthz.
Step 5. Connect the domain and HTTPS
Option A. Caddy
Caddy obtains the certificate, proxies WebSockets and passes Host and X-Forwarded-* on its own. Add to /etc/caddy/Caddyfile:
n8n.example.com {
reverse_proxy 127.0.0.1:5678
}Apply it with sudo systemctl reload caddy; details are in Caddy: automatic HTTPS.
Option B. Nginx and certbot
Out of the box nginx doesn't pass WebSockets, sends the backend address in Host, caps request bodies at 1 MB and waits 60 seconds for a response, and all of that gets in n8n's way. Create /etc/nginx/sites-available/n8n.example.com:
server {
listen 80;
server_name n8n.example.com;
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}Enable the site and get a certificate, as described in free SSL with Let's Encrypt (certbot):
sudo ln -s /etc/nginx/sites-available/n8n.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d n8n.example.comWhat the config lines do:
- WebSocket. The
UpgradeandConnectionheaders let through/rest/push, which the editor uses to receive execution results. - Origin check. It relies on
HostandX-Forwarded-Host: if n8n sees127.0.0.1:5678, it rejects the editor's WebSocket. - Request size.
client_max_body_sizelifts the 1 MB limit; n8n itself accepts up to 16 MiB (N8N_PAYLOAD_SIZE_MAX) and up to 200 MiB for multipart files. - Timeout.
proxy_read_timeoutstops nginx from cutting off long webhooks and the WebSocket, which n8n pings every 60 seconds. - Buffering. With
proxy_buffering off, streamed AI agent responses reach the client immediately.
Step 6. First login: owner, 2FA, ports
Open https://n8n.example.com. n8n asks you to create the owner account, and whoever fills in the form first gets the instance, so do it right after the first start. The same screen offers a free Registered Community key. Then enable 2FA under Settings → Personal → Enable 2FA and store the recovery codes.
If the codes are lost or the password is forgotten:
sudo docker compose exec n8n n8n mfa:disable [email protected]
sudo docker compose exec n8n n8n user-management:resetThe first command removes 2FA from one user. The second deletes the other users, hands their workflows and credentials to the owner and resets the owner account to the setup screen, which is the way back in for an owner without SMTP (N8N_EMAIL_MODE=smtp plus N8N_SMTP_*). For other users, the owner can copy a password reset link under Settings → Users.
Check the ports:
sudo ss -tlnp | grep -E '5678|5679|5432'The output should contain a single line with 127.0.0.1:5678. More measures are in the VPS security checklist.
Step 7. Set up webhooks
Every Webhook node has two URLs:
- Test
/webhook-test/<path>listens for 120 seconds after Listen for test event and accepts one call; the data shows up on the canvas. - Production
/webhook/<path>works once the workflow is published. Since 2.0, edits stay a draft while production keeps running the published version, so publish again after changes. Runs appear only under Executions.
Besides /webhook/, keep /webhook-waiting/ public for workflows that wait for a response such as a Slack approval, plus /form/ and /form-waiting/ for forms. Protect public webhooks with the node's authentication: Basic, Header or JWT. Behind a proxy, the node's IP allowlist only works when N8N_PROXY_HOPS is set.
Step 8. Know how the Code node works now
- Python is available only through the runners container. It's native Python 3 instead of Pyodide, so
_inputand dot notation from old workflows don't work. - Environment variables are blocked:
$envisn't available in expressions or Code (N8N_BLOCK_ENV_ACCESS_IN_NODE=true), so keep secrets in credentials. - Third-party modules go into your own image
FROM n8nio/runners:2.41.4viapnpm addoruv pip installand must be allowed in/etc/n8n-task-runners.jsonwithNODE_FUNCTION_ALLOW_EXTERNALandN8N_RUNNERS_EXTERNAL_ALLOW. - The time limit for code is 300 seconds (
N8N_RUNNERS_TASK_TIMEOUT); 3.0 lowers it to 60. - Execute Command and Local File Trigger are disabled via
NODES_EXCLUDE. Re-enabling them gives a shell in the container to anyone who can edit workflows. - File nodes only see
/home/node/.n8n-files, which lies outside the volume. Mount a directory of your own, for example./files:/files, and setN8N_RESTRICT_FILE_ACCESS_TO=/files.
Step 9. Switch to queue mode when one process isn't enough
In regular mode one Node.js process serves the UI, receives webhooks and runs workflows, so a heavy workflow slows down everything else while the other cores sit idle. Try N8N_CONCURRENCY_PRODUCTION_LIMIT first: production runs above the limit wait in a queue. If that's not enough, enable queue mode: the main process puts jobs into Redis and workers run them. OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS sends editor runs there too, and in 3.0 that becomes the only behavior.
Add to x-n8n-env:
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_HEALTH_CHECK_ACTIVE: "true"
OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: "true"Under services, add Redis and a worker paired with its own runners; add redis_data: under volumes and redis with condition: service_healthy to the depends_on of the n8n service:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
worker-1:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
restart: unless-stopped
command: worker --concurrency=10
environment: *n8n-env
volumes:
- n8n_data:/home/node/.n8n
depends_on:
- n8n
runners-1:
image: n8nio/runners:${N8N_VERSION}
restart: unless-stopped
environment:
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_TASK_BROKER_URI: http://worker-1:5679
depends_on:
- worker-1Keep in mind:
- The encryption key comes from the same
x-n8n-env: without it a worker won't start, and with a different one it can't decrypt credentials. - Each worker needs its own runners, because the task broker lives in the worker. That's why
--scaledoesn't fit here: addworker-2andrunners-2as a pair. - Concurrency.
--concurrencyis the number of parallel runs per worker, 10 by default. Values below 5 aren't recommended, since many such workers exhaust the database connection pool. - Files from workflows are stored in PostgreSQL, up to 512 MiB each:
filesystemmode isn't supported in queue mode, and S3 is a paid feature. - Redis must not be published or configured with key eviction such as
allkeys-lru, or jobs will go missing — see Redis: install and configure.
Step 10. Set up maintenance
Execution history pruning
By default n8n keeps executions for 14 days and no more than 10,000; the compose file above sets 7 days and 20,000. They're deleted together with their binary data, while annotated and unfinished executions stay. To write less, set EXECUTIONS_DATA_SAVE_ON_SUCCESS=none to keep only failures and EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=false; individual workflows have the same options in their settings.
PostgreSQL doesn't return the space of deleted rows to the OS. See what has grown:
sudo docker compose exec postgres psql -U n8n -d n8n -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 5;"Usually it's execution_data. Only VACUUM FULL shrinks it: it locks the table and needs free space equal to its size, so stop n8n first (sudo docker compose stop n8n) and start it afterwards:
sudo docker compose exec postgres psql -U n8n -d n8n -c "VACUUM (FULL, ANALYZE) execution_data;"Binary data in the n8n volume and Docker logs grow too — set max-size and max-file in /etc/docker/daemon.json, see running out of disk space.
Backups
You need three things: a database dump, the key from .env and the n8n_data volume with the config file, binary data and community nodes. A dump without the key is useless.
sudo mkdir -p /var/backups/n8n && cd /opt/n8n
sudo docker compose exec -T postgres pg_dump -U n8n -d n8n -Fc | sudo tee /var/backups/n8n/db-$(date +%F).dump > /dev/null
sudo docker run --rm -v n8n_n8n_data:/data:ro -v /var/backups/n8n:/backup alpine tar czf /backup/n8n-data-$(date +%F).tgz -C /data .
sudo cp .env /var/backups/n8n/env-$(date +%F)The volume is named after the project directory, n8n_n8n_data; check with sudo docker volume ls. Schedules and off-site copies are covered in database backups, scheduled backups with cron and incremental backups with restic. Restore a backup on a clean server at least once. The CLI export n8n export:workflow --backup is no replacement: it has no users, history or variables.
Upgrades
Minor versions ship almost every week, and security fixes every two weeks for stable, beta and the 1.x line at once.
- Read the release notes up to the target version, and before a major upgrade open Settings → Migration Report.
- Take a backup.
- Change
N8N_VERSIONin.envand runsudo docker compose pull && sudo docker compose up -d. - Don't stop the container while the log shows
Migrations in progress, please do NOT stop the process.
Migrations are one-way: rolling back means the old version plus a pre-upgrade dump. Keep main, workers and runners on the same version. Schedules live in memory by default, so a run that falls on a restart is lost; since 2.36 the durable scheduler fixes that (N8N_SCHEDULER_ENABLED=true and N8N_USE_WORKFLOW_PUBLICATION_SERVICE=true).
What n8n 3.0 changes:
- Docker only, installing via npm stops working;
~/.n8n/binaryDatabecomes~/.n8n/storage, so fix volumes mounted directly onbinaryData;- unverified community nodes won't load without
N8N_UNVERIFIED_PACKAGES_ENABLED=true; - the time limit for Code drops to 60 seconds;
- encryption key rotation turns on by default, so going back to 2.x only works from a dump;
- old nodes such as Function, Cron and Read Binary File are removed, and Migration Report lists them.
Step 11. Close the main risks
- Update on time. In 2026 n8n has published 196 security advisories, 19 of them critical. Ni8mare (CVE-2026-21858, CVSS 10) let unauthenticated attackers read server files through forms and take over the instance; it was fixed in 1.121.0. CVE-2025-68613, code execution by any logged-in user, has been in the CISA KEV catalog since March 2026, which means it's exploited in real attacks.
- Community nodes run with n8n's privileges and see every credential. In January 2026 Endor Labs found
n8n-nodes-*packages on npm that posed as integrations, one of them as Google Ads, and stole OAuth tokens. Install only verified nodes or turn installation off:N8N_COMMUNITY_PACKAGES_ENABLED=false. - The editor is safest behind a VPN or an IP allowlist (
allowanddenyin nginx), leaving/webhook/,/webhook-waiting/,/form/and/form-waiting/public. - Turn off what you don't use: the public API with
N8N_PUBLIC_API_DISABLED=true. If other people build workflows, enableN8N_SSRF_PROTECTION_ENABLED=true(since 2.12) so nodes can't reach private addresses and neighboring containers. - Audit:
sudo docker compose exec n8n n8n auditreports unused credentials and risky nodes.
How many resources you need
In regular mode n8n is a single Node.js process on a single core. The official Helm chart budgets 512 MB to 1 GB of memory for the main process and for each worker, and 256 to 512 MB for runners. Memory grows with data volume: n8n keeps the items of every node in memory, and a manual run copies them again for the editor.
- 1 vCPU, 2 GB — a personal instance: dozens of scheduled and webhook workflows, occasional Code.
- 2 vCPU, 4 GB — a production instance: AI agents, Code, thousands of rows, files. The official guide asks for the same for the stack with the n8n Assistant sandbox.
- 4 vCPU, 8 GB and up — queue mode with two or three workers.
Disk: NVMe, 20 GB or more, since every execution writes to execution_data.
n8n hardly uses any traffic. Incoming webhooks, API responses, docker pull, apt and npm are inbound, and ForestsNet Cloud counts only outbound traffic toward the plan: n8n's requests to APIs and its responses to webhooks, usually kilobytes. It only becomes noticeable if workflows send files out.
Troubleshooting
- The Connection lost banner. The
/rest/pushWebSocket doesn't get through:UpgradeandConnectionare missing,Hostis replaced (the n8n log showsOrigin header does NOT match the expected origin), or a timeout is too short. Compare your config with Step 5. - Webhook URLs start with localhost.
N8N_WEBHOOK_URLisn't set: add it and runsudo docker compose up -d. - An X-Forwarded-For error in the log.
ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is falsemeansN8N_PROXY_HOPSisn't set. - A page about a secure cookie.
Your n8n server is configured to use a secure cookie, however you are either visiting this via an insecure URL, or using Safari.appears when you open n8n overhttp://. Set up HTTPS; until then use an SSH tunnel tolocalhost:5678in a browser other than Safari.N8N_SECURE_COOKIE=falseis for a quick test only. - Credentials can't be decrypted.
Credentials could not be decryptedmeans the volume was recreated withoutN8N_ENCRYPTION_KEYor the key was changed. Restore the old key from a backup, otherwise credentials have to be re-entered. - No access to the config file.
EACCES: permission denied, open '/home/node/.n8n/config'means a root-owned host directory is mounted, while n8n runs as uid 1000:sudo chown -R 1000:1000 <directory>. - A webhook returns 404.
The requested webhook "POST <path>" is not registered.means the workflow isn't published, test mode has expired, or a CLI import unpublished it;This webhook is not registered for GET requestsmeans the wrong HTTP method. - Out of memory. You see
n8n may have run out of memory while running this execution, or the container restarts. Check the exit code insudo docker compose ps -a: 137 is the kernel OOM killer, 134 is the V8 heap limit. Split data with Loop Over Items and sub-workflows, add RAM or raiseNODE_OPTIONS=--max-old-space-size=2048. - Code doesn't run.
Task request timed outorPython runner unavailablemeans the runners didn't connect: image versions or tokens differ, orN8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0is missing. Checksudo docker compose logs runners. - PostgreSQL won't start after a tag change.
database files are incompatible with servermeans a major version change, which needspg_dumpalland a restore into a new volume; the data itself is intact.
Sources
- n8n docs: Docker Compose install, webhook URLs behind a reverse proxy, environment variables, task runners, queue mode, execution data, memory errors, backup and restore, durable scheduler
- License and editions: compare editions, license FAQ
- Changes: n8n 2.0 breaking changes, n8n 3.0 breaking changes, GitHub releases, Compose examples in n8n-io/n8n-hosting
- Security: n8n security advisories, Cyera — Ni8mare, NVD — CVE-2025-68613, CISA KEV catalog, Endor Labs — malicious community nodes
- Host and proxy: Docker and ufw, Caddy reverse_proxy, nginx WebSocket proxying, PostgreSQL VACUUM
- Community: Connection lost and the Host header, Invalid origin behind Cloudflare Tunnel, 413 when uploading a PDF via webhook