Back to Article List

Use private networking for n8n, Postgres and Redis on a VPS

Use private networking for n8n, Postgres and Redis on a VPS - Use private networking for n8n, Postgres and Redis on a VPS

Does port 5432 on your n8n server answer from the internet? Run ss -ltn on the box and look for 0.0.0.0:5432 or *:6379; if Postgres or Redis are in Docker with a plain ports: entry, they're published on every interface the host has, and Docker's iptables rules sit in front of UFW, so a ufw deny 5432 you added afterwards doesn't block them. I found that out with a Redis container that had answered on its public IP for eleven days. This guide moves the datastores to a second server that talks to n8n only over a private network, with the publish syntax, firewall rules and Postgres access rules that keep it that way.

Two-server layout over a private network

Server A runs n8n main and Caddy and has a public IP plus a private one, 10.20.0.11. Server B runs Postgres and Redis and has the private IP 10.20.0.12; it can keep a public IP for SSH and updates, but nothing on it listens there. Both sit in the same location on a private /24, which every LumaDock VPS in a location gets, and the firewall management in the panel is where I block the public side of server B outright. The private interface is ens10 in the commands below; yours may be eth1 or enp7s0, check with ip -br addr.

Ports that cross the private network: 5432 from A to B, 6379 from A to B if the instance runs in queue mode, and nothing from B to A. Redis is only in the picture in queue mode, so on a regular-mode instance skip every Redis line and the layout is n8n on A and Postgres on B.

Server B: Postgres published on the private IP only

Inside a container Postgres can listen on all its interfaces; the container's interfaces are its own bridge address, not the host's. What decides who can reach it is the host-side publish, and Docker's port syntax takes an IP: 10.20.0.12:5432:5432 publishes only on the private address. That one line does more than any listen_addresses setting, and it's the line the old version of this setup got wrong.

services:
  postgres:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: n8n
    command: postgres -c hba_file=/etc/postgresql/pg_hba.conf
    ports:
      - "10.20.0.12:5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./pg_hba.conf:/etc/postgresql/pg_hba.conf:ro

  redis:
    image: redis:7
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --protected-mode yes
    ports:
      - "10.20.0.12:6379:6379"
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

The official image's generated pg_hba.conf allows every host with a password, which is why the compose mounts its own and points Postgres at it with hba_file. The pg_hba.conf reference covers the format; the file for this layout is four lines:

local   all   all                     scram-sha-256
host    all   all   127.0.0.1/32      scram-sha-256
host    n8n   n8n   10.20.0.0/24      scram-sha-256
host    all   all   0.0.0.0/0         reject

Only the n8n role from the private subnet gets in over TCP; the local and loopback lines are for psql inside the container and the image's own checks. Postgres 17 uses scram-sha-256 for password storage by default, so the method matches without further config. Restart the container after editing the file, and note that Docker ignores chmod on a bind-mounted file for the purpose of the container's read, so a 0644 file works.

If Postgres runs on the host and not in Docker

Then listen_addresses in postgresql.conf does the binding: listen_addresses = 'localhost,10.20.0.12'. The Postgres connection settings allow hostnames there too, but a hostname is resolved once at startup and a wrong DNS answer leaves Postgres listening somewhere you didn't intend, so I write the IP. The same pg_hba lines apply, in the distro's file under /etc/postgresql/17/main/.

Server B: Redis with requirepass on the private IP

The Redis service in the compose above uses the same publish trick, 10.20.0.12:6379:6379, plus --requirepass and --protected-mode yes. Protected mode refuses connections from outside the loopback when no password is set, so with a password it's a safety net for the day someone removes the flag by accident. Redis 6 and later also support ACL usernames, which n8n reads from QUEUE_BULL_REDIS_USERNAME, but a password on the default user is what I run and it's enough on a private network nobody else is on.

UFW rules for 5432 and 6379 on the private interface

UFW on server B, allowing the two ports only on the private interface and only from server A:

ufw default deny incoming
ufw default allow outgoing
ufw allow from 203.0.113.5 to any port 22 proto tcp
ufw allow in on ens10 from 10.20.0.11 to any port 5432 proto tcp
ufw allow in on ens10 from 10.20.0.11 to any port 6379 proto tcp
ufw enable

Replace 203.0.113.5 with the address you administer from. The interface match is the important half: a rule that says "from 10.20.0.11" without in on ens10 would also accept a packet with a spoofed source on the public interface. Because the datastores are published on the private IP alone, Docker's own rules aren't exposing anything here even though they bypass UFW; the publish restriction and the UFW rule are two seperate locks and I want both. On server A the ruleset is 80, 443 and SSH, nothing for the private side since A only makes outbound connections to B.

Verify from server A with nc -zv 10.20.0.12 5432 and nc -zv 10.20.0.12 6379, both should connect. Then from your laptop, nmap -Pn -p 5432,6379 <server B public IP> should report both as filtered. Run the nmap again a month later; configurations drift.

Server A: n8n main pointed at the private addresses

n8n only needs the two host variables changed. The compose on server A is a normal single-server n8n file with the database and Redis pointed across the wire:

services:
  n8n-main:
    image: n8nio/n8n:2.38.5
    restart: unless-stopped
    environment:
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: 10.20.0.12
      DB_POSTGRESDB_PORT: "5432"
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      EXECUTIONS_MODE: queue
      QUEUE_BULL_REDIS_HOST: 10.20.0.12
      QUEUE_BULL_REDIS_PORT: "6379"
      QUEUE_BULL_REDIS_PASSWORD: ${REDIS_PASSWORD}
      N8N_HOST: n8n.example.com
      N8N_PROTOCOL: https
      N8N_EDITOR_BASE_URL: https://n8n.example.com/
      N8N_WEBHOOK_URL: https://n8n.example.com/
      N8N_PROXY_HOPS: "1"
      GENERIC_TIMEZONE: Europe/Paris
      TZ: Europe/Paris
    volumes:
      - n8n_data:/home/node/.n8n

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data

volumes:
  n8n_data:
  caddy_data:

The n8n service publishes no port; Caddy reaches it on the compose network by service name with a three-line Caddyfile, n8n.example.com { reverse_proxy n8n-main:5678 { flush_interval -1 } }, spread over lines. Drop the three QUEUE_BULL_* lines and EXECUTIONS_MODE on a regular-mode instance. Workers, if you run them, go on server B or a server C with the same DB and Redis variables and the same key; the queue mode compose file in the setup guide has the worker service definition, and sizing is a separate topic.

A note on latency, since it's the reason people hesitate to split: across the private network in one of our locations a Postgres round trip from server A is about 0.3 ms, and an execution with a dozen nodes makes a few dozen queries, so the split adds single-digit milliseconds per execution. It's invisible next to any HTTP Request node.

TLS to Postgres with DB_POSTGRESDB_SSL_ENABLED

A provider's private network is isolated from the internet, not from other tenants in every design, and if that matters for your data the Postgres connection can carry TLS. n8n's side is four variables: DB_POSTGRESDB_SSL_ENABLED=true, DB_POSTGRESDB_SSL_CA with the CA certificate contents, DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED=true (the default) and optionally DB_POSTGRESDB_SSL_CERT and DB_POSTGRESDB_SSL_KEY for client certificates. Postgres's side is ssl = on, ssl_cert_file and ssl_key_file pointing at a certificate issued for 10.20.0.12, and the pg_hba line changed from host to hostssl so plaintext connections are refused, not merely unused. Redis has QUEUE_BULL_REDIS_TLS=true for the same purpose, with the TLS build of Redis or a stunnel in front. I run neither on a LumaDock private network and both against managed databases reached over someone else's network.

WireGuard when the provider has no private network

Without a private network the same layout works over a WireGuard tunnel: a wg0 interface on both servers with addresses from 10.30.0.0/24, and every 10.20.0.x above becomes 10.30.0.x, including the publish addresses, the pg_hba subnet and the UFW in on wg0 rules. The WireGuard quick start is short and the whole thing is two config files and two key pairs. A ready-made WireGuard VPS can be the hub if the servers are in different locations. The cost is encryption on every packet and a tunnel that has to be up before Postgres is reachable, which means wg-quick@wg0 enabled at boot and n8n's DB_PING_* recovery loop covering the seconds in between; how much latency the encryption adds on a datastore link I've never measured, since ours don't need it.

Task runner broker port 5679

n8n's task broker listens on 127.0.0.1:5679 by default, and in the default internal runner mode that's correct: the runner is a child process on the same host and the port never leaves the loopback. It changes only if you run external runners on another server, in which case the main or the worker sets N8N_RUNNERS_BROKER_LISTEN_ADDRESS to 0.0.0.0, adds a publish line of 10.20.0.11:5679:5679, a UFW rule on ens10 from the runner host and a shared N8N_RUNNERS_AUTH_TOKEN. In compose on one host the sidecar reaches the broker by service name and the port still isn't published. Don't publish 5679 on a public address for any reason; the token is the only thing between that port and arbitrary code execution.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.