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

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 enable
Important: Docker publishes the ports from a ports: section through NAT rules that run before ufw, so a ufw deny rule doesn't block them. That's why n8n below listens only on 127.0.0.1:5678, and PostgreSQL and the task runner broker aren't published at all.

Step 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 .env

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

Important: for an existing installation don't generate a new key — take it from the config file. With a different key n8n refuses to start and logs Mismatching encryption keys.

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/n8n and n8nio/runners must run the same version, and with the latest tag any docker compose pull brings a new version along with database migrations.
  • The PostgreSQL data directory is set explicitly. postgres:18 moved it, and without the PGDATA line the database ends up outside the volume.
  • Public URLs. N8N_WEBHOOK_URL is the base for webhooks: without it the editor shows http://localhost:5678/webhook/...; N8N_EDITOR_BASE_URL is used for emails and OAuth. The old name WEBHOOK_URL has been deprecated since 2.35.
  • Proxy. N8N_PROXY_HOPS: 1 makes n8n take the client IP from X-Forwarded-For. If Cloudflare proxies traffic in front of nginx, set it to 2.
  • Time zone. Without GENERIC_TIMEZONE, schedules run on America/New_York time.
  • 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 runners container 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.com

What the config lines do:

  • WebSocket. The Upgrade and Connection headers let through /rest/push, which the editor uses to receive execution results.
  • Origin check. It relies on Host and X-Forwarded-Host: if n8n sees 127.0.0.1:5678, it rejects the editor's WebSocket.
  • Request size. client_max_body_size lifts 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_timeout stops 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.
Tip: Cloudflare's proxy drops a request after 100 seconds without a response and returns error 524. Run long workflows with an immediate response (Respond: Immediately) and send the result in a separate request.

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:reset

The 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 _input and dot notation from old workflows don't work.
  • Environment variables are blocked: $env isn'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.4 via pnpm add or uv pip install and must be allowed in /etc/n8n-task-runners.json with NODE_FUNCTION_ALLOW_EXTERNAL and N8N_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 set N8N_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-1

Keep 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 --scale doesn't fit here: add worker-2 and runners-2 as a pair.
  • Concurrency. --concurrency is 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: filesystem mode 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.

  1. Read the release notes up to the target version, and before a major upgrade open Settings → Migration Report.
  2. Take a backup.
  3. Change N8N_VERSION in .env and run sudo docker compose pull && sudo docker compose up -d.
  4. 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/binaryData becomes ~/.n8n/storage, so fix volumes mounted directly on binaryData;
  • 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 (allow and deny in 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, enable N8N_SSRF_PROTECTION_ENABLED=true (since 2.12) so nodes can't reach private addresses and neighboring containers.
  • Audit: sudo docker compose exec n8n n8n audit reports 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/push WebSocket doesn't get through: Upgrade and Connection are missing, Host is replaced (the n8n log shows Origin 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_URL isn't set: add it and run sudo 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 false means N8N_PROXY_HOPS isn'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 over http://. Set up HTTPS; until then use an SSH tunnel to localhost:5678 in a browser other than Safari. N8N_SECURE_COOKIE=false is for a quick test only.
  • Credentials can't be decrypted. Credentials could not be decrypted means the volume was recreated without N8N_ENCRYPTION_KEY or 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 requests means 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 in sudo 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 raise NODE_OPTIONS=--max-old-space-size=2048.
  • Code doesn't run. Task request timed out or Python runner unavailable means the runners didn't connect: image versions or tokens differ, or N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 is missing. Check sudo docker compose logs runners.
  • PostgreSQL won't start after a tag change. database files are incompatible with server means a major version change, which needs pg_dumpall and a restore into a new volume; the data itself is intact.

Sources