Why host n8n on your VPS
The cloud version of n8n charges per execution beyond the plan and limits the number of active workflows. On your VPS, the only cost is that of the server, regardless of the number of automations or API calls. You also keep full control over credentials, webhook payloads and execution history — no data transits through a third-party infrastructure.
The concrete benefits of a self-hosted n8n
- No monthly execution cap: no monthly cap, no cost at scale.
- Credentials and webhook payloads confined to your server, without external transit.
- Access to community nodes and custom code execution without restriction.
- Webhooks on your own domain, configurable for each inbound integration.
- Predictable cost: a fixed VPS price, independent of automation volume.
- Controlled persistence of workflows and history in volumes you back up.
- Compatibility with Ollama, Flowise or any other Docker service on the same internal network.
Numbered prerequisites
Before starting, verify your VPS meets the following requirements. For moderate usage, 1 vCPU and 1 GB of RAM are sufficient; plan for 2 vCPU and 2 GB as soon as you run heavy or concurrent workflows, and 4 GB if you connect a dedicated PostgreSQL database or integrate a local AI model. Allow 10 GB of disk minimum for Docker, volumes and logs. Port 5678 must not be exposed directly — n8n only listens on 127.0.0.1:5678 and all requests go through the reverse proxy on port 443. A subdomain (for example n8n.your-domain.com) pointing to the VPS IP is required for HTTPS and inbound webhooks. Docker Engine 24+ and Docker Compose v2 are required.
Step-by-step installation
Update the VPS and install Docker
Connect via SSH and update packages:
apt update && apt upgrade -y. Then install Docker via the official script:curl -fsSL https://get.docker.com | sh. Verify the installation:docker --version && docker compose version. Create the working directory:mkdir -p /opt/n8n && cd /opt/n8n.Create the docker-compose.yaml file
Create
docker-compose.yamlwith the n8n service, a named volume for persistence and the essential environment variables. Declareimage: n8nio/n8n:latest,restart: unless-stopped, and mountn8n_data:/home/node/.n8n. Expose only on127.0.0.1:5678:5678to avoid any direct public exposure of the port.Set critical environment variables
In the service
environmentsection, declare at minimum:N8N_HOST=n8n.your-domain.com,N8N_WEBHOOK_URL=https://n8n.your-domain.com/,N8N_PROXY_HOPS=1(so n8n acceptsX-Forwarded-*headers from the reverse proxy),N8N_BASIC_AUTH_ACTIVE=true,N8N_BASIC_AUTH_USER=<your-login>andN8N_BASIC_AUTH_PASSWORD=<strong-password>. For production use, store these values in an adjacent.envfile and reference it viaenv_file: .env.Start the container
Run:
docker compose up -d. Verify the container is running:docker compose ps. Check logs:docker compose logs -f n8n. n8n is ready when the lineEditor is now accessible via: http://localhost:5678/appears in the logs. The interface is only accessible from127.0.0.1at this point — this is intentional.Configure the reverse proxy with Caddy (recommended)
Caddy is the simplest reverse proxy for n8n: it handles the Let's Encrypt certificate and renewal without additional configuration. Install Caddy (
apt install caddy) then edit/etc/caddy/Caddyfileto add:n8n.your-domain.com { reverse_proxy localhost:5678 }. Reload:systemctl reload caddy. With nginx, add in yourlocationblock:proxy_set_header X-Forwarded-Host $host;,proxy_set_header X-Forwarded-Proto $scheme;andproxy_set_header X-Real-IP $remote_addr;— without these headers, n8n rebuilds incorrect webhook URLs.Verify inbound webhooks
In the n8n editor, create a test workflow with a Webhook node. The URL displayed in production mode must be exactly
https://n8n.your-domain.com/webhook/<your-path>. Trigger the call from your local machine:curl -X POST https://n8n.your-domain.com/webhook/test -d '{}'. If the displayed URL containslocalhostor port5678, theN8N_WEBHOOK_URLvariable is not being picked up — verify the container restarted after adding the variable.Connect PostgreSQL for production
SQLite is fine for testing; in production, prefer PostgreSQL. Add a
postgres:15service to the samedocker-compose.yaml, with a dedicatedpg_datavolume. In the n8n service, add:DB_TYPE=postgresdb,DB_POSTGRESDB_HOST=postgres,DB_POSTGRESDB_DATABASE=n8n,DB_POSTGRESDB_USER=n8n,DB_POSTGRESDB_PASSWORD=<password>. Restart everything:docker compose up -d. The SQLite database is not automatically migrated — export your workflows before switching.
Hardening and queue mode
Three production reflexes: (1) Never expose port 5678 on the public interface — keep the 127.0.0.1:5678 binding. (2) Enable authentication: in v1.x, N8N_BASIC_AUTH_ACTIVE=true; in v1.27+, prefer native authentication via the interface (Settings → Security). (3) For AI or long-running workflows, switch to queue mode with a separate worker instance and a Redis queue: this isolates long executions from the interface and allows horizontal scaling without reconfiguring webhooks.
Securing persisted executions (advisory GHSA-vrv8-j27g-g7cr)
In August 2026, advisory GHSA-vrv8-j27g-g7cr revealed that three nodes write decrypted credentials into persisted execution data on error: the Strapi node (OAuth tokens written in plaintext into execution_data), the SeaTable node (API keys written into error execution logs) and the Mailcheck node (SMTP credentials persisted in execution_data). These secrets remain readable by any user with access to execution logs in the n8n interface. On an instance without pruning, they accumulate indefinitely.
Four steps to fix and prevent
Update to the version fixing the advisory
The update is the only complete fix for all three nodes. Check your current version:
docker exec n8n n8n --version. Pull the latest image and restart:docker compose pull && docker compose up -d. On the stable branch, the fix is available from version 2.35.4; on the v1 branch, from version 1.123.73. Confirm the absence of credential leaks in error logs after updating.Enable execution pruning
Add two variables in the
environmentsection of the n8n service:EXECUTIONS_DATA_PRUNE=trueandEXECUTIONS_DATA_MAX_AGE=168(7 days, expressed in hours). Restart the container:docker compose restart n8n. Pruning deletes execution data beyond the defined age. It reduces the exposure surface but does not replace the update — data exposed before pruning may already have been read.Restrict access to execution logs
In multi-user mode, verify in Settings → Users that only administrators can view execution logs. This safeguard limits propagation if sensitive data was persisted before the update. On a single-user instance exposed on a shared server, verify that port 5678 is not directly accessible from outside.
Precautionary measure before updating
On an instance not yet updated: disable or remove workflows using the Strapi, SeaTable or Mailcheck nodes. The issue only triggers on execution errors on these nodes, but the error can occur on a network cut or expired token — conditions outside your direct control.
Fatal V8 crash with more than 30 active workflows
Symptom. The n8n instance stops abruptly with the error FATAL ERROR: invalid-mark-compact are transition in Docker logs. The container restarts if restart: unless-stopped is configured, but the crash recurs as soon as load passes the same threshold. No error message in the interface: the container disappears without warning.
Cause. This error is a panic of the V8 engine (the JavaScript engine embedded in Node.js) during a garbage collection cycle. It triggers when the V8 heap is saturated — typically from 30 workflows executing simultaneously on a VPS with less than 2 GB of RAM allocated to the process. Each active workflow maintains an execution context in memory; beyond a threshold, the GC attempts a mark-compact transition on an already corrupted heap, causing the fatal crash. The problem has no automatic recovery: n8n does not have a graceful degradation mechanism at this level.
Immediate fix. Increase the V8 heap limit by adding the following environment variable in the n8n service in your docker-compose.yaml: NODE_OPTIONS=--max-old-space-size=2048. This allocates 2 GB to the V8 heap. Restart the container: docker compose restart n8n. Monitor logs for a few minutes to confirm no crash.
Structural fix. The container memory limit must match the V8 limit. If your docker-compose.yaml declares mem_limit: 1g and you pass --max-old-space-size=2048, the OOM killer kills the container before V8 can use it. Rule: allocate at least 2 GB of RAM to the VM beyond 30 active workflows, and pass NODE_OPTIONS=--max-old-space-size=1536 (leave 512 MB for the rest of the system). For installations with more than 50 workflows, prefer queue mode (separate Redis worker instance) which decouples executions from the main process and distributes memory load. Source: community.n8n.io thread #308425.
Persistent 502 Bad Gateway behind nginx
Symptom. Short requests succeed but certain workflows return 502 Bad Gateway from nginx — particularly workflows that call slow external APIs, process large data volumes or chain many nodes. The 502 occurs exactly 60 seconds after execution starts, even if n8n continues working in the background.
Cause. nginx waits by default 60 seconds for a response from the backend before closing the connection (proxy_read_timeout = 60 s). For n8n, the backend is the process executing the workflow: if execution takes more than 60 seconds, nginx closes the connection and returns a 502. n8n continues execution in the background (the workflow completes server-side), but the client never receives the response — making it appear as a failure when the result was actually produced. The behavior is exacerbated with synchronous webhooks that wait for workflow completion before responding (Respond to Webhook node at end of flow).
Fix. Add these two directives in the location block of your nginx configuration that proxies to n8n:
proxy_read_timeout 300;proxy_send_timeout 300;
The 300 second value (5 minutes) covers the vast majority of long workflows. For exceptionally long workflows (massive data imports, multi-step AI chains), raise to 600 s. Reload nginx: nginx -t && systemctl reload nginx. Note that this value does not replace the connection timeout (proxy_connect_timeout, keep it at 60 s — it only applies to the initial TCP connection establishment).
Verification. Run a deliberately slow workflow (node Wait set to 90 s, for example) and verify the response arrives beyond 60 seconds without error. If the 502 persists after modification, verify you edited the correct location block — a fragmented nginx configuration in multiple include files may have a more specific block that overrides the timeout. Source: community.n8n.io thread #274581.
Breaking changes to know (v1.27–v1.31)
Three changes in recent versions can silently break an existing instance.
Renaming of the OAuth 2.0 parameter in HTTP Request. The oauthTokenData field was renamed in versions 1.27-1.31. Requests authenticated via OAuth 2.0 in the HTTP Request node may stop working without explicit error message — n8n sends an unauthenticated request rather than raising an exception. Check each workflow using this node with OAuth credentials after an update.
Webhook URL format. The URL format generated by Webhook nodes changed in this version range. If you hard-coded webhook URLs in third-party services (Stripe, GitHub, Slack…), re-verify them after the update.
Deprecation of $item(). The $item() function available in expressions and the Function node is marked deprecated. Its replacement is $input.item for the current item or $('Node Name').item for items from a previous node. It still works in v1.x.
The complete list of breaking changes is available on the official n8n documentation.
Troubleshooting common errors
Port 5678 is unreachable. Verify the container is running (docker compose ps) and that you are listening on 127.0.0.1:5678. If testing from the local machine, curl http://127.0.0.1:5678/ should respond.
Webhooks display localhost instead of the domain. The N8N_WEBHOOK_URL variable is missing or incorrectly defined. Add N8N_WEBHOOK_URL=https://n8n.your-domain.com/ (with trailing slash) and restart: docker compose restart n8n.
Missing N8N_PROXY_HOPS: 502 error or loop. Without this variable, n8n rejects or loops on X-Forwarded-* headers. Add N8N_PROXY_HOPS=1 in the container environment.
Volume permissions error. The n8n container runs under UID 1000. If the data folder was created by root, permissions are incorrect: chown -R 1000:1000 /opt/n8n/data then docker compose restart n8n.
PostgreSQL database refuses connection. Verify the postgres service is on the same Docker network as n8n (docker network inspect <name>) and that DB_POSTGRESDB_HOST, DB_POSTGRESDB_USER and DB_POSTGRESDB_PASSWORD match exactly those defined in the postgres service.
Brutal V8 crash (FATAL ERROR: invalid-mark-compact). The V8 heap is saturated: more than 30 simultaneous workflows with insufficient RAM. Add NODE_OPTIONS=--max-old-space-size=2048 in the n8n service environment and increase the VPS RAM to 2 GB minimum. See the dedicated section above.
Persistent 502 Bad Gateway (exactly 60 s). The nginx proxy_read_timeout is at default (60 s). Raise it to 300 s in the nginx location block pointing to n8n. See the dedicated section above.
Credentials visible in execution logs. You are using one of the Strapi, SeaTable or Mailcheck nodes on a version prior to the GHSA-vrv8-j27g-g7cr advisory fix. Update to the version fixing the advisory and enable execution pruning. See the dedicated section above.
Silent SQLite rollback under load. Symptom: missing executions or inconsistent results with no visible error, only under heavy concurrency. Cause: SQLite does not support concurrent writes; beyond a few simultaneous workflows, transactions silently roll back (GitHub issue n8n #32284). Only solution: migrate to PostgreSQL.
SQLite or PostgreSQL: the choice that changes everything in production
SQLite is a testing engine, not a production engine. It works well for discovering n8n on a local machine or validating a workflow before deployment. As soon as your instance receives real traffic — multiple workflows triggered in parallel, inbound webhooks, simultaneous scheduled executions — SQLite becomes a risk. GitHub issue n8n #32284 documents silent rollbacks under concurrent load: executions disappear from history without error messages, and intermediate results are written partially. The problem is architectural: SQLite does not support concurrent writes, and n8n generates them as soon as it processes multiple workflows in parallel.
Migrating to PostgreSQL. The official docker-compose.yaml already includes a postgres service. The three-step migration preserves all your workflows:
1. Export existing workflows from the running SQLite instance: n8n export:workflow --all --output=/opt/n8n/workflows-backup.json
2. Edit the .env file to switch the engine: DB_TYPE=postgresdb with the variables DB_POSTGRESDB_HOST, DB_POSTGRESDB_DATABASE, DB_POSTGRESDB_USER and DB_POSTGRESDB_PASSWORD pointing to the postgres service in your Compose.
3. Restart everything (docker compose up -d) then import workflows: n8n import:workflow --input=/opt/n8n/workflows-backup.json.
Independent persistent storage. The PostgreSQL volume (pg_data) must be mounted on persistent storage independent of the host — a named Docker volume (volumes: at the bottom of Compose) or a bind mount on a backed-up directory. If the postgres container is destroyed and recreated without a named volume or bind, the entire database is lost. Always verify with docker volume ls that the volume exists after a docker compose up -d.
Sizing. PostgreSQL consumes approximately 50 to 100 MB of additional RAM compared to SQLite on a light load. Plan at minimum 2 GB of RAM for the n8n + PostgreSQL combination on the same VPS; 4 GB if you regularly run AI workflows or large imports.
Four critical configurations for a stable production
These four points concentrate the bulk of incidents reported on community.n8n.io and DEV Community in 2026. Each is independent and can be applied to an existing instance without a full redeployment.
1. N8N_WEBHOOK_URL is mandatory in the .env. Without this variable, n8n rebuilds the webhook URL by combining the container's internal hostname and port 5678. The result looks like http://n8n_container:5678/webhook/… — a URL unusable from any external service. Worse: the URL changes on every container recreation if you use docker compose down && docker compose up. Set N8N_WEBHOOK_URL=https://n8n.your-domain.com/ (with trailing slash) in your .env and never omit it, even on a test deployment accessible from outside.
2. Minimum 2 GB RAM, recommended 4 to 8 GB. A 1 GB RAM VPS is insufficient as soon as n8n executes several workflows simultaneously. The Linux OOM killer terminates the Node.js process with no explicit log in n8n — the container disappears and restart: unless-stopped relaunches it, creating a crash cycle that is difficult to diagnose. The minimum documented by n8n for production use is 2 GB; 4 to 8 GB are recommended for instances with AI workflows, agents or more than 20 active workflows. If you cannot increase RAM immediately, reduce the number of workers and activate queue mode to isolate long executions.
3. nginx WebSocket headers are mandatory for the editor. The n8n interface communicates with the backend via WebSocket for real-time execution and status updates. Without these two headers in the nginx location block, the WebSocket connection is rejected and the editor becomes inaccessible or freezes:
proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";
These headers are distinct from the IP forwarding headers already mentioned. Caddy handles them automatically via reverse_proxy; with nginx, they must be added explicitly. Typical symptom without them: the editor page loads but executions do not start or the interface disconnects after a few seconds.
4. Automatic execution history pruning. Without pruning, the executions table grows indefinitely. On an instance active for several months, it can reach several gigabytes and significantly degrade performance — slow execution list loading, sluggish interface, even disk saturation. Enable automatic pruning with two environment variables:
EXECUTIONS_DATA_PRUNE=trueEXECUTIONS_DATA_MAX_AGE=168
The value 168 corresponds to 7 days expressed in hours. Adjust according to your retention needs — 720 for 30 days, 336 for 14 days. These variables go in the environment section of the n8n service and take effect on the next container start.
Maintaining and scaling the instance
For updates, two approaches: Watchtower (automatic image monitoring and restart) or a manual cron task (docker pull n8nio/n8n:latest && docker compose up -d). In both cases, read release notes before a major update — the breaking changes listed above are representative of the pace of changes. Keep your workflow exports up to date in a git repository: it is the fastest backup to restore in case of incident.
For security advisories, follow the GitHub Security Advisories page of the n8n repository — stable versions publish critical patches quickly, and each advisory lists the affected versions and the minimum version fixing the issue.