Series: How I Used Claude Code to Slash My Cloud Bill by 63%
- Part 1: The $198/Month Wake-Up Call
- Part 2: Migrating 7 Static Sites to Cloudflare Pages
- Part 3: Killing the Load Balancer with Cloudflare Tunnel
- Part 4: Building a Home Router with a Raspberry Pi 5
- Part 5: Connecting Everything with Tailscale (you are here)
- Part 6: The Final Architecture - $74/mo and Zero Regrets
The Problem: Private Access Without Public Exposure
After the Cloudflare Tunnel work in Part 3, I had one service (n8n) accessible through a tunnel and everything else locked down inside the Kubernetes cluster. But I had other internal services I wanted to reach from my home network – things like SearXNG, Redis Commander, and the Kubernetes dashboard. None of these should be publicly accessible. I don’t want a public endpoint for my search engine or my database admin tool.
I needed a private network that spans my home LAN (behind the OpenWrt Pi from Part 4) and my DigitalOcean Kubernetes cluster. Without opening ports, without managing certificates, without running my own WireGuard server.
Why Tailscale
Tailscale is a mesh VPN built on WireGuard. Every device on the tailnet gets a 100.x.y.z address and can reach every other device directly – peer-to-peer when possible, relayed through Tailscale’s DERP servers when NAT traversal fails. No central VPN server. No hub-and-spoke bottleneck.
The free tier (called “Personal”) gives you:
- 100 devices
- 3 users
- Unlimited bandwidth
- Subnet routing
- MagicDNS
For my use case – three or four devices on the mesh – this is more than enough.
Installing Tailscale on OpenWrt
The Pi 5 running OpenWrt was the first node. OpenWrt 25.12.2 ships with Tailscale in its package repository:
apk add tailscale
Installing tailscale (1.94.1-1) to root...
Downloading https://downloads.openwrt.org/releases/25.12.2/packages/aarch64_cortex-a76/packages/tailscale_1.94.1-1_aarch64_cortex-a76.apk
Installing tailscale (1.94.1-1)...
Configuring tailscale.
Version 1.94.1. Then I started the daemon and brought it up:
/etc/init.d/tailscale start
/etc/init.d/tailscale enable
tailscale up --accept-dns=false --advertise-routes=192.168.1.0/24
A few important flags:
--accept-dns=false: I don’t want Tailscale overriding OpenWrt’s DNS configuration. The Pi is running its own DNS (dnsmasq + adblock) and I want to keep that.--advertise-routes=192.168.1.0/24: This tells Tailscale that the Pi can route traffic to the192.168.1.0/24subnet. Any device on the tailnet that accepts this route can reach devices on my home LAN through the Pi.
The command printed an auth URL:
To authenticate, visit:
https://login.tailscale.com/a/abc123def456
I opened the link on my Mac, logged into Tailscale, and approved the device. The Pi got its Tailscale IP:
tailscale status
100.92.14.87 openwrt scott@ linux -
Node one was on the mesh.
Deploying a Subnet Router in Kubernetes
The second node needed to be inside the Kubernetes cluster, advertising the K8s ClusterIP range so I could reach internal services from home.
Claude created terraform/tailscale.tf:
variable "tailscale_auth_key" {
type = string
sensitive = true
}
resource "kubernetes_namespace" "tailscale" {
metadata {
name = "tailscale"
}
}
resource "kubernetes_secret" "tailscale_auth" {
metadata {
name = "tailscale-auth"
namespace = kubernetes_namespace.tailscale.metadata[0].name
}
data = {
TS_AUTHKEY = var.tailscale_auth_key
}
}
resource "kubernetes_service_account" "tailscale" {
metadata {
name = "tailscale"
namespace = kubernetes_namespace.tailscale.metadata[0].name
}
}
resource "kubernetes_role" "tailscale" {
metadata {
name = "tailscale"
namespace = kubernetes_namespace.tailscale.metadata[0].name
}
rule {
api_groups = [""]
resources = ["secrets"]
verbs = ["create", "get", "update", "patch"]
}
}
resource "kubernetes_role_binding" "tailscale" {
metadata {
name = "tailscale"
namespace = kubernetes_namespace.tailscale.metadata[0].name
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "Role"
name = kubernetes_role.tailscale.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.tailscale.metadata[0].name
namespace = kubernetes_namespace.tailscale.metadata[0].name
}
}
resource "kubernetes_deployment" "tailscale_subnet_router" {
metadata {
name = "tailscale-subnet-router"
namespace = kubernetes_namespace.tailscale.metadata[0].name
labels = { app = "tailscale-subnet-router" }
}
spec {
replicas = 1
selector {
match_labels = { app = "tailscale-subnet-router" }
}
template {
metadata {
labels = { app = "tailscale-subnet-router" }
}
spec {
service_account_name = kubernetes_service_account.tailscale.metadata[0].name
container {
name = "tailscale"
image = "ghcr.io/tailscale/tailscale:latest"
env {
name = "TS_AUTHKEY"
value_from {
secret_key_ref {
name = kubernetes_secret.tailscale_auth.metadata[0].name
key = "TS_AUTHKEY"
}
}
}
env {
name = "TS_ROUTES"
value = "10.245.0.0/16"
}
env {
name = "TS_HOSTNAME"
value = "k8s-subnet-router"
}
env {
name = "TS_KUBE_SECRET"
value = "tailscale-state"
}
env {
name = "TS_USERSPACE"
value = "false"
}
security_context {
capabilities {
add = ["NET_ADMIN", "NET_RAW"]
}
}
resources {
requests = { memory = "64Mi", cpu = "50m" }
limits = { memory = "128Mi" }
}
}
}
}
}
}
The key environment variable is TS_ROUTES=10.245.0.0/16 – that’s the DigitalOcean Kubernetes ClusterIP range. The subnet router advertises this range to the tailnet, meaning any device on the mesh can route traffic to K8s ClusterIPs through this pod.
I generated a reusable auth key in the Tailscale admin console (Settings > Keys > Generate auth key), marked it as reusable so the pod can re-authenticate if it restarts, and stored it as a GitHub Actions secret (TF_VAR_TAILSCALE_AUTH_KEY).
Push to main. Terraform applies. The pod came up:
kubectl logs -n tailscale deployment/tailscale-subnet-router
2026-03-29T16:42:11Z boot: Tailscale starting...
2026-03-29T16:42:12Z control: AuthURL is https://login.tailscale.com/a/...
2026-03-29T16:42:13Z control: authenticated as k8s-subnet-router
2026-03-29T16:42:13Z wgengine: Adding route 10.245.0.0/16
2026-03-29T16:42:14Z control: netmap received, 2 peers
But the routes weren’t active yet. Tailscale requires you to manually approve advertised routes in the admin console.
Approving Routes
In the Tailscale admin dashboard (https://login.tailscale.com/admin/machines):
- Click on
k8s-subnet-router - Under “Subnets”, see
10.245.0.0/16listed as “Awaiting approval” - Toggle it to approved
Same for openwrt:
- Click on
openwrt - Under “Subnets”, see
192.168.1.0/24listed as “Awaiting approval” - Toggle it to approved
Both subnet routers were now advertising their local networks to the tailnet.
Testing the Connection
First, basic connectivity. From the Pi, I pinged the K8s subnet router’s Tailscale IP:
ping -c 3 100.108.47.63
PING 100.108.47.63 (100.108.47.63): 56 data bytes
64 bytes from 100.108.47.63: seq=0 ttl=64 time=23.4 ms
64 bytes from 100.108.47.63: seq=1 ttl=64 time=22.8 ms
64 bytes from 100.108.47.63: seq=2 ttl=64 time=23.1 ms
23ms latency from my home in the midwest to a DigitalOcean datacenter. WireGuard overhead is negligible.
Then the real test – reaching a K8s ClusterIP from the Pi:
curl -s http://10.245.15.122:5678 | head -5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>n8n - Workflow Automation</title>
I was hitting n8n’s ClusterIP directly from my home router, over the Tailscale mesh, through the K8s subnet router. No public endpoint, no load balancer, no tunnel. Just WireGuard packets flowing through the mesh.
Three Devices, One Mesh
tailscale status
100.92.14.87 openwrt scott@ linux -
100.108.47.63 k8s-subnet-router scott@ linux idle
100.73.201.15 ipad-pro scott@ iOS idle
Three nodes on the tailnet. The iPad joined when I installed the Tailscale app from the App Store and logged in. Now I can reach internal K8s services from my iPad on the couch. Or from a coffee shop. Or from anywhere with internet.
The Routing Misconception
I initially thought that because Tailscale was running on the Pi (my router), all devices on my home LAN would automatically be able to route through the tailnet to reach K8s services. I tried hitting 10.245.15.122 from my Mac (which is connected to the Pi’s WiFi but doesn’t have Tailscale installed):
curl http://10.245.15.122:5678
curl: (7) Failed to connect to 10.245.15.122 port 5678 after 5012 ms: No route to host
That’s not how Tailscale works. Tailscale is a per-device mesh. Each device needs to be running the Tailscale client to participate in the network. The Pi advertising 192.168.1.0/24 means other tailnet devices can reach the LAN – but LAN devices can’t reach the tailnet unless they’re running Tailscale themselves.
The subnet router is an on-ramp into the LAN for tailnet devices. It’s not an on-ramp out of the LAN for non-tailnet devices. This is actually the correct security model – I don’t want every device on my WiFi automatically having access to my K8s cluster. I want access to be per-device and authenticated.
I installed Tailscale on my Mac, and then:
curl -s http://10.245.15.122:5678 | head -1
<!DOCTYPE html>
Works.
Accepting Routes on OpenWrt
One more thing I needed to configure: the OpenWrt node needed to accept the K8s routes being advertised by the subnet router. By default, Tailscale only advertises routes – it doesn’t accept routes from other nodes unless told to.
tailscale up --accept-dns=false --advertise-routes=192.168.1.0/24 --accept-routes=true
The --accept-routes=true flag tells the Pi to install routes from other subnet routers into its routing table. Now the Pi itself could reach K8s ClusterIPs, which mattered because I was running monitoring scripts on the Pi that checked service health.
ip route show table 52
10.245.0.0/16 dev tailscale0
The K8s ClusterIP range appeared in the Tailscale routing table on the Pi. Bidirectional routing across the mesh.
Two Paths to n8n
At this point, n8n was reachable via two completely different paths:
Public path (for browser access from anywhere):
Browser → Cloudflare Edge → CF Tunnel → cloudflared pod → n8n pod
URL: https://automation.shellnetsecurity.com
Private path (for API/CLI access from tailnet devices):
Tailscale device → WireGuard mesh → k8s-subnet-router pod → n8n pod
URL: http://10.245.15.122:5678
The public path goes through Cloudflare’s network with their DDoS protection, WAF, and caching. The private path goes directly over WireGuard with end-to-end encryption. Both cost $0/month.
For most users accessing n8n through a browser, the Cloudflare Tunnel path is the right one. For automation scripts running on my home network or other K8s pods that need to call n8n’s API, the Tailscale path is faster and doesn’t leave the private network.
What Claude Code Did Here
Claude handled the Terraform for the K8s subnet router – the RBAC configuration, the security context capabilities, and the environment variables for the Tailscale container are the kind of boilerplate that’s easy to get wrong. It also caught the --accept-routes issue before I spent time debugging why the Pi couldn’t route to K8s, and it explained the per-device mesh model when I was confused about LAN routing.
The Tailscale deployment was one of the smoother parts of this whole project. The software is genuinely well-designed. Install it, authenticate it, approve the routes, done. No certificate management, no firewall rules, no port forwarding.
Next up in Part 6: pulling all the pieces together – the final architecture, the total savings, and an honest assessment of what Claude Code did well and where human judgment was still essential.
The moment I curled a Kubernetes ClusterIP from my couch and got a response was the moment this whole project clicked. A $4/month VPS running one pod, a free Tailscale mesh, and a Raspberry Pi. That’s the whole private network. No VPN server, no OpenVPN configs, no certificate rotation. Sometimes the right tool makes the problem disappear.