DEV Community

M Hossein
M Hossein

Posted on

Multi-VPS Distributed n8n Cluster on Ubuntu 24.04 with an IPIP Tunnel

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

Launch the core stack on VPS 1:

docker compose up -d

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Launch the worker node:

docker compose up -d

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Update these properties to ensure safety:

PermitEmptyPasswords no
PermitRootLogin prohibit-password
PasswordAuthentication no
MaxAuthTries 3

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)