The Story

I run a GTM automation stack for my agency. Lead enrichment, webhook-triggered outreach, CRM syncs, AI pipelines. The kind of stuff that makes you dangerous with n8n and a good API key collection.

For months I ran n8n on a $4/month Hetzner VPS. Docker Compose, port 5678 wide open, basic auth in front. It worked. I figured the username and password were enough.

They weren't.

Then my friend Marc Fajardo asked me if I'd checked my server logs. I hadn't. When I did, here's what I found:

These aren't hackers targeting me specifically. These are automated botnets that scan every IP address on the internet, 24/7, looking for servers with default credentials. They found mine in hours.

The n8n UI? Sitting on a public port with nothing but a login form between an attacker and my API keys, workflow data, and webhook endpoints. One weak password away from someone running arbitrary workflows on my server.

Marc pointed me to Wireguard as the fix. From there I went down the rabbit hole and locked the whole thing down. VPN tunnel, firewall, webhook proxy, SSH hardening, automated banning. The whole stack. It took an afternoon. (Marc's a sharp infrastructure engineer, by the way. If you're hiring, check him out.)

The bots are still scanning. They just bounce off now.

This playbook is everything I did, step by step, so you don't have to figure it out the hard way. Every command is copy-paste. Every config is pulled from my actual production server.


Who This Is For

GTM engineers, marketers running automation, RevOps people, solo founders. Anyone self-hosting n8n, OpenClaw, Activepieces, Windmill, or similar tools who doesn't have a security background but knows their way around a terminal.

What you'll end up with:

Time: ~90 minutes start to finish. ~30 minutes once you've done it before.

Cost: ~$4-7/month (Hetzner CX22, 2 vCPU, 4GB RAM, 40GB SSD).



1. Buy the VPS

Why Hetzner?

Hetzner gives you the best price-to-performance ratio for self-hosting. Their CX22 plan ($4.35/month) gives you 2 vCPUs, 4GB RAM, and 40GB SSD, which is more than enough for n8n. They have data centers in Germany, Finland, and the US.

Other options: DigitalOcean ($6/mo), Vultr ($6/mo), Linode ($5/mo). The hardening steps in this guide work on any Ubuntu VPS.

Steps

  1. Go to cloud.hetzner.com and create an account
  2. Click Add Server
  3. Choose your settings:
    • Location: Pick what's closest to you or your webhook sources
    • Image: Ubuntu 24.04
    • Type: Shared vCPU, CX22 (2 vCPU, 4GB RAM)
    • Networking: Public IPv4 (you need this)
    • SSH Keys: Add your SSH public key here (see below if you don't have one)
    • Name: Something memorable like n8n-prod
  4. Click Create & Buy Now

Your server will be ready in ~30 seconds. Note the public IP address (e.g., 203.0.113.50). You'll use this throughout the guide.

Generate an SSH Key (if you don't have one)

On your local machine (Mac/Linux):

ssh-keygen -t ed25519 -C "your-email@example.com"

Press Enter to accept the default location. Set a passphrase if you want extra security (recommended).

Your public key is at ~/.ssh/id_ed25519.pub. Copy its contents and paste it into Hetzner's SSH key field.


2. First Login and System Update

Connect to your server

ssh root@YOUR_SERVER_IP

Replace YOUR_SERVER_IP with your Hetzner IP. First connection will ask you to confirm the fingerprint. Type yes.

Update the system

apt update && apt upgrade -y

This pulls the latest security patches. Do this regularly (or set up unattended upgrades later).

Set the timezone

timedatectl set-timezone YOUR_TIMEZONE

Common values: America/New_York, America/Los_Angeles, Europe/London, Asia/Singapore.

Check available timezones with timedatectl list-timezones.


3. Install Docker

n8n runs in Docker. This keeps it isolated from the rest of your system and makes updates painless.

# Install Docker's official GPG key and repo
apt install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

Verify it works:

docker run hello-world

You should see "Hello from Docker!"


4. Deploy n8n

Create the project directory

mkdir -p /opt/n8n
cd /opt/n8n

Create the Docker Compose file

cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
  n8n:
    image: n8nio/n8n
    restart: always
    ports:
      - "0.0.0.0:5678:5678"
    environment:
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=your_username
      - N8N_BASIC_AUTH_PASSWORD=your_strong_password_here
      - N8N_HOST=YOUR_SERVER_IP
      - N8N_PROTOCOL=http
      - N8N_SECURE_COOKIE=false
      - WEBHOOK_URL=http://YOUR_SERVER_IP:5678/
      - GENERIC_TIMEZONE=YOUR_TIMEZONE
    volumes:
      - n8n_data:/home/node/.n8n
volumes:
  n8n_data:
EOF

Replace:

Start n8n

docker compose up -d

Verify it's running

docker compose logs -f

You should see n8n starting up. Press Ctrl+C to exit logs.

At this point, n8n is accessible at http://YOUR_SERVER_IP:5678. Open it in your browser, set up your account, and confirm it works.

This is the "most tutorials stop here" moment. Your n8n is live and functional, but it's completely exposed. Anyone who finds your IP can reach the login page. Let's fix that.


5. Set Up Wireguard VPN

What is Wireguard and why do we need it?

Wireguard creates an encrypted tunnel between your computer and the VPS. Once set up, you can access n8n through the tunnel as if you were sitting next to the server, while making it invisible to the rest of the internet.

Think of it as a private hallway between your laptop and the server. Only you can walk through it.

Install Wireguard on the VPS

apt install -y wireguard

Generate server keys

cd /etc/wireguard
wg genkey | tee server_private.key | wg pubkey > server_public.key
chmod 600 server_private.key

Generate client keys (for your laptop/desktop)

wg genkey | tee client_private.key | wg pubkey > client_public.key
chmod 600 client_private.key

Note down the keys

echo "Server private key: $(cat server_private.key)"
echo "Server public key:  $(cat server_public.key)"
echo "Client private key: $(cat client_private.key)"
echo "Client public key:  $(cat client_public.key)"

Save these somewhere safe. You'll need them in the next steps.

Create the server config

cat > /etc/wireguard/wg0.conf << EOF
[Interface]
PrivateKey = $(cat server_private.key)
Address = 10.0.0.1/24
ListenPort = 51820

[Peer]
PublicKey = $(cat client_public.key)
AllowedIPs = 10.0.0.2/32
EOF

Start Wireguard on the VPS

systemctl enable wg-quick@wg0
systemctl start wg-quick@wg0

Verify it's running:

wg show

You should see the interface with your peer listed.

Set up Wireguard on your local machine

Mac: Download WireGuard from the App Store.

Windows: Download from wireguard.com/install.

Linux:

sudo apt install wireguard

Create the client config

On your local machine, create a file called wg0.conf:

[Interface]
PrivateKey = YOUR_CLIENT_PRIVATE_KEY
Address = 10.0.0.2/24

[Peer]
PublicKey = YOUR_SERVER_PUBLIC_KEY
Endpoint = YOUR_SERVER_IP:51820
AllowedIPs = 10.0.0.1/32
PersistentKeepalive = 25

Replace:

Important: The AllowedIPs = 10.0.0.1/32 setting means only traffic to 10.0.0.1 goes through the VPN. Your regular internet traffic is unaffected. This is a "split tunnel" and it's what you want.

Import and connect

Test the tunnel

ping 10.0.0.1

If you get replies, the tunnel is working. You now have a private connection to your VPS.


6. Bind n8n to the VPN

This is the key security step. We're going to change n8n so it only listens on the VPN address, making it invisible to the public internet.

Update docker-compose.yml

On the VPS, edit /opt/n8n/docker-compose.yml. Change the ports line:

Before (exposed to everyone):

    ports:
      - "0.0.0.0:5678:5678"

After (VPN only):

    ports:
      - "10.0.0.1:5678:5678"

Also update the WEBHOOK_URL to use a separate port we'll set up next:

      - WEBHOOK_URL=http://YOUR_SERVER_IP:5679/

Full updated docker-compose.yml

version: '3.8'
services:
  n8n:
    image: n8nio/n8n
    restart: always
    ports:
      - "10.0.0.1:5678:5678"
    environment:
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=your_username
      - N8N_BASIC_AUTH_PASSWORD=your_strong_password_here
      - N8N_HOST=YOUR_SERVER_IP
      - N8N_PROTOCOL=http
      - N8N_SECURE_COOKIE=false
      - WEBHOOK_URL=http://YOUR_SERVER_IP:5679/
      - GENERIC_TIMEZONE=YOUR_TIMEZONE
    volumes:
      - n8n_data:/home/node/.n8n
volumes:
  n8n_data:

Restart n8n

cd /opt/n8n
docker compose down
docker compose up -d

Test it

With your VPN connected:

curl http://10.0.0.1:5678
# Should return HTML (the n8n login page)

From the public internet (VPN disconnected or from another machine):

curl http://YOUR_SERVER_IP:5678
# Should timeout or refuse connection

n8n is now invisible to the internet. But webhooks need to be reachable, so let's fix that.


7. Install Caddy as a Webhook Proxy

The problem

Your n8n automations likely use webhooks. Services like Stripe, GitHub, Slack, and CRMs need to send HTTP requests to your server. But we just hid n8n from the internet.

The solution

Caddy is a web server that acts as a bouncer. It listens on a public port and only forwards requests to specific paths (/webhook/*) to n8n. Everything else gets a 403 Forbidden.

This means:

Install Caddy

apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install -y caddy

Configure Caddy

cat > /etc/caddy/Caddyfile << 'EOF'
:5679 {
  @webhooks path /webhook/* /webhook-test/*
  reverse_proxy @webhooks 10.0.0.1:5678
  respond 403
}
EOF

What this does:

Restart Caddy

systemctl restart caddy
systemctl enable caddy

Test it

# This should return 403 (blocked)
curl http://YOUR_SERVER_IP:5679/

# This would forward to n8n (if you have a webhook set up)
curl http://YOUR_SERVER_IP:5679/webhook/test

Update your webhook URLs

Any external services calling your n8n webhooks need to use the new URL format:

Old: http://YOUR_SERVER_IP:5678/webhook/abc123
New: http://YOUR_SERVER_IP:5679/webhook/abc123


8. Configure the Firewall (UFW)

UFW (Uncomplicated Firewall) is Ubuntu's built-in firewall. We're going to set it up to:

Enable UFW

# IMPORTANT: Allow your current SSH connection first, or you'll lock yourself out
ufw allow 51820/udp comment "Wireguard VPN"

# Allow webhook proxy from anywhere (external services need this)
ufw allow 5679/tcp comment "Caddy webhook proxy"

# Allow SSH only from VPN
ufw allow from 10.0.0.0/24 to any port 22 proto tcp comment "SSH via VPN only"

# Explicitly deny n8n port from public
ufw deny 5678 comment "Block n8n direct access"

# Enable the firewall
ufw --force enable

If you also want to restrict webhooks to VPN-only (no external webhooks needed), replace the ufw allow 5679/tcp line with:

ufw allow from 10.0.0.0/24 to any port 5679 proto tcp comment "Webhook proxy via VPN only"

Verify

ufw status verbose

You should see:

Status: active
Default: deny (incoming), allow (outgoing), deny (routed)

To                         Action      From
--                         ------      ----
51820/udp                  ALLOW IN    Anywhere
5679/tcp                   ALLOW IN    Anywhere
22/tcp                     ALLOW IN    10.0.0.0/24
5678                       DENY IN     Anywhere

Emergency note

If you lock yourself out of SSH, use Hetzner's web console:

  1. Go to cloud.hetzner.com
  2. Click your server
  3. Click "Console" (top right)
  4. This gives you direct terminal access regardless of firewall rules

9. Install fail2ban

fail2ban monitors your logs for repeated failed login attempts and automatically bans attacker IPs. It's your automated security guard.

Install

apt install -y fail2ban

Configure

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd

[sshd]
enabled = true
maxretry = 5
bantime = 600
EOF

What this does:

Want longer bans? Change bantime = 600 to bantime = 3600 (1 hour) or bantime = 86400 (24 hours). For repeat offenders, look into bantime.increment = true.

Start fail2ban

systemctl enable fail2ban
systemctl start fail2ban

Check status

fail2ban-client status sshd

10. Disable SSH Password Login

This is critical. Even with fail2ban, password-based SSH is a liability. We already set up SSH keys in Step 1, so let's disable passwords entirely.

Edit the SSH config

nano /etc/ssh/sshd_config

Find and change (or add) these lines:

PasswordAuthentication no
KbdInteractiveAuthentication no

Restart SSH

systemctl restart sshd

Test it (important!)

Before closing your current SSH session, open a new terminal window and try to connect:

ssh root@10.0.0.1

If it works (using your key), you're good. If it doesn't, go back to your existing session and fix the config.

From now on, SSH only works via VPN (10.0.0.1) with your SSH key. No passwords, no public access. This is exactly what you want.


11. Verify Everything Works

Run through this checklist:

VPN tunnel

# On your local machine, with VPN connected
ping 10.0.0.1
# Expected: replies

n8n UI (VPN only)

# Open in browser
http://10.0.0.1:5678
# Expected: n8n login page

n8n blocked publicly

# From a different network or with VPN disconnected
curl -m 5 http://YOUR_SERVER_IP:5678
# Expected: timeout or connection refused

Webhooks work publicly

curl http://YOUR_SERVER_IP:5679/webhook-test/test
# Expected: response from n8n (or a "not found" if no workflow is set up)

Non-webhook paths blocked

curl http://YOUR_SERVER_IP:5679/
# Expected: 403 Forbidden

curl http://YOUR_SERVER_IP:5679/admin
# Expected: 403 Forbidden

SSH via VPN only

# With VPN connected
ssh root@10.0.0.1
# Expected: works

# With VPN disconnected
ssh root@YOUR_SERVER_IP
# Expected: connection refused

fail2ban running

ssh root@10.0.0.1
fail2ban-client status sshd
# Expected: shows filter and actions status

12. Ongoing Monitoring

Manual check

SSH in and run:

fail2ban-client status sshd

This shows you currently banned IPs and total ban count.

Automated monitoring (optional)

Create a script that reports fail2ban stats on a schedule:

cat > /opt/fail2ban-report.sh << 'SCRIPT'
#!/bin/bash
STATUS=$(fail2ban-client status sshd)
CURRENTLY_BANNED=$(echo "$STATUS" | grep "Currently banned" | awk '{print $NF}')
TOTAL_BANNED=$(echo "$STATUS" | grep "Total banned" | awk '{print $NF}')
TOTAL_FAILED=$(echo "$STATUS" | grep "Total failed" | awk '{print $NF}')

echo "=== fail2ban Report ==="
echo "Currently banned: $CURRENTLY_BANNED"
echo "Total banned: $TOTAL_BANNED"
echo "Total failed attempts: $TOTAL_FAILED"
echo "========================"
SCRIPT

chmod +x /opt/fail2ban-report.sh

To run it on a schedule:

crontab -e

Add this line to run every 12 hours:

0 */12 * * * /opt/fail2ban-report.sh >> /var/log/fail2ban-report.log

You can also pipe this output to Slack, Discord, or email using a webhook.

System updates

# Check for updates
apt update && apt list --upgradable

# Apply updates
apt upgrade -y

Do this at least monthly. For kernel updates, you'll need to reboot:

reboot

After rebooting, reconnect your VPN and verify n8n started automatically (docker compose ps in /opt/n8n).


13. Architecture Reference

Here's what we built, visualized as a network diagram:

THE INTERNET
    |
    | (port 51820/udp - Wireguard)
    v
+-------------------------------------------+
|              YOUR VPS                      |
|                                            |
|  +----------------+                        |
|  | Wireguard VPN  |<-- 10.0.0.1           |
|  | (wg0)          |                        |
|  +-------+--------+                        |
|          |                                 |
|          |  (10.0.0.1:5678)                |
|          v                                 |
|  +----------------+                        |
|  | n8n (Docker)   |  <-- Only reachable    |
|  | port 5678      |      via VPN tunnel    |
|  +-------+--------+                        |
|          ^                                 |
|          | (reverse proxy, /webhook/* only) |
|          |                                 |
|  +----------------+                        |
|  | Caddy          |  <-- port 5679         |
|  | (webhook proxy)|      (public)          |
|  +----------------+                        |
|                                            |
|  +----------------+    +----------------+  |
|  | UFW Firewall   |    | fail2ban       |  |
|  | (deny all but  |    | (auto-ban      |  |
|  |  51820, 5679,  |    |  attackers)    |  |
|  |  22/VPN)       |    |                |  |
|  +----------------+    +----------------+  |
+-------------------------------------------+
    ^
    | (port 5679/tcp - webhooks only)
    |
EXTERNAL SERVICES (Stripe, GitHub, Slack, CRMs, etc.)


YOUR LAPTOP
    |
    | Wireguard tunnel (10.0.0.2 -> 10.0.0.1)
    v
  n8n UI at http://10.0.0.1:5678 (private)
  SSH at root@10.0.0.1 (private)

Port map

Port Protocol Access Purpose
51820 UDP Public Wireguard VPN
5679 TCP Public Caddy webhook proxy (only /webhook/* paths)
5678 TCP Denied publicly n8n UI and API (VPN only)
22 TCP VPN only SSH

14. FAQ and Troubleshooting

"I locked myself out of SSH"

Use Hetzner's web console (cloud.hetzner.com > your server > Console). This gives you terminal access regardless of firewall or VPN state. From there, fix your UFW rules or Wireguard config.

"My webhooks stopped working"

  1. Check Caddy is running: systemctl status caddy
  2. Check the Caddyfile: cat /etc/caddy/Caddyfile
  3. Check UFW allows port 5679: ufw status
  4. Check n8n is running: cd /opt/n8n && docker compose ps

"n8n won't start after a reboot"

cd /opt/n8n
docker compose up -d
docker compose logs -f

If you see errors about the Wireguard interface not being ready, it means Docker started before Wireguard. Fix by adding a dependency:

# Ensure Wireguard starts before Docker
systemctl enable wg-quick@wg0

"How do I update n8n?"

cd /opt/n8n
docker compose pull
docker compose down
docker compose up -d

Your data is safe in the n8n_data Docker volume.

"How do I add another device (phone, second laptop)?"

Generate a new key pair on the VPS:

cd /etc/wireguard
wg genkey | tee client2_private.key | wg pubkey > client2_public.key

Add a new [Peer] block to /etc/wireguard/wg0.conf:

[Peer]
PublicKey = CLIENT2_PUBLIC_KEY
AllowedIPs = 10.0.0.3/32

Restart Wireguard:

systemctl restart wg-quick@wg0

Create the client config with Address = 10.0.0.3/24 (increment the IP for each device).

"Should I use HTTPS/TLS for n8n?"

For the webhook proxy (port 5679), yes, if you're handling sensitive webhook payloads. You can add a domain and let Caddy auto-provision a TLS certificate:

yourdomain.com:443 {
  @webhooks path /webhook/* /webhook-test/*
  reverse_proxy @webhooks 10.0.0.1:5678
  respond 403
}

Caddy handles Let's Encrypt certificates automatically. You'll need to point a domain's DNS A record to your VPS IP.

For the n8n UI (port 5678), TLS is optional since it's already behind the VPN tunnel, which is encrypted.

"Can I use this same setup for OpenClaw, Activepieces, or other self-hosted tools?"

Yes. The pattern is identical:

  1. Run the tool in Docker, bound to 10.0.0.1:SOME_PORT
  2. If it needs public endpoints, add a Caddy reverse proxy rule for specific paths
  3. Deny the direct port in UFW
  4. Access the UI through the VPN tunnel

For OpenClaw specifically, bind to 10.0.0.1:18789 instead of 0.0.0.0:18789. If it needs to receive messages from Telegram/Slack/WhatsApp bots, proxy those specific webhook paths through Caddy.

"Is this enterprise-grade security?"

No. This is practical security for solo operators and small teams. Enterprise setups would add:

But for a GTM engineer running automation workflows, this setup blocks 99% of automated attacks while keeping your tools accessible and your webhooks functional. That's the right tradeoff.


Quick Reference Card

# Connect to VPN
# (activate in WireGuard app, or: wg-quick up wg0)

# Access n8n
open http://10.0.0.1:5678

# SSH to server
ssh root@10.0.0.1

# Check n8n status
ssh root@10.0.0.1 "cd /opt/n8n && docker compose ps"

# Check who's attacking
ssh root@10.0.0.1 "fail2ban-client status sshd"

# Update n8n
ssh root@10.0.0.1 "cd /opt/n8n && docker compose pull && docker compose down && docker compose up -d"

# Check firewall rules
ssh root@10.0.0.1 "ufw status numbered"

# View Caddy logs
ssh root@10.0.0.1 "journalctl -u caddy --since '1 hour ago' --no-pager"

This is the exact setup running my production GTM stack. 37,924 attacks absorbed and counting. If it saved you an afternoon of Googling, share it with someone else who's running n8n on a naked VPS.