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:
- 37,924 failed SSH login attempts
- 328 unique attacker IPs banned
- 3,009 brute-force attempts in a single day
- One IP alone hit my server 5,097 times
- Usernames tried:
admin,root,ubuntu,solana,postgres,oracle
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:
- n8n running in Docker, accessible only through a VPN tunnel
- Webhooks exposed to the internet (so your automations work) but nothing else
- SSH locked to key-only, VPN-only access
- Automatic banning of attackers
- A setup that's survived real attacks
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
- Go to cloud.hetzner.com and create an account
- Click Add Server
- 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
- 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:
your_usernamewith a username (not "admin")your_strong_password_herewith a strong password (16+ characters, mix of letters/numbers/symbols)YOUR_SERVER_IPwith your Hetzner IPYOUR_TIMEZONEwith your timezone (e.g.,America/New_York)
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:
YOUR_CLIENT_PRIVATE_KEYwith the client private key from earlierYOUR_SERVER_PUBLIC_KEYwith the server public key from earlierYOUR_SERVER_IPwith your Hetzner public IP
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
- Mac/Windows: Open the WireGuard app, click "Import tunnel(s) from file", select your
wg0.conf, then activate it. - Linux:
sudo wg-quick up wg0
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:
- Webhook triggers work normally
- The n8n UI, API, and everything else stays hidden
- Attackers scanning your ports see nothing useful
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:
- Listens on port 5679 (public)
- If the path starts with
/webhook/or/webhook-test/, forward it to n8n - Everything else gets a 403 (Forbidden)
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:
- Allow Wireguard (port 51820)
- Allow webhook proxy (port 5679)
- Allow SSH only from the VPN
- Block everything else
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/tcpline 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:
- Go to cloud.hetzner.com
- Click your server
- Click "Console" (top right)
- 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:
- Watches SSH login attempts
- After 5 failed attempts from the same IP, ban it for 600 seconds (10 minutes)
- Uses nftables for banning (modern Linux firewall backend)
Want longer bans? Change
bantime = 600tobantime = 3600(1 hour) orbantime = 86400(24 hours). For repeat offenders, look intobantime.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"
- Check Caddy is running:
systemctl status caddy - Check the Caddyfile:
cat /etc/caddy/Caddyfile - Check UFW allows port 5679:
ufw status - 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:
- Run the tool in Docker, bound to
10.0.0.1:SOME_PORT - If it needs public endpoints, add a Caddy reverse proxy rule for specific paths
- Deny the direct port in UFW
- 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:
- Separate user accounts (not running as root)
- Intrusion detection systems (OSSEC, Wazuh)
- Log aggregation (Loki, Datadog)
- Automated patching
- Network segmentation
- VPN with certificate-based auth
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.