Skip to content

exzentcg.com Homelab — Full Implementation Guide

Covers: Phase 1 end-to-end — from bare Proxmox host to n8n.exzentcg.com live on the internet.

Prerequisites: - Proxmox VE installed and accessible at 192.168.0.200:8006 - exzentcg.com domain registered and DNS managed by Cloudflare - A Cloudflare account (free tier is sufficient) - Admin desktop at 192.168.0.16 with browser and SSH client


Step 0 — Proxmox Firewall Lockout Prevention

⚠️ Do this FIRST. If you enable the datacenter firewall without these rules, you will be locked out and must fix it from the physical console.

0.1 — Enable the Node-level Firewall

In the Proxmox WebUI (https://192.168.0.200:8006):

  1. Go to Datacenter → pve → Firewall → Options
  2. Set Firewall to Yes (node level)
  3. Do NOT enable the Datacenter-level firewall yet

0.2 — Create Node Firewall Rules

Go to Datacenter → pve → Firewall → Add

Create these rules in this exact order:

# Direction Action Source Dest Port Protocol Comment
1 IN ACCEPT 192.168.0.16 8006 TCP Admin WebUI from LAN desktop
2 IN ACCEPT 192.168.0.16 22 TCP Admin SSH from LAN desktop
3 IN DROP 8006 TCP Block all others from WebUI

Tailscale note: These rules use a hardcoded LAN IP because the admin_desktop IP set does not exist yet. After completing Step 0.5 (Tailscale install) and Step 1 (IP set creation), come back here and replace the hardcoded 192.168.0.16 source on rules 1 and 2 with +admin_desktop. That single change extends admin access to both the LAN desktop and the Tailscale laptop without duplicate rules.

0.3 — Test Before Proceeding

From your desktop (192.168.0.16), confirm:

# Should succeed
curl -k https://192.168.0.200:8006

# Should succeed
ssh root@192.168.0.200

From any OTHER device on your network (e.g. phone on WiFi), confirm:

# Should timeout / fail
https://192.168.0.200:8006  → should NOT load

If the above checks pass, proceed. If not, fix the rules before continuing.


Step 0.5 — Install Tailscale for Remote Admin

Per the architecture decision, Tailscale is the remote admin mechanism. It is deliberately independent of Cloudflare so an outage of one service does not lock the operator out of the hypervisor. Installing Tailscale before locking down the datacenter firewall means the admin laptop's Tailscale IP is known and can be added to the admin_desktop IP set in Step 1.

0.5.1 — Install Tailscale on the Proxmox Host

From the Proxmox host (SSH from desktop or use the node shell in the WebUI):

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up

Follow the auth URL printed in the terminal and log in with your Tailscale account (create one if needed — free tier is sufficient).

After auth, get the host's Tailscale IP:

tailscale ip -4
# Example output: 100.64.12.34

Record this — it is the Proxmox host on the mesh.

0.5.2 — Install Tailscale on the Admin Laptop

Install Tailscale on the laptop used for remote admin (download from tailscale.com/download, or use the relevant package manager). Log in with the same Tailscale account.

From the laptop, get its Tailscale IP:

tailscale ip -4
# Example output: 100.64.56.78

Record this IP — it will be added to the admin_desktop IP set in Step 1.

0.5.3 — Verify the Mesh

From the laptop (while off your home LAN, or with LAN disabled to confirm it's going over Tailscale):

ping <proxmox_tailscale_ip>
ssh root@<proxmox_tailscale_ip>

Both should succeed. If so, remote access works; proceed.

Note on ACLs: The default Tailscale ACL allows all devices in your tailnet to reach each other. If you later add non-admin devices to the tailnet, tighten the ACL in the Tailscale admin console so only the admin laptop can reach the Proxmox host.


Step 1 — Create Datacenter IP Sets

Go to Datacenter → Firewall → IPSet

Create these IP sets:

IP Set Name Members Purpose
admin_desktop 192.168.0.16, <laptop_tailscale_ip_from_0.5.2> Trusted admin machines (LAN + Tailscale)
lan_subnet 192.168.0.0/24 Entire home LAN
router_gw 192.168.0.1 Default gateway / DNS
edge_gw 192.168.0.51 edge-gateway LXC

After creating admin_desktop: Return to the Step 0.2 node firewall rules and replace the hardcoded 192.168.0.16 source on rules 1 and 2 with +admin_desktop. This extends node admin access over Tailscale.

These will be referenced in all container firewall rules.


Step 2 — Enable Datacenter Firewall

Go to Datacenter → Firewall → Options

  1. Set Firewall to Yes
  2. Set Input Policy to DROP
  3. Set Output Policy to ACCEPT

Verify you can still access the WebUI from your desktop. If locked out, access the physical console and run:

# Emergency disable from Proxmox physical console
pve-firewall stop

Step 3 — Create LXC: edge-gateway (192.168.0.51)

3.1 — Download CT Template

In the Proxmox WebUI:

  1. Go to local (pve) → CT Templates → Templates
  2. Download debian-12-standard (latest)

3.2 — Create the Container

Go to Create CT (top right button):

Setting Value
CT ID 101 (or your preference)
Hostname edge-gateway
Password (set a strong root password)
Template debian-12-standard
Disk 4 GB
CPU 1 core
Memory 1024 MB
Network Bridge: vmbr0, IPv4: 192.168.0.51/24, Gateway: 192.168.0.1
DNS Domain: exzentcg.com, Server: 192.168.0.1

Check Start after created, then click Finish.

3.3 — Verify Networking

SSH into the container:

ssh root@192.168.0.51

Test connectivity:

ping -c 3 1.1.1.1
ping -c 3 google.com
apt update

If DNS fails, check /etc/resolv.conf:

cat /etc/resolv.conf
# Should contain: nameserver 192.168.0.1
# If missing:
echo "nameserver 192.168.0.1" > /etc/resolv.conf

3.4 — Install NPM + cloudflared (single Docker Compose stack)

Both services live in one LXC (edge-gateway) and one compose file. This keeps the install paradigm consistent (all apps = docker-compose in LXCs), backups simple (one directory), and lets NPM bind to localhost only — a second layer of defence behind the Proxmox firewall.

# Install Docker
curl -fsSL https://get.docker.com | sh

# Create edge-gateway directory
mkdir -p /opt/edge-gateway && cd /opt/edge-gateway

Create the compose file:

cat > docker-compose.yml << 'EOF'
services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    container_name: npm
    restart: unless-stopped
    ports:
      - "127.0.0.1:80:80"       # localhost only — cloudflared reaches this
      - "127.0.0.1:443:443"     # localhost only
      - "192.168.0.51:81:81"    # admin panel on LAN IP only
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}
    depends_on:
      - npm
    network_mode: host          # so localhost:80 resolves to NPM's bind
EOF

Create an empty .env — the tunnel token will be filled in at Step 7.3 after the tunnel is created in the Cloudflare dashboard:

cat > .env << 'EOF'
TUNNEL_TOKEN=
EOF
chmod 600 .env

Start NPM only for now (cloudflared will fail to start without a valid token — that's expected):

docker compose up -d npm

Wait ~30 seconds, then access the NPM admin panel from your desktop:

http://192.168.0.51:81

Default credentials: - Email: admin@example.com - Password: changeme

Immediately change the admin email and password on first login.

Why network_mode: host for cloudflared? Without it, cloudflared runs in its own Docker network and localhost:80 would mean the cloudflared container's own loopback, not NPM. Host networking makes cloudflared share the LXC's network namespace, so localhost:80 correctly reaches NPM's 127.0.0.1:80 bind.

Backup target: /opt/edge-gateway/ now contains everything — compose file, NPM data, certs, tunnel token. tar czf edge-gateway-backup.tgz /opt/edge-gateway/ grabs the lot.


Step 4 — Create LXC: n8n-app (192.168.0.52)

4.1 — Create the Container

Go to Create CT:

Setting Value
CT ID 102 (or your preference)
Hostname n8n-app
Password (set a strong root password)
Template debian-12-standard
Disk 10 GB
CPU 2 cores
Memory 2048 MB
Network Bridge: vmbr0, IPv4: 192.168.0.52/24, Gateway: 192.168.0.1
DNS Domain: exzentcg.com, Server: 192.168.0.1

Start the container.

4.2 — Verify Networking

ssh root@192.168.0.52
ping -c 3 google.com
apt update

4.3 — Generate n8n Encryption Key (do this FIRST)

⚠️ Do this before starting n8n for the first time. n8n encrypts every credential it stores (API keys, OAuth tokens, DB passwords) with this key. If n8n generates its own random key on first start and the volume is later lost or corrupted, every stored credential becomes permanently unrecoverable. Generating the key yourself means you can back it up separately.

On the n8n-app container:

openssl rand -hex 32

Copy the output. Save it immediately to your password manager under an entry like n8n encryption key (exzentcg homelab). Treat it like a root password — losing it is equivalent to losing every credential n8n will ever hold.

4.4 — Install n8n via Docker Compose

# Install Docker
curl -fsSL https://get.docker.com | sh

# Create n8n directory
mkdir -p /opt/n8n && cd /opt/n8n

Create the compose file (pin to a specific version — replace 1.80.0 with the current stable at install time, see https://github.com/n8n-io/n8n/releases):

cat > docker-compose.yml << 'EOF'
services:
  n8n:
    image: n8nio/n8n:1.80.0
    container_name: n8n
    restart: unless-stopped
    ports:
      - "192.168.0.52:5678:5678"    # bind to LXC IP only, not 0.0.0.0
    environment:
      - N8N_HOST=n8n.exzentcg.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.exzentcg.com
      - NODES_EXCLUDE=["n8n-nodes-base.executeCommand"]
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - GENERIC_TIMEZONE=Asia/Singapore
      - TZ=Asia/Singapore
    volumes:
      - ./data:/home/node/.n8n
EOF

Create the .env file with the encryption key you generated in Step 4.3:

cat > .env << 'EOF'
N8N_ENCRYPTION_KEY=<paste the openssl output from Step 4.3 here>
EOF
chmod 600 .env

Start n8n:

docker compose up -d

Production hardening notes: - Image pinned to a specific version. Weekly maintenance will upgrade this deliberately instead of :latest auto-breaking you. - Port bound to 192.168.0.52 (LXC IP), not 0.0.0.0. Belt-and-suspenders with the Proxmox firewall. - Timezone set so scheduled workflows fire at the right local clock time. Adjust Asia/Singapore to your zone. - .env is chmod 600 and should be included in any backup of /opt/n8n/ — but also backed up separately in a password manager because this key is the master secret for all credentials.

4.5 — Verify n8n is Running

# From inside the n8n-app container (note: bind is on 192.168.0.52, not localhost)
curl -s http://192.168.0.52:5678/healthz
# Should return OK or a JSON response

# From the edge-gateway container
ssh root@192.168.0.51
curl -s http://192.168.0.52:5678
# Should return the n8n UI HTML

Step 5 — Configure Proxmox Container Firewalls

5.1 — edge-gateway Firewall Rules

Go to Datacenter → 101 (edge-gateway) → Firewall

First, enable the firewall: Options → Firewall → Yes

Then add rules in this exact order:

# Direction Action Source/Dest Port Protocol Comment
1 IN ACCEPT 127.0.0.1 80,443 TCP cloudflared local to NPM
2 IN ACCEPT +admin_desktop 81 TCP NPM admin panel
3 IN DROP Block all other inbound
4 OUT ACCEPT +router_gw 53 UDP DNS resolution
5 OUT ACCEPT +router_gw 53 TCP DNS resolution TCP
6 OUT ACCEPT 192.168.0.52 5678 TCP Proxy to n8n-app
7 OUT ACCEPT 192.168.0.53 TCP Proxy to biz-app Phase 2
8 OUT DROP +lan_subnet Block all other LAN
9 OUT ACCEPT 443 TCP cloudflared tunnel + APIs
10 OUT ACCEPT 80 TCP HTTP fallback

Rule order matters. Rule 8 (DROP lan_subnet) MUST be above rules 9-10 (ALLOW any). This ensures traffic to LAN IPs on port 443 is dropped before the wildcard allows it.

5.2 — n8n-app Firewall Rules

Go to Datacenter → 102 (n8n-app) → Firewall

Enable the firewall: Options → Firewall → Yes

Add rules in this exact order:

# Direction Action Source/Dest Port Protocol Comment
1 IN ACCEPT +edge_gw 5678 TCP Only proxy may reach n8n
2 IN ACCEPT +admin_desktop 22 TCP SSH from admin desktop
3 IN DROP Block all other inbound
4 OUT ACCEPT +router_gw 53 UDP DNS resolution
5 OUT ACCEPT +router_gw 53 TCP DNS resolution TCP
6 OUT DROP +lan_subnet Lateral movement prevention
7 OUT ACCEPT 443 TCP n8n external API calls

Critical: Rule 6 (DROP lan_subnet) MUST be above rule 7 (ALLOW any:443). This is the rule that prevents a compromised n8n from reaching your desktop, Proxmox host, or any LAN device — including via DNS rebinding attacks.

5.3 — Test Firewall Rules

From the n8n-app container:

# Should SUCCEED — external API access
curl -s https://httpbin.org/get | head -5

# Should FAIL / timeout — lateral movement blocked
curl -s --connect-timeout 3 http://192.168.0.16
# Expected: connection timeout

curl -s --connect-timeout 3 https://192.168.0.200:8006
# Expected: connection timeout

curl -s --connect-timeout 3 http://192.168.0.1
# Expected: connection timeout (except DNS on port 53)

From the edge-gateway container:

# Should SUCCEED — can reach n8n
curl -s http://192.168.0.52:5678 | head -5

# Should FAIL — cannot reach desktop
curl -s --connect-timeout 3 http://192.168.0.16
# Expected: connection timeout

If any test returns unexpected results, review rule ordering in the Proxmox Firewall UI.


Step 6 — Cloudflare DNS Configuration

6.1 — Add DNS Records

In the Cloudflare dashboard for exzentcg.com:

Go to DNS → Records → Add Record

Type Name Content Proxy
CNAME n8n (will be set by tunnel) Proxied (orange cloud)

Note: When you create the tunnel in Step 7, Cloudflare will automatically create or update the DNS record. You can also set this manually to point to the tunnel's generated CNAME.


Step 7 — Create Cloudflare Tunnel

7.1 — Create Tunnel in Dashboard

  1. Go to Cloudflare → Zero Trust → Networks → Tunnels
  2. Click Create a tunnel
  3. Select Cloudflared as the connector
  4. Name: exzentcg-homelab
  5. Click Save tunnel

7.2 — Copy the Tunnel Token

Cloudflare will show an install command containing a token. Copy the token (the long string after --token).

It will look like:

cloudflared service install eyJhIjoiNjI...very_long_string

Copy everything after --token.

7.3 — Start cloudflared on edge-gateway

cloudflared runs as a Docker Compose service alongside NPM (see Step 3.4). You only need to fill in the token and bring the service up.

SSH into edge-gateway:

ssh root@192.168.0.51
cd /opt/edge-gateway

Fill in the token in the .env file you created earlier:

# Replace the empty TUNNEL_TOKEN= line with your real token
nano .env

The file should now look like:

TUNNEL_TOKEN=eyJhIjoiNjI...<your_long_token>

Save and exit. Then start cloudflared:

docker compose up -d cloudflared

Verify:

# Check the cloudflared container is running
docker compose ps cloudflared
# Should show "Up" / "running"

# Tail logs to confirm the tunnel registered successfully
docker compose logs --tail 30 cloudflared
# Look for: "Registered tunnel connection" and 2-4 connection lines to Cloudflare edge

# Then check the Cloudflare dashboard — tunnel should show HEALTHY

If cloudflared fails to start: the most common cause is a malformed .env (extra quotes, stray whitespace, line breaks in the token). Make sure TUNNEL_TOKEN= is followed by the raw token with no quotes and no spaces.

7.4 — Configure Tunnel Ingress Rules

Back in the Cloudflare Dashboard → Zero Trust → Tunnels → exzentcg-homelab → Configure:

Go to the Public Hostname tab and add:

Subdomain Domain Path Service
n8n exzentcg.com (empty) http://localhost:80

Important: The service target is http://localhost:80 because cloudflared runs on the same container as NPM. cloudflared forwards to NPM, and NPM routes to the correct backend.

Click Save.


Step 8 — Configure NPM Proxy Host

8.1 — Add Proxy Host for n8n

From your desktop, go to http://192.168.0.51:81 (NPM admin panel).

  1. Click Hosts → Proxy Hosts → Add Proxy Host
  2. Fill in:
Field Value
Domain Names n8n.exzentcg.com
Scheme http
Forward Hostname / IP 192.168.0.52
Forward Port 5678
Websockets Support ✅ Enable
  1. Click Save

Websockets support is required — n8n's UI uses websockets for real-time updates.

8.2 — Test the Full Path

At this point, the traffic path is:

Internet → Cloudflare → Tunnel → cloudflared (localhost) → NPM (:80) → n8n (:5678)

From your desktop browser, visit:

https://n8n.exzentcg.com

You should see the n8n setup screen (or login screen if already configured). If you get a Cloudflare error page, check:

  1. Tunnel status in Cloudflare dashboard (should say HEALTHY)
  2. NPM proxy host configuration
  3. docker logs on both containers for errors

Step 9 — Cloudflare Zero Trust Access Policy

9.1 — Protect the n8n UI

  1. Go to Cloudflare → Zero Trust → Access → Applications
  2. Click Add an application → Self-hosted
  3. Fill in:
Field Value
Application name n8n
Application domain n8n.exzentcg.com
Session duration 24 hours
  1. Click Next to configure the policy:
Field Value
Policy name Allow admin
Action Allow
Selector Emails
Value your-email@example.com
  1. Authentication method: One-time PIN (simplest) or connect Google/GitHub as an identity provider.
  2. Click Save

9.2 — Bypass for Webhooks

If n8n will receive webhooks (from Stripe, Telegram, etc.), create a bypass:

  1. In the same application, go to Policies → Add a policy
Field Value
Policy name Webhook bypass
Action Bypass
Selector URI Path
Value /webhook/*
  1. Add another rule for the test webhook path:
Selector Value
URI Path /webhook-test/*
  1. Click Save

9.3 — Test Access Policy

From your desktop browser in an incognito/private window:

  1. Visit https://n8n.exzentcg.com
  2. You should see a Cloudflare Access login page asking for your email
  3. Enter your email → receive a one-time PIN → enter PIN
  4. You should now see the n8n login/setup screen

From a different email (or test with a colleague), verify that unauthorized emails are blocked.


Step 10 — n8n Initial Setup

10.1 — Create Owner Account

After passing the Cloudflare Access gate:

  1. n8n will show a setup wizard
  2. Create your owner account with a strong, unique password
  3. This is a secondary auth layer behind Cloudflare Zero Trust

10.2 — Verify Webhook URL

  1. Go to Settings → General in n8n
  2. Confirm Webhook URL is https://n8n.exzentcg.com
  3. If not, set it manually

10.3 — Test a Webhook

Create a simple test workflow:

  1. Add a Webhook trigger node
  2. Set method to GET
  3. Activate the workflow
  4. Copy the production webhook URL (e.g. https://n8n.exzentcg.com/webhook/abc123)
  5. Open that URL in a browser or curl it from anywhere:
curl https://n8n.exzentcg.com/webhook/abc123

If you get a response, the full chain is working end to end: Internet → Cloudflare → Tunnel → NPM → n8n → Response


Step 11 — Snapshot Everything

Now that Phase 1 is fully operational, take snapshots of both containers:

# From Proxmox host
pct stop 101
pct stop 102

11.2 — Create Snapshots

In the Proxmox WebUI:

  1. Go to 101 (edge-gateway) → Snapshots → Take Snapshot
  2. Name: phase1-complete
  3. Description: Phase 1 complete — cloudflared + NPM working

  4. Go to 102 (n8n-app) → Snapshots → Take Snapshot

  5. Name: phase1-complete
  6. Description: Phase 1 complete — n8n running, firewall tested

11.3 — Restart Containers

pct start 101
pct start 102

Verify everything comes back up:

# Wait 30 seconds, then check
curl -s --connect-timeout 5 https://n8n.exzentcg.com

Post-Deployment Verification Checklist

Run through every check. All must pass.

Security Checks

# From n8n-app container:

# ✅ Can reach external APIs
curl -s --connect-timeout 5 https://httpbin.org/get | head -3

# ❌ Cannot reach desktop
curl -s --connect-timeout 3 http://192.168.0.16
# Expected: timeout

# ❌ Cannot reach Proxmox host
curl -s --connect-timeout 3 https://192.168.0.200:8006
# Expected: timeout

# ❌ Cannot reach router (except DNS)
curl -s --connect-timeout 3 http://192.168.0.1
# Expected: timeout
# From edge-gateway container:

# ✅ Can reach n8n
curl -s --connect-timeout 5 http://192.168.0.52:5678 | head -3

# ❌ Cannot reach desktop
curl -s --connect-timeout 3 http://192.168.0.16
# Expected: timeout

# ❌ Cannot reach Proxmox host WebUI
curl -s --connect-timeout 3 https://192.168.0.200:8006
# Expected: timeout

Functionality Checks

Check How Expected Result
n8n UI loads Browser: https://n8n.exzentcg.com Cloudflare Access gate → n8n login
Zero Trust blocks strangers Incognito + wrong email Access denied
Webhook works Curl a test webhook URL Response from n8n
NPM admin accessible Browser: http://192.168.0.51:81 from desktop only NPM dashboard
Proxmox WebUI accessible Browser: https://192.168.0.200:8006 from desktop only Proxmox login
Tunnel status Cloudflare dashboard → Tunnels Shows HEALTHY

Service Persistence Checks

# Reboot both containers and verify auto-start
pct reboot 101
pct reboot 102

# Wait 60 seconds, then verify
curl -s --connect-timeout 10 https://n8n.exzentcg.com
# Should load (Cloudflare Access page or n8n UI)

ssh root@192.168.0.51 "cd /opt/edge-gateway && docker compose ps"
# Should show both npm and cloudflared containers running

ssh root@192.168.0.52 "docker ps"
# Should show n8n container running

Ongoing Maintenance

Frequency Task Command / Location
Weekly Update n8n (bump pinned version in compose, then) ssh root@192.168.0.52 "cd /opt/n8n && docker compose pull && docker compose up -d"
Weekly Check Cloudflare Access logs Zero Trust → Logs → Access
Monthly Review firewall DROP logs Proxmox → each LXC → Firewall → Log
Monthly Snapshot both LXCs Proxmox → Snapshots
Monthly Verify firewall rule order Proxmox → each LXC → Firewall (rules haven't shifted)
Monthly Update edge-gateway LXC packages ssh root@192.168.0.51 "apt update && apt upgrade -y"
Monthly Update NPM + cloudflared images ssh root@192.168.0.51 "cd /opt/edge-gateway && docker compose pull && docker compose up -d"
As needed Rotate n8n credentials n8n Settings → Credentials
Never Rotate N8N_ENCRYPTION_KEY Would require re-entering every credential in n8n. Back it up instead.

Troubleshooting Quick Reference

Symptom Likely Cause Fix
Locked out of Proxmox WebUI Firewall misconfiguration Physical console: pve-firewall stop then fix rules
Tunnel shows DEGRADED cloudflared container stopped ssh root@192.168.0.51 "cd /opt/edge-gateway && docker compose restart cloudflared"
cloudflared can't reach NPM network_mode: host missing on cloudflared Check compose file — cloudflared must be network_mode: host to see NPM's 127.0.0.1:80
n8n unreachable via browser NPM proxy misconfigured Check NPM → Proxy Hosts → ensure :5678 and websockets on
n8n shows 502 Bad Gateway n8n container down ssh root@192.168.0.52 "cd /opt/n8n && docker compose up -d"
n8n credentials unusable after restart N8N_ENCRYPTION_KEY changed or .env missing Restore .env from password manager backup — there is no other recovery
Webhooks failing Access policy blocking them Add /webhook/* bypass in Zero Trust
n8n can reach LAN devices Firewall rule order wrong Verify DROP lan_subnet is ABOVE ALLOW any:443
DNS not resolving in containers Missing resolver echo "nameserver 192.168.0.1" > /etc/resolv.conf
NPM admin panel not loading Firewall blocking port 81 Verify rule: IN ACCEPT admin_desktop 81 TCP