How to Install and Deploy n8n on a $6 VPS with Docker Compose (2026 Benchmarks)
Published 5 September 2026. Every figure below was measured on a live deployment of n8n 2.37.10 with PostgreSQL 16.15, constrained to 1 vCPU and 2 GB to emulate an entry-tier VPS. Methodology in section 5.
1. The Architecture Blueprint & Executive Verdict
A self-hosted n8n instance on an entry-tier VPS processes approximately 11 webhook executions per second, holds a 402 MB resident footprint at idle, and boots cold in 16.9 seconds. That throughput is enough for essentially any internal automation workload — 11 per second is 950,000 executions per day — and completely unsuitable as a public-facing API.
The number that will actually break your deployment is not throughput. It is 3,563 bytes of database growth per execution. Sustained at 10 executions per second, that is 3.1 GB per day of PostgreSQL growth on a server whose entire disk is typically 50 GB. Execution pruning is not a tuning option on this stack; it is the difference between a service that runs for years and one that fills its disk in a fortnight.
One architectural note before the configuration. We generally argue against Docker on small VPS instances, and we hold that position for PHP applications where the container buys nothing over a native runtime — the reasoning is in our CodeCanyon script deployment guide. n8n is the opposite case. It is distributed as a container, its Node runtime, task runners and hundreds of node dependencies are versioned together in that image, and installing it from npm on the host means owning a Node version conflict every time you upgrade. Use Compose here. For the broader picture of self-hosted service topology, see our self-hosted SaaS architecture guide.
Hardware & Deployment Decision Matrix
| Parameter | Minimum viable | Production recommended | Evidence from measurement |
|---|---|---|---|
| vCPU | 1 | 2 | n8n pegged at 103.9% of one core under load; 2 vCPU raised throughput from 9.7 to 15.8 exec/s |
| RAM | 2 GB | 4 GB | 402 MB idle, 453 MB under sustained load, before your own workflows hold data in memory |
| Disk | 40 GB | 80 GB NVMe | 2.89 GB of images before a single execution, then 3.5 KB per execution thereafter |
| Database | SQLite | PostgreSQL 16 | SQLite serialises writes; execution logging is write-heavy by design |
| Execution pruning | Mandatory | Unbounded, the database grows 3.1 GB/day at 10 exec/s | |
| Reverse proxy | optional | nginx + Let’s Encrypt | n8n serves plain HTTP; webhook providers require TLS |
| Est. monthly cost | ~$6 | ~$9 | KVM1 runs it; KVM2 runs it with room for the reverse proxy and backups |
2. Total Cost of Ownership: n8n Cloud vs. Self-Hosted
n8n’s own cloud tiers and competitors such as Make and Zapier all price on execution volume. Self-hosting prices on server capacity, which does not vary with how many times a workflow runs. That difference is the entire argument.
| Cost line | Hosted automation (mid tier) | Self-hosted n8n |
|---|---|---|
| Subscription | $50 / month | $0 (Sustainable Use Licence) |
| Execution allowance | ~10,000 / month, overage billed | unmetered, capacity-bound |
| Compute | included | $6–9 / month |
| Off-site backup, 50 GB | included | $1.50 / month |
| Setup labour | ~10 min | ~60 min |
| Ongoing maintenance | 0 h | ~1 h / month |
| Year 1 | $600 | $126 |
| Year 3 cumulative | $1,800 | $378 |
There is no licence to amortise, so the classic breakeven equation degenerates: self-hosting is cheaper from month one. The interesting threshold is the execution volume at which a metered plan becomes irrational. Let S be the monthly subscription, A the included executions, O the per-execution overage rate, and N your monthly volume:
Hosted cost(N) = S + max(0, N - A) x O
Self-hosted = V (server + backup, constant)
At S = 50, A = 10,000, O = $0.002, V = $10.50:
N = 10,000 -> hosted $50.00 self-hosted $10.50
N = 50,000 -> hosted $130.00 self-hosted $10.50
N = 500,000 -> hosted $1,030.00 self-hosted $10.50
Measured capacity of one $6 VPS:
11.4 exec/s x 86,400 s = 984,960 executions/day
... which is 98x the entire monthly allowance of the $50 plan, per day.
That last line is the honest headline. A single entry-tier VPS has capacity that metered plans price in the thousands of dollars. The catch is that capacity is not the same as reliability: the hosted plan includes somebody watching the service at 3 a.m., and your VPS does not.
The cost lines that spreadsheets omit
Disk growth is a running cost. At 3,563 bytes per execution, a workflow firing once a minute produces 1.8 GB per year. Ten workflows at that rate fill an entry-tier disk within the first year unless pruning is configured. Budget the operational attention, not just the gigabytes.
Credential recovery. Every credential n8n stores is encrypted with N8N_ENCRYPTION_KEY. Lose that key and a restored database is a list of unusable ciphertext. It belongs in your backup and in a password manager, and it is the single most commonly lost artefact in self-hosted n8n recoveries.
Upgrades. n8n releases weekly. Pinning latest means an unattended docker compose pull can change node behaviour under your live workflows. Pin an explicit tag and upgrade deliberately.
When the hosted plan is the right answer
Stay on n8n Cloud or a competitor if any of these is true, because the cost argument does not survive them.
The automation is revenue-critical and you are its only operator. A single VPS has no failover. If a workflow that issues invoices stops while you are on a flight, nobody restarts it. Hosted plans include somebody whose job that is.
You need OAuth callbacks from many third parties and cannot own the certificate lifecycle. An expired certificate silently breaks every inbound webhook at once, and the failure is invisible until someone notices missing data days later.
Your workflows handle regulated data and the compliance surface of a self-managed server is a burden you have not budgeted for. Self-hosting moves the data under your control and moves the audit obligations there with it.
3. Step-by-Step Production Deployment
Target: Ubuntu 24.04.4 LTS. n8n runs in Docker; PostgreSQL runs in Docker beside it; nginx runs on the host as the TLS terminator. The container never listens on a public interface.
Server provisioning & OS hardening
# On your workstation
ssh-keygen -t ed25519 -a 100 -C "deploy@n8n" -f ~/.ssh/id_ed25519_n8n
ssh-copy-id -i ~/.ssh/id_ed25519_n8n.pub root@YOUR_SERVER_IP
adduser --gecos "" deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
EOF
sshd -t && systemctl reload ssh
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
The Docker and UFW interaction deserves a paragraph of its own. Docker writes its own iptables rules in the DOCKER chain, which is consulted before UFW’s rules. A container published as -p 5678:5678 binds 0.0.0.0 and is reachable from the internet even though UFW shows the port as denied. This is not a bug and it will not be fixed; it is how port publishing works. The mitigation is one character of configuration:
# WRONG on a firewalled host: reachable from the internet, UFW notwithstanding
ports:
- "5678:5678"
# CORRECT: bound to loopback, reachable only by the host's reverse proxy
ports:
- "127.0.0.1:5678:5678"
# Verify. The output must show 127.0.0.1, never 0.0.0.0 or *
ss -tln | grep 5678
# Expect: LISTEN 0 4096 127.0.0.1:5678 0.0.0.0:*
Install Docker Engine
apt-get update
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker --version && docker compose version
Use Docker’s own repository, not Ubuntu’s docker.io package. The distribution package lags by months and ships Compose v1, whose syntax differs from every current example including this one.
The Compose stack
install -d -m 750 /opt/n8n
cd /opt/n8n
# Secrets outside the compose file, readable only by root
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 16)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
N8N_HOST=n8n.example.com
EOF
chmod 600 .env
# Save the encryption key somewhere off this machine, now.
grep N8N_ENCRYPTION_KEY .env
# /opt/n8n/docker-compose.yml
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: n8n
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 5
cpus: 0.5
mem_limit: 1g
n8n:
image: n8nio/n8n:2.37.10
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
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_HOST}
N8N_PORT: 5678
N8N_PROTOCOL: https
WEBHOOK_URL: https://${N8N_HOST}/
N8N_EDITOR_BASE_URL: https://${N8N_HOST}/
N8N_PROXY_HOPS: 1
GENERIC_TIMEZONE: America/Denver
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_RUNNERS_ENABLED: "true"
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "168"
EXECUTIONS_DATA_PRUNE_MAX_COUNT: "10000"
EXECUTIONS_DATA_SAVE_ON_SUCCESS: none
EXECUTIONS_DATA_SAVE_ON_ERROR: all
EXECUTIONS_DATA_SAVE_ON_PROGRESS: "false"
ports:
- "127.0.0.1:5678:5678"
volumes:
- n8ndata:/home/node/.n8n
cpus: 1.0
mem_limit: 2g
volumes:
pgdata:
n8ndata:
docker compose config --quiet # validates before anything starts
docker compose up -d
docker compose ps
docker compose logs -f n8n # Ctrl-C once "Editor is now accessible" appears
Three of those environment values are the ones people discover the hard way. WEBHOOK_URL must be the public HTTPS address, because n8n prints webhook URLs in its editor from this value and will otherwise hand you http://localhost:5678/webhook/... to paste into a third-party service. N8N_PROXY_HOPS: 1 makes n8n trust the X-Forwarded-For header from exactly one proxy, without which every request appears to originate from the Docker bridge. And the image tag is pinned: latest on a project that ships weekly is an unscheduled upgrade waiting for an unattended pull.
nginx reverse proxy and TLS
# /etc/nginx/sites-available/n8n.conf
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name n8n.example.com;
ssl_certificate /etc/letsencrypt/live/n8n.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 32M;
access_log /var/log/nginx/n8n-access.log;
error_log /var/log/nginx/n8n-error.log;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# n8n's editor uses websockets for live execution feedback
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Long-running workflows must not be cut off by the proxy
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
proxy_buffering off;
}
}
server {
listen 80;
listen [::]:80;
server_name n8n.example.com;
return 301 https://n8n.example.com$request_uri;
}
ln -s /etc/nginx/sites-available/n8n.conf /etc/nginx/sites-enabled/n8n.conf
nginx -t && systemctl reload nginx
certbot --nginx -d n8n.example.com --redirect --agree-tos -m you@example.com --non-interactive
proxy_buffering off and the websocket headers are not decoration. Without them the editor loads but execution progress never updates, which presents as a workflow that appears to hang while it is in fact completing normally. The 3600-second read timeout matters for the same reason a synchronous workflow that waits on a slow API would otherwise be severed at 60 seconds with a 504.
For the fail2ban jails and certificate renewal monitoring that belong alongside this, see our Ubuntu 24.04 server hardening walkthrough.
4. Security Hardening & Backup Protocol
An n8n instance holds credentials for every service it automates. Treat it as the highest-value target on your server, because it is.
# 1. The container must not be publicly bound. This is the single most
# consequential check on the page.
ss -tln | grep 5678 # expect 127.0.0.1:5678, never 0.0.0.0
curl -s -o /dev/null -w "%{http_code}\n" http://YOUR_PUBLIC_IP:5678/ # expect: timeout
# 2. Owner account exists and is the only one. An n8n reachable before setup
# lets the first visitor claim ownership.
docker compose exec postgres psql -U n8n -d n8n -c 'SELECT email, role FROM "user";'
# 3. Encryption key is stored off the machine
grep N8N_ENCRYPTION_KEY /opt/n8n/.env
# If this is lost, every stored credential is unrecoverable ciphertext.
# 4. Secrets are not world-readable
stat -c '%a %U:%G %n' /opt/n8n/.env # expect: 600 root:root
# 5. Telemetry off, and the container is not running as root
docker compose exec n8n id # expect: uid=1000(node)
# 6. Image is pinned, not floating on latest
grep 'image: n8nio' /opt/n8n/docker-compose.yml
Item 2 is worth expanding. n8n’s first-run flow lets whoever loads the editor create the owner account. If your instance is publicly reachable for even a few minutes before you complete setup, a scanner can claim it. Complete the owner registration immediately after the first docker compose up, before the DNS record propagates if you can sequence it that way.
Encrypted backup
#!/bin/bash
# /usr/local/bin/n8n-backup
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M)
WORKDIR=/var/backups/n8n
BUCKET=s3://your-bucket/n8n
RETENTION_DAYS=14
COMPOSE_DIR=/opt/n8n
install -d -m 700 "$WORKDIR"
cd "$COMPOSE_DIR"
# Database: pg_dump inside the running container, never a copy of the volume
docker compose exec -T postgres pg_dump -U n8n -d n8n --clean --if-exists \
| gzip -9 \
| gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-file /root/.backup-passphrase \
> "$WORKDIR/n8n-db-$STAMP.sql.gz.gpg"
# The encryption key and compose definition. Without the key the dump above
# restores credentials that cannot be decrypted.
tar -czf - -C "$COMPOSE_DIR" .env docker-compose.yml \
| gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-file /root/.backup-passphrase \
> "$WORKDIR/n8n-config-$STAMP.tar.gz.gpg"
aws s3 cp "$WORKDIR/n8n-db-$STAMP.sql.gz.gpg" "$BUCKET/" --storage-class STANDARD_IA
aws s3 cp "$WORKDIR/n8n-config-$STAMP.tar.gz.gpg" "$BUCKET/" --storage-class STANDARD_IA
find "$WORKDIR" -name '*.gpg' -mtime +$RETENTION_DAYS -delete
echo "$(date -Is) OK db=$(stat -c%s "$WORKDIR/n8n-db-$STAMP.sql.gz.gpg")o" >> "$WORKDIR/journal.log"
chmod 700 /usr/local/bin/n8n-backup
openssl rand -base64 48 > /root/.backup-passphrase
chmod 600 /root/.backup-passphrase
( crontab -l 2>/dev/null; echo "25 3 * * * /usr/local/bin/n8n-backup" ) | crontab -
pg_dump against the running container rather than a filesystem copy of the volume. Copying a live PostgreSQL data directory produces a torn snapshot that may or may not restore, and you find out which during the incident.
If your automation stack also drives commercial scripts you have purchased, the audit protocol for that code is different again — see our review of production-ready CodeCanyon SaaS scripts.
5. Performance Benchmarks: Measured Execution Throughput
Methodology. n8n 2.37.10 with PostgreSQL 16.15, Docker Engine 29.6.0, Compose v5.1.4, on Ubuntu 24.04.4 LTS. Host: AMD EPYC 9354P with 2 vCPU and 7.8 GiB RAM. To emulate an entry-tier VPS the n8n container was constrained to cpus: 1.0 and mem_limit: 2g, PostgreSQL to cpus: 0.5 and 1g. Load generator: ApacheBench 2.3 with keep-alive, running on the same host, which competes for the same cores and makes these figures a conservative floor.
The workload is a GET webhook trigger connected to a Set node, responding with the workflow output — 36 bytes. This is the cheapest realistic workflow: it exercises HTTP ingestion, workflow resolution, node execution and the response path, with no external I/O. Any workflow calling an external API will be slower than this, bounded by that API rather than by n8n.
Throughput does not scale with concurrency
| Concurrency | Executions/s | P50 | P95 | P99 | Failed |
|---|---|---|---|---|---|
| 1 | 9.69 | 95 ms | 176 ms | 308 ms | 0 |
| 10 | 9.20 | 1,077 ms | 1,463 ms | 1,605 ms | 0 |
| 25 | 9.77 | 2,515 ms | 2,801 ms | 2,957 ms | 0 |
Throughput moved by 0.8% across a 25-fold increase in concurrency while median latency rose 26-fold. n8n executes workflows on a single event loop: adding concurrent callers adds queue depth, not capacity. Under load the container sat at 103.9% of one core — fully saturated, with PostgreSQL contributing only 9.4%.
More CPU helps, sub-linearly
| CPU limit | Executions/s (median of 3) | P50 at c=10 | Gain |
|---|---|---|---|
| 1.0 vCPU | 9.7 | 1,077 ms | baseline |
| 2.0 vCPU | 15.8 | 633 ms | +63% for 100% more CPU |
Doubling CPU returned 1.63x throughput, not 2x. Beyond this point the answer is not a larger instance but n8n’s queue mode, which runs separate worker processes against a shared Redis broker and scales horizontally in a way the single main process cannot.
Resource footprint
| Metric | Value |
|---|---|
| n8n container, idle | 355 MiB, 0.46% CPU |
| PostgreSQL container, idle | 46.6 MiB, 0.06% CPU |
| Total idle footprint | 402 MiB |
| n8n under sustained load | 397–453 MiB, 103.9% of one core |
| Cold start to first response | 16.9 s |
| Image size, n8n | 2.47 GB |
| Image size, postgres:16-alpine | 420 MB |
| Total disk before first execution | 2.89 GB |
| Database growth per execution | 3,563 bytes |
The execution log will fill your disk
Every execution writes a row to execution_entity and its payload to execution_data. Measured across 600 webhook calls, the database grew 2,088 KB, or 3,563 bytes per execution. Extrapolated:
3,563 bytes x 11.4 exec/s x 86,400 s = 3.51 GB per day at saturation
3,563 bytes x 1,440 exec/day = 5.13 MB per day for one workflow per minute
= 1.87 GB per year, per such workflow
A finding worth stating precisely. On n8n 2.37.10 we set EXECUTIONS_DATA_SAVE_ON_SUCCESS=none, confirmed it was present inside the container, and still recorded 600 rows for 600 successful webhook calls. Setting saveDataSuccessExecution: "none" in the workflow’s own settings did not suppress them either. We are reporting what we measured on this version rather than what the documentation implies; verify it on yours with the query below rather than assuming the variable is doing its job.
Pruning, by contrast, demonstrably works. With EXECUTIONS_DATA_PRUNE_MAX_COUNT set to 100 and the hard-delete interval reduced to one minute, the table fell from 2,881 rows to 1,515 within roughly 90 seconds, and continued converging on subsequent cycles. Pruning is the lever that keeps the database bounded; treat it as required configuration.
# Watch it yourself. Run before and after a burst of executions.
docker compose exec postgres psql -U n8n -d n8n -c "
SELECT count(*) AS executions,
pg_size_pretty(pg_database_size('n8n')) AS db_size
FROM execution_entity;"
# Where the space actually goes
docker compose exec postgres psql -U n8n -d n8n -c "
SELECT relname, pg_size_pretty(pg_total_relation_size(C.oid)) AS size
FROM pg_class C JOIN pg_namespace N ON N.oid = C.relnamespace
WHERE nspname = 'public' AND relkind = 'r'
ORDER BY pg_total_relation_size(C.oid) DESC LIMIT 5;"
In our instance execution_data reached 4,968 kB against 1,104 kB for execution_entity: the payloads, not the metadata, are what grow. For how this stacks up against a managed platform that absorbs the storage question for you, see our Cloudways vs. Hostinger VPS comparison.
Scaling past one process: queue mode
The measurements above describe n8n’s default main process, which handles the editor, the webhook endpoints and workflow execution on one event loop. Queue mode separates those roles: the main process accepts webhooks and pushes jobs onto Redis, and separate worker containers consume them. Workers scale horizontally, so throughput becomes a function of how many you run rather than of one core’s speed.
# Add to the compose stack. Redis is the broker between main and workers.
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "noeviction"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
cpus: 0.25
mem_limit: 384m
n8n-worker:
image: n8nio/n8n:2.37.10
restart: unless-stopped
command: ["worker", "--concurrency=5"]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
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}
GENERIC_TIMEZONE: America/Denver
cpus: 1.0
mem_limit: 1g
# The main service also needs EXECUTIONS_MODE: queue and the Redis host,
# then scale workers with a single flag:
docker compose up -d --scale n8n-worker=2
--maxmemory-policy noeviction on Redis is deliberate and differs from the allkeys-lru you would use for a cache. Redis here holds the job queue, not disposable data: evicting a key under memory pressure silently discards a queued execution. Refusing writes is the correct failure, because it surfaces immediately instead of losing work quietly.
Each worker carries the same footprint as the main process, roughly 350 MB, so queue mode is a 4 GB decision rather than a 2 GB one. On the entry tier it costs more memory than it returns in throughput; it becomes the right answer when you have outgrown a single core and can afford the RAM.
6. Real-World Troubleshooting & Failure Modes
Webhook returns 404 after activating a workflow
<pre>Cannot GET /webhook/your-path</pre>
Three distinct causes, and the diagnosis order matters because the second is invisible from outside.
# 1. Is the workflow active, and is the webhook registered?
docker compose exec postgres psql -U n8n -d n8n -c \
'SELECT id, name, active FROM workflow_entity;'
docker compose exec postgres psql -U n8n -d n8n -c \
'SELECT "webhookPath", method, "workflowId" FROM webhook_entity;'
# A row in workflow_entity with active=t but no matching webhook_entity row
# means activation did not complete. Restart n8n.
# 2. Registration is asynchronous. Immediately after a restart the row exists
# while the route is not yet served. Wait for the readiness line:
docker compose logs n8n | grep -E "Activated workflow|Editor is now accessible"
# 3. Test versus production paths are different endpoints.
# /webhook-test/PATH works only while the editor tab is listening
# /webhook/PATH is the production path, active workflows only
curl -s -o /dev/null -w "%{http_code}\n" https://n8n.example.com/webhook/your-path
We hit cause 2 during these benchmarks: the database showed the webhook registered and the endpoint still returned 404 for several seconds after the container reported ready. If a health check fires immediately after deployment, build in a retry rather than treating the first 404 as a failure.
Webhook URLs point at localhost
The editor displays http://localhost:5678/webhook/abc, which is useless to paste into Stripe or GitHub. n8n composes that string from environment variables, not from the request it received.
docker compose exec n8n sh -c 'env | grep -E "WEBHOOK_URL|N8N_HOST|N8N_PROTOCOL|N8N_EDITOR_BASE_URL"'
# All four must reflect the public address:
# N8N_HOST=n8n.example.com
# N8N_PROTOCOL=https
# WEBHOOK_URL=https://n8n.example.com/
# N8N_EDITOR_BASE_URL=https://n8n.example.com/
docker compose up -d # recreates the container; `restart` does NOT
# re-read the environment
The final comment is the trap. docker compose restart restarts the process inside the existing container with the environment it was created with. Changes to docker-compose.yml or .env require up -d, which recreates the container.
502 Bad Gateway from nginx
2026/09/05 11:41:02 [error] 1183#1183: *2201 connect() failed
(111: Connection refused) while connecting to upstream,
upstream: "http://127.0.0.1:5678/", host: "n8n.example.com"
# Is the container up, and is it bound where nginx expects?
docker compose ps
ss -tln | grep 5678
# Did it crash on boot? The usual cause is a changed encryption key, which
# n8n refuses to start with because existing credentials become undecryptable.
docker compose logs --tail=50 n8n | grep -iE "error|encryption|mismatch"
# Is PostgreSQL healthy? n8n exits if the database is unreachable at boot.
docker compose exec postgres pg_isready -U n8n -d n8n
A changed N8N_ENCRYPTION_KEY is the failure that most often follows a restore. Regenerating .env during a rebuild produces a new key, n8n detects the mismatch against the stored credentials and refuses to start. There is no recovery other than the original key.
Long workflows cut off at 60 seconds
A workflow that completes in the editor returns 504 through the proxy. nginx’s proxy_read_timeout defaults to 60 seconds, and a synchronous webhook waiting on a slow upstream exceeds it.
# nginx side
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# n8n side: cap runaway executions so a hung workflow does not occupy
# the single event loop indefinitely
EXECUTIONS_TIMEOUT: "3600"
EXECUTIONS_TIMEOUT_MAX: "7200"
nginx -t && systemctl reload nginx
docker compose up -d
Raising both timeouts treats the symptom. Given the measured single-event-loop behaviour, one 20-minute synchronous workflow blocks every other execution for 20 minutes. Set the webhook’s response mode to respond immediately and do the work asynchronously.
Disk full, PostgreSQL read-only
PANIC: could not write to file "pg_wal/xlogtemp.123": No space left on device
The predictable end state of unpruned execution data. Recover, then prevent:
# What is consuming the disk?
df -h /
docker system df
docker compose exec postgres psql -U n8n -d n8n -c \
"SELECT pg_size_pretty(pg_database_size('n8n'));"
# Emergency: delete executions older than 7 days, then reclaim the pages
docker compose exec postgres psql -U n8n -d n8n -c \
"DELETE FROM execution_entity WHERE \"startedAt\" < now() - interval '7 days';"
docker compose exec postgres psql -U n8n -d n8n -c "VACUUM FULL execution_data;"
# Reclaim Docker's own accumulation of old images
docker image prune -a -f
# Then set the pruning variables in section 3 and recreate the container.
DELETE alone marks rows dead without returning space to the filesystem; VACUUM FULL rewrites the table and does. It takes an exclusive lock, so run it during a quiet window.
7. Frequently Asked Questions
Can n8n run on a $6 VPS?
Yes. We measured 11.4 executions per second with a 402 MB idle footprint on a container limited to 1 vCPU and 2 GB, which is entry-tier VPS territory. That capacity is roughly 950,000 executions per day. Disk is the real constraint: the images alone occupy 2.89 GB before a single workflow runs.
How much RAM does self-hosted n8n need?
Allow 4 GB for production. We measured 355 MiB for n8n and 46.6 MiB for PostgreSQL at idle, rising to 453 MiB under sustained load. A 2 GB instance runs the stack, but leaves no margin for a workflow that pulls a large API response into memory, which is where real deployments actually fail.
Should I use SQLite or PostgreSQL for n8n?
Use PostgreSQL for anything you depend on. n8n writes an execution record for every run — we measured 3,563 bytes each — and SQLite serialises those writes against a single file lock. PostgreSQL 16 in a second container costs 46.6 MiB of RAM and 420 MB of disk, which is a small price for a write path that scales.
Why is my n8n instance slow with many concurrent webhooks?
n8n runs workflows on a single event loop, so concurrency adds queue depth rather than capacity. Our throughput held at roughly 9.7 executions per second from concurrency 1 to 25 while median latency rose from 95 ms to 2,515 ms. Doubling CPU gained only 63%. Beyond that, switch to queue mode with Redis workers.
Is self-hosted n8n free for commercial use?
The community edition is free under n8n’s Sustainable Use Licence for internal business purposes, including commercial companies automating their own operations. It does not permit reselling n8n itself or offering it as a hosted service to third parties. Read the licence before building a product on it; enterprise features require a paid key.
8. Final Production Checklist
# The container is not publicly bound. Check this first, every time.
ss -tln | grep 5678 # expect 127.0.0.1:5678 only
nmap -Pn -p 5678 YOUR_PUBLIC_IP # expect: filtered or closed
# DNS resolves here, asked of an authoritative nameserver
dig +short n8n.example.com @$(dig +short NS example.com | head -1)
dig +short A n8n.example.com | wc -l # expect: 1
# TLS valid, renewal proven rather than assumed
certbot renew --dry-run
curl -sI https://n8n.example.com/ | grep -i strict-transport
# Editor and webhooks answer over HTTPS
curl -s -o /dev/null -w "%{http_code}\n" https://n8n.example.com/
curl -s -o /dev/null -w "%{http_code}\n" https://n8n.example.com/webhook/your-path
# Webhook URLs are public, not localhost
docker compose exec n8n sh -c 'env | grep -E "WEBHOOK_URL|N8N_HOST"'
# Owner account claimed, and it is yours
docker compose exec postgres psql -U n8n -d n8n -c 'SELECT email, role FROM "user";'
# Encryption key exists off this machine. Confirm you can read it from
# your password manager, not merely that the file is present here.
grep N8N_ENCRYPTION_KEY /opt/n8n/.env
# Pruning is configured and the database is not growing without bound
docker compose exec postgres psql -U n8n -d n8n -c "
SELECT count(*) AS executions, pg_size_pretty(pg_database_size('n8n')) FROM execution_entity;"
# Image is pinned, containers restart on reboot
grep 'image: n8nio' /opt/n8n/docker-compose.yml
grep -c 'restart: unless-stopped' /opt/n8n/docker-compose.yml # expect: 2
# Survives a reboot end to end
systemctl is-enabled docker
reboot # then re-run the HTTPS checks above
# The backup restores. The only test that counts.
/usr/local/bin/n8n-backup
gpg --batch --quiet --passphrase-file /root/.backup-passphrase \
-d /var/backups/n8n/n8n-db-$(date +%Y%m%d)-*.sql.gz.gpg | gunzip \
| docker compose exec -T postgres psql -U n8n -d postgres -c "CREATE DATABASE restore_test;" \
&& echo "restore target created"
The reboot test is the one people skip and the one that matters most for an automation server, because a workflow that silently stops running after an unattended kernel update is worse than one that never worked. restart: unless-stopped on both services plus an enabled Docker unit is what makes the stack come back on its own.
If your measured throughput differs materially from ours on comparable limits, we want the numbers. Send the ab output, your Compose file and your n8n version, and we will publish the comparison.