homelab

Mini PC · Proxmox VE · 16GB RAM

Operational
Physical
Network routing
DNS
Data / API
Auto-deploy
Mgmt / Remote
Arrow = one-way flow
🔒 Tailscale EXT
GitHub EXT
🤖 Anthropic Claude API + MCPs EXT
🌤 Open-Meteo EXT
📧 SMTP provider smtp :465 EXT
📡 ISP Router 192.168.X.X
🔀 Unifi Flex Mini 10.0.10.87 2.5G Switch
🖥️ Mini PC — 16GB RAM · 256GB SSD
Proxmox VE — 10.0.0.XX
🛡️ OPNsense VM WAN 192.168.X.XX LAN 10.0.0.1 VM
LAN · 10.0.0.0/24 VLAN 10 · 10.0.10.0/24 VLAN 20 · 10.0.20.0/24 VLAN 30 · 10.0.30.0/24 VLAN 40 · 10.0.40.0/24
VLAN 10 — mgmt · 10.0.10.0/24
🚫 AdGuard Home 10.0.10.XX LXC
🖧 Unifi OS Server 10.0.10.XX LXC
📊 Homarr 10.0.10.XX LXC
VLAN 20 — services · 10.0.20.0/24 · tailscale-only inbound
📖 homelab-site 10.0.20.XX nginx · this page LXC
🎬 Jellyfin 10.0.20.x (planned) VM
VLAN 30 — automation · 10.0.30.0/24 · internet-only
agent60 VM — Ubuntu (default user) · 10.0.30.XX · 2 cores · 2GB
agent60
📅 Cron Reports Fri · Mon · Tue–Sun SVC
💬 Claude CLI claude -p SVC
📤 msmtp SMTP client SVC
auto-deploy
🌐 ngrok :443 tunnel SVC
🪝 webhook Node.js :3000 SVC
LAN

LAN

10.0.0.0/24
Proxmox 10.0.0.XX OPNsense 10.0.0.1 Trusted wired
ActionSourceDestinationNote
BlockLANmgmtIsolated
BlockLANservicesIsolated
BlockLANautomationIsolated
PassLAN*Internet OK
10

mgmt

10.0.10.0/24
AdGuard 10.0.10.XX Unifi OS 10.0.10.XX Homarr 10.0.10.XX
ActionPortDestinationNote
Pass53*AdGuard bootstrap DNS (resolves DoH upstream hostname)
Pass53AdGuardDNS (internal — mgmt devices → AdGuard)
Block*RFC1918No internal
Pass443*HTTPS — updates / Ubiquiti cloud / AdGuard DoH upstream
Pass80*HTTP — apt repos
Block**Default deny
20

services

10.0.20.0/24
homelab-site 10.0.20.XX Jellyfin (planned) tailscale-only inbound
ActionPortDestinationNote
Pass53AdGuardDNS
Block*RFC1918No internal access
Pass443*HTTPS — updates, metadata
Pass80*HTTP — apt repos
Block**Deny everything else
30

automation

10.0.30.0/24
agent60 VM 10.0.30.XX agent60 internet-only
ActionPortDestinationNote
Pass53AdGuardDNS only
Block*RFC1918No internal access
Pass443*HTTPS (Claude, Notion, GCal)
Pass80*HTTP (apt repos)
Pass465*SMTP/SSL (SMTP provider)
Block**Deny everything else
40

iot

10.0.40.0/24
No devices yet

Reserved for IoT / smart home devices. Firewall rules not yet configured.

Scheduled Reports

🌅

Monday Brief

0 10 * * 1

Preview of the week ahead: personal calendar highlights + 7-day weather forecast.

Google Calendar MCPOpen-Meteo5:00 AM local
☀️

Daily Brief

0 10 * * 2-7

Today's calendar events, weather forecast, and outfit/prep tip.

Google Calendar MCPOpen-Meteo5:00 AM local Tue–Sun
📅

Friday Report

0 23 * * 5

Weekly wrap-up of online coding class notes transcribed in Notion — topics covered, concepts learned, follow-ups.

Notion MCP6:00 PM local

Report Pipeline

Croncrontab
📜Shell script*.sh
💬claude -pheadless
🔌MCP + APIsNotion · GCal · Weather
🎨HTML templatefilled by Claude
📤msmtpSMTP provider :465
📧InboxHTML email

Auto-Deploy Pipeline

git pushto main
🐙GitHub webhookapplication/json
🌐ngrok tunnelngrok-free.app
🪝webhook.serviceHMAC-SHA256
📥git pull/opt/agent60
📅 agent60

Symptom

msmtp: permission denied when scripts pass --file= pointing to a path inside the repo.

Cause

AppArmor restricts msmtp to reading files only from the home directory.

Fix

Copy config to ~/.msmtprc and remove --file= from all scripts.

cp /opt/agent60/.msmtprc ~/.msmtprc chmod 600 ~/.msmtprc

Symptom

Cron job exits with code 127. Log shows command not found: claude.

Cause

Cron runs with a stripped PATH. /home/user/.local/bin (where claude lives) is not included.

Fix

Add this as the very first line of crontab -e:

PATH=/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin

Symptom

msmtp silently hangs or shows connect failed. Automation VLAN is IPv4-only but msmtp resolves SMTP hostnames to IPv6 first.

Fix

Disable IPv6 on agent60 VM permanently:

echo "net.ipv6.conf.all.disable_ipv6 = 1 net.ipv6.conf.default.disable_ipv6 = 1" | sudo tee /etc/sysctl.d/99-no-ipv6.conf sudo sysctl --system

Symptom

msmtp: authentication failed even with the correct password.

Fix

Use an app password — not your regular account password. For the SMTP provider: Account Security → App Passwords.

Symptom

msmtp reports success, logs look clean, but the report email never lands in the inbox.

Cause

The test message body is just the word test with no Subject: header or real body. Providers (Gmail, etc.) silently drop or spam-filter these as malformed / suspicious mail — msmtp still reports success because the SMTP handhake completed.

Fix

Send test messages with a proper subject and body, e.g.:

printf "Subject: msmtp test\n\nThis is a test email from the homelab.\n" | msmtp you@example.com

Once a real subject and body are present, the message arrives normally.

Fix

Remove tls_trust_file from ~/.msmtprc to let msmtp use the system default CA bundle. Setting it manually to a nonexistent path causes certificate errors.

Diagnose

Test MCP connectors interactively and verify auth:

echo "List my Google Calendar events for this week" | claude -p echo "List my online coding class notes transcribed in Notion from the past week" | claude -p claude whoami

If connectors return empty, verify they are active at claude.ai/connectors.

Cause

The agent60 VM runs UTC. local = UTC offset. Cron times must be offset accordingly.

Check & fix

timedatectl # To set: sudo timedatectl set-timezone America/Chicago

Example: 5:00 AM local → 0 10 * * 1 in UTC

Debug

Run with --dry-run to generate the full report and print HTML without sending email:

./friday_report.sh --dry-run ./monday_brief.sh --dry-run ./daily_brief.sh --dry-run
🔀 Network & SSH

Cause

VLAN 30 only allows port 443 outbound. SSH default port 22 is blocked by firewall rules.

Fix

Route SSH via ssh.github.com:443. Add to ~/.ssh/config:

Host github.com Hostname ssh.github.com Port 443

Diagnose

systemctl status webhook ngrok journalctl -fu webhook journalctl -u webhook --no-pager | grep -E "Pull|Rejected|Webhook"

If ngrok is down, verify the static domain in ~/.config/ngrok/ngrok.yml matches the GitHub webhook URL. If the webhook is rejected, check the HMAC secret matches index.js.

By design

Proxmox (10.0.0.XX:8006) is only reachable from the LAN (10.0.0.0/24). The OPNsense Tailscale plugin advertises all subnets (LAN + VLAN 10/20/30/40), so any tailnet device has full L3 reach — connect via Tailscale and browse to 10.0.0.XX:8006. Same path reaches Homarr (10.0.10.XX), AdGuard (10.0.10.XX), Unifi OS (10.0.10.XX), and agent60 VM (10.0.30.XX).

🖥️ Proxmox & System

Symptom

TOTP code rejected at login screen — usually caused by clock drift.

Fix

Verify NTP sync. Re-generate TOTP secret under user settings if needed. 2FA locations: Proxmox → Datacenter → Users → Edit; OPNsense → System → Access → Users.

Diagnose

systemctl status cron crontab -l tail -f /opt/agent60/logs/friday_report.log

Simulate the exact cron environment to reproduce issues locally:

env -i PATH=/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin \ HOME=$HOME USER=$USER \ /opt/agent60/friday_report.sh --dry-run

Symptom

msmtp: connect failed when scripts pass --file= pointing to a path inside the repo.

Scripts fail with Unable to connect to Anthropic services — ECONNREFUSED. curl shows Could not resolve host: api.anthropic.com. DNS broken across all VLANs.

Cause

AdGuard uses DoH upstream (https://dns10.quad9.net/dns-query) and requires a bootstrap DNS lookup on plain port 53 to resolve the Quad9 hostname before it can connect. Adding a "deny everything else" rule to mgmt blocked this outbound port 53 request. AdGuard's cached IP kept things working briefly, then DNS failed entirely once the cache expired.

Fix

Add a rule in mgmt above the RFC1918 block allowing AdGuard's IP to reach any destination on port 53:

Pass TCP/UDP 10.0.10.XX → *:53 — AdGuard bootstrap DNS

Rule order matters: this rule must come before the RFC1918 block, otherwise AdGuard's bootstrap queries to public resolvers are caught and dropped.

📖

Homelab Documentation

Single-node Proxmox VE homelab on a Mini PC, running OPNsense as the core firewall & router. Four VLANs segment management, services, automation, and IoT. AdGuard Home provides network-wide DNS plus .homelab URL rewrites. Tailscale (via the OPNsense exit-node plugin) advertises every subnet for secure remote access.

🎯 Goals

🖥️ Hardware

🔌 Physical Wiring

  1. WAN: Cat6 from wall → Mini PC nic0 (vmbr0)
  2. LAN: Mini PC USB port → USB/USB-C adapter → USB-C to Ethernet adapter → Cat6 patch → Port 2 of Flex Mini (vmbr1)

🌐 Network Architecture

Home gateway is 192.168.X.X (ISP router, DHCP range 192.168.X.x). OPNsense runs as a Proxmox VM with vmbr0 as WAN (192.168.X.XX/24) and vmbr1 as LAN (10.0.0.1/24). Proxmox itself sits on 10.0.0.XX with gateway 10.0.0.1 — only reachable through the LAN. The OPNsense Tailscale plugin runs as an exit node and advertises every VLAN subnet for remote access.

Division of labor

🛡️ OPNsense VM — does all the actual networking

  • Creates + enables each VLAN on the vmbr1 parent (Interfaces → Other Types → VLAN)
  • Configures each VLAN interface (subnet, gateway IP, MTU) under Interfaces → Assignments
  • Runs DHCP on every VLAN (advertises AdGuard 10.0.10.XX as DNS)
  • Default gateway for every subnet (.1 of each /24)
  • All firewall rules between VLANs and to/from WAN
  • Forwards LAN DNS → AdGuard, handles NAT for WAN egress

🖧 Unifi OS Server — dumb-pipe configurator

  • Defines the VLAN tag numbers (10 / 20 / 30 / 40) so the switch knows what to forward
  • Port 2 native VLAN = LAN, trunks tags 10/20/30/40 (uplink to OPNsense vmbr1)
  • Assigns access VLAN per remaining port as devices are added
  • Does NOT do subnets, gateways, DHCP, DNS, firewall, or routing — all OPNsense

🚫 AdGuard Home — DNS + ad blocking

  • DNS resolver for every LXC, VM, and tailnet client
  • Upstream: Quad9 DoH (https://dns10.quad9.net/dns-query) — malware-blocking endpoint over HTTPS:443
  • Hosts .homelab URL rewrites
  • OPNsense DHCP and the Tailscale plugin both advertise AdGuard as DNS

🔀 VLAN Layout

VLANTagSubnetDHCP RangePurpose
LAN10.0.0.0/2410.0.0.50 – 200Proxmox host, trusted wired devices
mgmt1010.0.10.0/2410.0.10.50 – 200Proxmox, OPNsense, Unifi OS, AdGuard, Homarr
services2010.0.20.0/2410.0.20.50 – 200Self-hosted services — homelab-site, Jellyfin (planned)
automation3010.0.30.0/2410.0.30.50 – 200agent60 VM
iot4010.0.40.0/2410.0.40.50 – 200IoT / smart home (future)

All DHCP servers advertise 10.0.10.XX (AdGuard) as DNS.

🛡️ Firewall Rules

LAN

ActionProtocolSourceDestinationDescription
BlockIPv4 *LAN networkmgmt networkBlock LAN → mgmt
BlockIPv4 *LAN networkservices networkBlock LAN → services
BlockIPv4 *LAN networkautomation networkBlock LAN → automation
PassIPv4 *LAN network*Allow LAN to internet

LAN devices use OPNsense (10.0.0.1) for DNS, which forwards to AdGuard internally. Mgmt UIs (Homarr, Proxmox, OPNsense) reached via Tailscale when on LAN or remotely.

mgmt (VLAN 10)

ActionProtocolSourceDestinationPortDescription
PassTCP/UDP10.0.10.XX*53AdGuard bootstrap DNS — must be first; resolves DoH upstream hostname (dns10.quad9.net) before RFC1918 block
PassTCP/UDPmgmt network10.0.10.XX53DNS to AdGuard (mgmt devices)
Block*mgmt networkRFC1918*Block all internal nets
PassTCPmgmt network*443HTTPS — updates, Ubiquiti cloud, Proxmox community repo, AdGuard DoH upstream
PassTCPmgmt network*80HTTP — apt package repos
Block*mgmt network**Default deny

services (VLAN 20)

ActionProtocolSourceDestinationPortDescription
PassTCP/UDPservices network10.0.10.XX53DNS to AdGuard
Block*services networkRFC1918*Block lateral access to other internal networks
PassTCPservices network*443HTTPS — updates, Jellyfin metadata (TMDB / TVDB), GitHub SSH-via-443
PassTCPservices network*80HTTP — apt package repos
Block*services network**Deny everything else

Inbound: LAN → services stays blocked. Jellyfin (and any future service) is reached via Tailscale only — the OPNsense Tailscale plugin already advertises 10.0.20.0/24 to the tailnet, so tailnet devices have L3 reach without a per-port pass rule.

automation (VLAN 30)

ActionProtocolSourceDestinationPortDescription
PassTCP/UDPautomation network10.0.10.XX53DNS to AdGuard
Block*automation networkRFC1918*No access to any internal network
PassTCPautomation network*443HTTPS — Anthropic, Notion, Google, Open-Meteo
PassTCPautomation network*80HTTP — apt package repos
PassTCPautomation network*465SMTP SSL — SMTP provider
Block*automation network**Deny everything else

iot (VLAN 40)

Rules not yet configured — no devices on this VLAN.

WAN / Tailscale

No inbound rules (default deny). Tailscale subnet routing handled by the OPNsense Tailscale plugin.

📊 LXC + VM Resources

ServiceTypeIP(s)RAMCoresVLAN
OPNsenseVMWAN 192.168.X.XX · LAN 10.0.0.1 · mgmt 10.0.10.12GiB / 4GiB swap4 (1 socket)
AdGuard HomeLXC10.0.10.XX2GiB / 2GiB swap1mgmt
Unifi OS ServerLXC10.0.10.XX4GiB / 512MiB swap2mgmt
HomarrLXC10.0.10.XX2GiB / 512MiB swap2mgmt
homelab-siteLXC10.0.20.XX256MiB / 256MiB swap1services
agent60 VMVM10.0.30.XX2GiB / 512MiB swap2automation

📦 Service Summary

🛡️ OPNsense (VM)

Core firewall, router, DHCP, DNS forwarder, NAT. WAN on vmbr0, LAN + VLANs on vmbr1. Tailscale plugin enabled as exit node advertising every VLAN subnet.

🖧 Unifi OS Server (LXC)

Controller for the Flex Mini switch (the switch has no UI of its own). Defines VLAN tag numbers and Port 2 trunk. OPNsense handles all the actual networking.

🚫 AdGuard Home (LXC)

DNS resolver and ad/tracker blocker for every device. Quad9 DoH upstream (dns10.quad9.net — malware-blocking endpoint). Hosts .homelab URL rewrites so memorable names work across LAN / VLANs / Tailscale.

📊 Homarr (LXC)

Homelab dashboard — service links and status. Reachable from mgmt VLAN directly or via Tailscale (LAN → mgmt blocked at the firewall). Pending: move to services VLAN.

📖 homelab-site (LXC, services VLAN)

Static nginx serving this very page (homelab_reorganization.html) at http://map.homelab for personal viewing. Tailscale-only inbound (LAN → services blocked). Private GitHub repo cloned via SSH using a read-only deploy key tunneled over HTTPS:443 (services VLAN doesn't allow :22). Manual git pull to update. If the LXC is ever compromised, blast radius is read access to this one repo — nothing else.

🤖 agent60 VM (Ubuntu, automation VLAN)

Isolated automation VM — internet-only egress, no internal access. Default user account. Hosts agent60 and the ngrok / webhook auto-deploy stack. Strong random password generated by and stored in a password manager — password-based SSH safe as a fallback alongside the ed25519 key.

🏷️ .homelab URL Rewrites

AdGuard rewrites these hostnames to internal IPs so memorable URLs work from any device on LAN, any VLAN, or Tailscale:

HostnameIPService
proxmox.homelab10.0.0.XXProxmox VE
opnsense.homelab10.0.10.1OPNsense admin
adguard.homelab10.0.10.XXAdGuard Home
unifi.homelab10.0.10.XXUnifi OS Server
homarr.homelab10.0.10.XXHomarr dashboard
map.homelab10.0.20.XXhomelab-site LXC (this dashboard)
agent.homelab10.0.30.XXagent60 VM (automation)

📅 agent60

Three cron-scheduled scripts on the agent60 VM. VM runs UTC, so cron times are offset to match local time (UTC offset):

Each script runs Claude CLI headless (claude -p), queries MCP connectors (Notion / Google Calendar) plus Open-Meteo via WebFetch, fills an HTML email template, and sends via msmtp to SMTP provider (smtp.example.com:465, implicit TLS).

See the agent60 tab for the full pipeline and auto-deploy flow diagram.

🔐 GitHub SSH (on agent60 VM)

🪝 Auto-Deploy (GitHub Webhook + ngrok)

Two systemd services on the agent60 VM, both enabled and auto-starting on boot. Pulls latest code to /opt/agent60 on every push to main:

webhook.service

Node.js HTTP server at localhost:3000 (/opt/webhook/index.js). Verifies HMAC-SHA256 signature, then runs git -C /opt/agent60 pull. 5s execSync timeout. Per-IP 5s rate limit (429 on repeats). Logs to journald.

ngrok.service

Exposes localhost:3000 via a static ngrok-free.app domain over HTTPS. Config at ~/.config/ngrok/ngrok.yml (chmod 600). Outbound on port 443 — the only port allowed off VLAN 30.

GitHub webhook payload URL: https://<static-domain>.ngrok-free.app/webhook · content-type JSON · push events only · HMAC secret matches SECRET in index.js.

🔒 Security Hardening

Completed

Pending

📚 See Also

🖥️