When running automation tools like n8n for personal or production workflows, you quickly run into resource walls if you stick to the default configuration. A standalone SQLite setup can experience performance drops under load, and heavy processing inside JavaScript/Python nodes can easily stall the primary web service or exhaust single-server resources.
In this guide, we will step through how to architecture a decentralized, enterprise-grade n8n cluster split across two separate Ubuntu 24.04 VPS instances using the open-source community edition.
We will completely isolate the execution engine from the control plane using a lightweight IPIP (IP-in-IP) tunnel, proxying incoming traffic via Nginx, and managing tasks asynchronously through a PostgreSQL 16 and Redis 7 backend backbone.
The Target Architecture
Instead of over-engineering the infrastructure with complex orchestrators, we split our environment into two lightweight planes over a private network connection:
- VPS 1: The Control Plane (Main Node) – Handles the web UI, external webhook endpoints via Nginx SSL, the PostgreSQL database, and the Redis message broker.
- VPS 2: The Execution Plane (Worker Node) – A stateless compute node dedicated purely to listening to the Redis queue, crunching complex data, and reporting back via the private tunnel network.
Step 1: Base Server Prep & Official Docker Installation
We begin by prepping both Ubuntu 24.04 LTS machines with baseline utilities, Python dependencies, and the official Docker engine repository.
Run these commands on both servers:
# Update base system packages
sudo apt update && sudo apt upgrade -y
# Install essential dependencies
sudo apt install -y curl wget unzip git vim build-essential net-tools
# Set up Python runtimes
sudo apt install -y python3 python3-pip python3-dev
Installing Docker using the Modern Apt Sources Method
Ubuntu 24.04 drops legacy apt-key procedures. We install the official engine using the modern .sources system:
# Add Docker's official GPG key
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Register the Docker repository configuration using DEB822 format
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: \$(. /etc/os-release && echo "\${UBUNTU_CODENAME:-\$VERSION_CODENAME}")
Components: stable
Architectures: \$(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
# Install the engine components and the compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Step 2: Provisioning a Persistent Private IPIP Tunnel
To safely expose PostgreSQL and Redis across our servers without leaving open holes to the public internet, we build a private point-to-point network using an kernel-level IPIP tunnel.
We want VPS 1 (Main) to live on private IP 10.0.0.1 and VPS 2 (Worker) on 10.0.0.2.
Create the following script named setup-tunnel.sh on both nodes:
#!/bin/bash
# Usage: sudo ./setup-tunnel.sh <MAIN_PUBLIC_IP> <WORKER_PUBLIC_IP> --install
if [ "$EUID" -ne 0 ]; then
echo "❌ Please run as root (sudo)."
exit 1
fi
MAIN_IP=$1
WORKER_IP=$2
INSTALL_PERSISTENCE=false
if [ "$3" == "--install" ]; then
INSTALL_PERSISTENCE=true
fi
configure_tunnel() {
echo "⚙️ Initializing IPIP Tunnel Configuration..."
modprobe ipip
if ip link show tun0 >/dev/null 2>&1; then
ip link set tun0 down
ip tunnel del tun0
fi
if ip addr | grep -q "$MAIN_IP"; then
echo "🖥️ Configuring: MAIN VPS"
ip tunnel add tun0 mode ipip remote "$WORKER_IP" local "$MAIN_IP" ttl 255
ip addr add 10.0.0.1/30 dev tun0
ip link set tun0 up
echo "✅ Tunnel 'tun0' is UP. Local private IP: 10.0.0.1"
elif ip addr | grep -q "$WORKER_IP"; then
echo "👷 Configuring: WORKER VPS"
ip tunnel add tun0 mode ipip remote "$MAIN_IP" local "$WORKER_IP" ttl 255
ip addr add 10.0.0.2/30 dev tun0
ip link set tun0 up
echo "✅ Tunnel 'tun0' is UP. Local private IP: 10.0.0.2"
else
echo "❌ Error: Public IP does not match local interfaces."
exit 1
fi
}
configure_tunnel
if [ "$INSTALL_PERSISTENCE" = true ]; then
SCRIPT_PATH="/usr/local/bin/ipip-tunnel-setup.sh"
cp "$0" "$SCRIPT_PATH"
chmod +x "$SCRIPT_PATH"
cat <<EOF > /etc/systemd/system/ipip-tunnel.service
[Unit]
Description=Maintain Persistent IPIP Tunnel between n8n Nodes
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=$SCRIPT_PATH $MAIN_IP $WORKER_IP
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable ipip-tunnel.service
echo "🌟 Systemd persistence initialized."
fi
Execute this script on both servers using your public IPs to establish the link permanently:
chmod +x setup-tunnel.sh
sudo ./setup-tunnel.sh <MAIN_PUBLIC_IP> <WORKER_PUBLIC_IP> --install
Verify the interface link by runnning a quick check from your main node: ping -c 3 10.0.0.2.
Step 3: Deploying the Control Plane (VPS 1)
With our private tunnel channel open, we configure the orchestration layer on the Main server. Create a directory at /home/n8n/ to hold your configurations.
1. The Environment Setup (/home/n8n/.env)
N8N_HOST=n8n.your-domain.com
N8N_PORT=5678
GENERIC_TIMEZONE=Europe/Vienna
N8N_ENCRYPTION_KEY=generate_a_random_long_string_here
POSTGRES_USER=n8n_admin
POSTGRES_PASSWORD=choose_a_secure_db_password_here
POSTGRES_DB=n8n_storage
REDIS_PASSWORD=choose_a_secure_redis_password_here
N8N_DIAGNOSTICS_ENABLED=false
N8N_PERSONALIZATION_ENABLED=false
2. The Infrastructure Deployment (/home/n8n/docker-compose.yml)
Note that modern Docker Compose specs omit the obsolete version string attribute. We explicitly map database and queue services strictly onto the secure tunnel interface IP (10.0.0.1), ensuring zero external exposures.
services:
postgres:
image: postgres:16-alpine
container_name: n8n_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "10.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1G
redis:
image: redis:7-alpine
container_name: n8n_redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- "10.0.0.1:6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 5
n8n_main:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n_core
restart: unless-stopped
ports:
- "127.0.0.1:${N8N_PORT}:5678"
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
- QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
- N8N_HOST=${N8N_HOST}
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${N8N_HOST}/
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=168
- OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_PERSONALIZATION_ENABLED=false
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
postgres_data:
redis_data:
n8n_data:
Launch the core stack on VPS 1:
docker compose up -d
Step 4: Configure Nginx Reverse Proxy with WebSockets
n8n uses persistent WebSocket connections to update progress inside your browser canvas UI. If Nginx proxy buffering isn't optimized, your UI will become unreadably laggy or constantly drop connections.
Create an Nginx server block at /etc/nginx/sites-available/n8n:
server {
listen 80;
listen [::]:80;
server_name n8n.your-domain.com;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:5678;
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;
# WebSocket support configurations
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Disable buffering to avoid streaming timeouts
proxy_buffering off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}
}
Enable the configuration block and use Certbot to register a Let's Encrypt SSL certificate automatically:
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# Run Certbot to handle automatic HTTP -> HTTPS redirects
sudo certbot --nginx -d n8n.your-domain.com
Step 5: Deploying the Execution Worker (VPS 2)
The execution worker node is completely stateless, meaning it needs no persistent storage databases. It only requires a minimal Compose setup pointing back across the tunnel to VPS 1.
1. Create /home/n8n/.env on VPS 2
Copy your environment details from VPS 1 to ensure that credential cryptographic keys and database definitions match exactly:
GENERIC_TIMEZONE=Europe/Vienna
N8N_ENCRYPTION_KEY=6uwhZTfJM80Hysuow5ge27r8xLtkFNWq
POSTGRES_USER=n8n_admin
POSTGRES_PASSWORD=WfVKtYp37bMDT6daPaTDSUYucBhfUw32
POSTGRES_DB=n8n_storage
REDIS_PASSWORD=8sHkQV3lpEc0yD4emUSn0oka9PrpdJ1h
2. Create /home/n8n/docker-compose.yml on VPS 2
We append command: worker to shift the container's operational blueprint into an isolated worker state:
services:
n8n_worker:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n_worker_remote
command: worker
restart: unless-stopped
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=10.0.0.1
- QUEUE_BULL_REDIS_PORT=6379
- QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=10.0.0.1
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
deploy:
resources:
limits:
memory: 2G
Launch the worker node:
docker compose up -d
Step 6: Server Hardening & Security Policies
⚠️ The Golden Rule of SSH Configuration: Always test your new configurations in an entirely separate terminal window before closing your current active shell connection so you don't accidentally lock yourself out.
1. Restrict Network Interfaces via UFW
Configure standard server firewalls to lock out random external pings.
On VPS 1 (Main Node):
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp # Change if using a custom SSH port
sudo ufw allow in on tun0
sudo ufw enable
On VPS 2 (Worker Node):
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow in on tun0
sudo ufw enable
2. Prevent Docker from Bypassing Your Firewall
Docker manipulates system iptables directly. To guarantee it respects your interface limits, explicitly verify your Docker routing daemon defaults:
sudo nano /etc/docker/daemon.json
Ensure "iptables": true is explicitly present, then apply a restart using sudo systemctl restart docker.
3. Polish the SSH Configuration
Enforce cryptographic authentication over standard passwords:
sudo nano /etc/ssh/sshd_config
Update these properties to ensure safety:
PermitEmptyPasswords no
PermitRootLogin prohibit-password
PasswordAuthentication no
MaxAuthTries 3
Test using sudo sshd -t and trigger a refresh via sudo systemctl restart ssh.
Verification & Monitoring
Because the multi-node worker management interface is part of n8n's enterprise plan, you can easily verify that your cluster is healthy from the command line using your Redis backend.
Execute a client list inspection inside your Redis service on VPS 1:
docker exec -it n8n_redis redis-cli -a <YOUR_REDIS_PASSWORD> client list
Look for the worker's network mapping signature in the connection stream output:
id=51 addr=10.0.0.2:35510 ... name=bull:am9icw== ... cmd=brpoplpush
The presence of the brpoplpush command originating from your tunnel gateway IP 10.0.0.2 means your stateless execution worker is successfully listening to your queue broker. Your secure, distributed n8n cluster is now fully configured and running smoothly.
Top comments (0)