The network
Photo by Kvistholt Photography on Unsplash
Five devices, zero open ports, one mesh. How Tailscale, Pi-hole, Cloudflare Tunnel, and NPM fit together — and why it's wired this way.
Every service in this homelab is accessible from anywhere — but nothing is open to the internet by default. No port forwarding. No VPN to configure per device. No firewall rules to punch holes through.
Here’s how that works.
The full picture
- Pi-hole DNS
- Tailscale exit node
- Watchtower
- n8n
- Penpot
- CouchDB
- Ollama / Open WebUI
- AMD 780M · Vulkan · 3GB
- NPM / HTTPS
- Immich
- Homepage
- Uptime Kuma
- cloudflared
- Claude Code
- Obsidian
- Figma · Browser
- Obsidian
- Blink Shell
Five nodes. Two traffic models. One mesh tying it together.
The mesh — Tailscale
The foundation is Tailscale, a zero-config mesh VPN built on WireGuard. Every device — Mac, GMKtec, Pi4, Linode, iPhone — gets a stable 100.x.x.x IP that never changes regardless of what network it’s on. They all reach each other directly, peer-to-peer, without a central relay when possible.
Why Tailscale over a self-hosted WireGuard setup:
- Zero configuration. No keys to distribute, no subnets to configure. Install, authenticate, done.
- Works through NAT. Home networks, mobile data, corporate WiFi — it punches through all of them.
- ACLs that don’t require an ops team. A JSON policy file controls who can reach what.
- Subnet routing. Pi4 advertises
192.168.68.0/24so every Tailscale device can reach home LAN IPs directly.
The mesh means internal services never need to be exposed publicly. n8n.[domain], penpot.[domain], couchdb:5984 — all Tailscale-only.
The exit node — Linode VPS
The Linode $5/month Nanode runs two things:
Pi-hole — a DNS sinkhole that blocks ad/tracker domains for every device on the mesh. All Tailscale devices point their DNS to 100.x.x.x (Linode’s Tailscale IP). It runs on the VPS instead of Pi4 because VPS uptime is more reliable — a Pi4 reboot or power cut doesn’t kill DNS for every device.
Tailscale exit node — when enabled, all internet traffic routes through Linode’s Singapore IP. Useful when on untrusted networks.
Public access — Cloudflare Tunnel
Only one service needs to be accessible from the internet without Tailscale: Immich (photo backup). The iPhone app runs in the background and can’t have Tailscale running simultaneously.
Cloudflare Tunnel handles this without opening a single port:
cloudflareddaemon runs on Pi4, maintains an outbound-only connection to Cloudflare’s networkimmich.[domain]DNS record points to the Cloudflare-managed tunnel hostname- Requests from anywhere hit Cloudflare → travel through the tunnel → reach Pi4 → Immich
- Cloudflare Access sits in front of it (Google login + FaceID biometrics required)
Everything else — n8n, Penpot, CouchDB, Homepage, Uptime Kuma — has no public DNS record pointing anywhere reachable. Requests from the internet just fail silently.
HTTPS everywhere, zero open ports — NPM
Nginx Proxy Manager runs on Pi4 and terminates HTTPS for all internal services. It gets wildcard Let’s Encrypt certs via DNS Challenge through the Cloudflare API — no HTTP challenge, no open port 80.
The proxy host for n8n.[domain] forwards to 100.x.x.x:5678 (GMKtec’s Tailscale IP). Same for Penpot, CouchDB, Open WebUI. Pi4 is the single HTTPS entry point for the internal mesh.
Traffic paths
How a request actually moves depending on where you are:
The difference matters: internal requests never leave the Tailscale mesh. They resolve to a private IP, hit NPM on Pi4, and land on GMKtec — all within the 100.x.x.x address space. Public requests take the Cloudflare path, and only Immich is on that path.
The CDN — Cloudflare R2
Static assets for artupinad.com are served from a Cloudflare R2 bucket (artupinad) via cdn.[domain]. R2 is S3-compatible with zero egress fees — n8n can write files to it via the S3 node (Force Path Style ON, region auto, endpoint at the account-scoped R2 URL).
This is the only part of the stack where Cloudflare is doing actual content serving rather than just routing.
Why this complexity?
The alternative is simpler on paper: port-forward everything, run Nginx directly, use dynamic DNS. But:
- Port forwarding exposes services to the public internet with no auth layer
- Dynamic DNS breaks during ISP changes or router reboots
- Every new service needs a new firewall rule
- Mobile devices on different networks need separate VPN configs
This setup has a higher initial cost (got the Linode, configured Tailscale ACLs, wired DNS Challenge) but the ongoing cost is near zero. A new service goes live in three steps: deploy container on GMKtec, add proxy host in NPM, add Pi-hole DNS record. It’s reachable on all Tailscale devices in under two minutes, with HTTPS, no port changes.