The Goal

I have a DigitalOcean Kubernetes cluster running services like n8n (workflow automation), SearXNG (search), PostgreSQL, and Redis. I also have a Raspberry Pi 5 running OpenWrt as my home router. I wanted every device on my WiFi – laptops, phones, tablets – to be able to access my K8s services directly by ClusterIP, without installing Tailscale (or any VPN client) on each device.

The result: my wife can open http://10.245.15.122:5678 on her laptop and hit n8n. My iPad can reach SearXNG. Any device on the WiFi just… works. The OpenWrt router handles the encrypted tunnel transparently.

The Architecture

WiFi Device (192.168.1.x)
  → OpenWrt Router (192.168.1.1)
    → Tailscale tunnel (encrypted WireGuard)
      → K8s Subnet Router pod (10.245.0.0/16)
        → K8s Service (e.g., n8n at 10.245.15.122:5678)

Two Tailscale subnet routers create a site-to-site mesh:

  • OpenWrt advertises 192.168.1.0/24 (home WiFi LAN)
  • K8s pod advertises 10.245.0.0/16 (Kubernetes ClusterIP range)

Both accept each other’s routes. The magic is in the OpenWrt firewall config that lets WiFi clients route through the Tailscale tunnel without knowing it exists.

Cost: $0. Tailscale’s free tier supports subnet routing, and devices behind a subnet router don’t count toward the 100-device limit.

Prerequisites

  • An OpenWrt router (I’m using a Raspberry Pi 5 with OpenWrt 25.12.2)
  • A Kubernetes cluster (I’m using DigitalOcean, but any cluster works)
  • A Tailscale account (free tier is fine)
  • About 30 minutes

Part 1: Tailscale on OpenWrt

Installation

OpenWrt 25.x uses apk (not opkg):

apk add tailscale
(1/2) Installing kmod-tun (6.12.74-r1)
(2/2) Installing tailscale (1.94.1-r1)

Enable and start:

/etc/init.d/tailscale enable
/etc/init.d/tailscale start

Authentication

tailscale up --accept-dns=false --advertise-routes=192.168.1.0/24
  • --accept-dns=false: Don’t let Tailscale override your DNS config (you want dnsmasq to keep handling DNS)
  • --advertise-routes=192.168.1.0/24: Tell the tailnet about your WiFi LAN

This prints an authentication URL. Open it in a browser, log in to Tailscale, and authorize the device.

Then enable accepting routes from other subnet routers:

tailscale set --accept-routes=true

Approve the Route

Go to Tailscale Admin Console, click on the openwrt machine, and approve the 192.168.1.0/24 subnet route.

Part 2: Tailscale Subnet Router in Kubernetes

Deploy a Tailscale pod that advertises your K8s ClusterIP range. I use Terraform, but you can adapt this to plain YAML.

The Terraform Config

variable "tailscale_auth_key" {
  type      = string
  sensitive = true
}

resource "kubernetes_secret" "tailscale_auth" {
  metadata {
    name      = "tailscale-auth"
    namespace = "default"
  }
  data = {
    TS_AUTHKEY = var.tailscale_auth_key
  }
}

resource "kubernetes_service_account" "tailscale" {
  metadata {
    name      = "tailscale"
    namespace = "default"
  }
}

resource "kubernetes_role" "tailscale" {
  metadata {
    name      = "tailscale"
    namespace = "default"
  }
  rule {
    api_groups = [""]
    resources  = ["secrets"]
    verbs      = ["create", "get", "update", "patch"]
  }
}

resource "kubernetes_role_binding" "tailscale" {
  metadata {
    name      = "tailscale"
    namespace = "default"
  }
  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 = "default"
  }
}

resource "kubernetes_deployment" "tailscale" {
  metadata {
    name   = "tailscale-subnet-router"
    labels = { app = "tailscale" }
  }
  spec {
    replicas = 1
    selector {
      match_labels = { app = "tailscale" }
    }
    template {
      metadata {
        labels = { app = "tailscale" }
      }
      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_USERSPACE"; value = "true" }
          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_ACCEPT_DNS"; value = "false" }
          resources {
            requests = { memory = "64Mi", cpu = "50m" }
            limits   = { memory = "128Mi" }
          }
        }
      }
    }
  }
}

The key environment variable is TS_ROUTES = "10.245.0.0/16" – this tells Tailscale to advertise the entire Kubernetes ClusterIP range.

Generate an Auth Key

Go to Tailscale Admin > Settings > Keys and create a reusable auth key. Store it as a Kubernetes secret or (as I do) a GitHub Actions secret for Terraform.

Approve the K8s Route

After the pod starts, go to the Tailscale Admin Console, click on k8s-subnet-router, and approve the 10.245.0.0/16 subnet route.

Part 3: The OpenWrt Firewall Config (The Hard Part)

This is where it gets interesting. At this point:

  • The Pi can reach K8s via Tailscale (the route is in table 52)
  • But WiFi clients have no idea 10.245.x.x traffic should go to the Pi

We need to:

  1. Tell OpenWrt about the Tailscale interface
  2. Create a firewall zone that allows traffic to flow between WiFi and Tailscale
  3. Enable masquerading so return traffic finds its way back

Create the Tailscale Network Interface

uci set network.tailscale=interface
uci set network.tailscale.proto="none"
uci set network.tailscale.device="tailscale0"
uci commit network

Create the Firewall Zone

uci add firewall zone
uci set firewall.@zone[-1].name="tailscale"
uci set firewall.@zone[-1].input="ACCEPT"
uci set firewall.@zone[-1].output="ACCEPT"
uci set firewall.@zone[-1].forward="ACCEPT"
uci set firewall.@zone[-1].masq="1"
uci set firewall.@zone[-1].network="tailscale"

The masq="1" is critical – it enables NAT (masquerading) on the Tailscale interface. Without it, K8s services receive packets with a 192.168.1.x source IP, but they don’t know how to route replies back to your WiFi network.

Create Forwarding Rules

# WiFi LAN → Tailscale (outbound to K8s)
uci add firewall forwarding
uci set firewall.@forwarding[-1].src="lan"
uci set firewall.@forwarding[-1].dest="tailscale"

# Tailscale → WiFi LAN (return traffic)
uci add firewall forwarding
uci set firewall.@forwarding[-1].src="tailscale"
uci set firewall.@forwarding[-1].dest="lan"

uci commit firewall

Apply Everything

/etc/init.d/network restart
/etc/init.d/firewall restart

And here’s where things went sideways.

The Bugs We Hit

Bug 1: Firewall Restart Killed Tailscale Connectivity

After restarting the firewall, the Pi couldn’t ping the K8s subnet router anymore:

ping -c 2 100.108.47.63
# 2 packets transmitted, 0 packets received, 100% packet loss

Tailscale has its own nftables chains (ts-input, ts-forward, ts-postrouting) that manage tunnel traffic. When OpenWrt’s firewall restarts, it rebuilds the nftables ruleset and Tailscale’s rules get disrupted.

Fix: Restart Tailscale after the firewall restart.

/etc/init.d/tailscale restart

After the restart, connectivity came back:

ping -c 2 100.108.47.63
# 64 bytes from 100.108.47.63: seq=0 ttl=64 time=90.892 ms

Bug 2: “Operation not permitted” When Accessing K8s Services

Even though pings worked to the Tailscale IP, accessing K8s ClusterIPs failed:

wget -q -O /dev/null --spider http://10.245.15.122:5678/
# Failed to send request: Operation not permitted

The K8s subnet routes weren’t appearing in the kernel routing table. Running ip route showed nothing for 10.245.0.0/16. But Tailscale manages routes in a separate routing table (table 52):

ip route show table 52
10.245.0.0/16 dev tailscale0
100.97.117.88 dev tailscale0
100.100.100.100 dev tailscale0
100.108.47.63 dev tailscale0

The routes were there – they just weren’t in the main table. After restarting Tailscale (which fixed the nftables chains), the routing worked because Tailscale’s policy routing rules in ip rule list direct traffic through table 52.

Bug 3: The Route Disappeared After Network Restart

This is related to Bug 1. The sequence matters:

  1. Configure network and firewall via uci
  2. Restart network: /etc/init.d/network restart
  3. Restart firewall: /etc/init.d/firewall restart
  4. Restart Tailscale: /etc/init.d/tailscale restart ← Don’t forget this!

If you skip step 4, Tailscale’s nftables rules are stale and routing breaks. Always restart Tailscale last.

The Final Test

From a Mac on the WiFi network (192.168.1.182), with no Tailscale client installed:

# n8n workflow automation
curl -s -o /dev/null -w "HTTP %{http_code}" http://10.245.15.122:5678/
# HTTP 200

# SearXNG search engine
curl -s -o /dev/null -w "HTTP %{http_code}" http://10.245.67.150:8080/
# HTTP 200

# PostgreSQL
nc -z -w 3 10.245.20.103 5432
# Connection succeeded!

# Redis
nc -z -w 3 10.245.116.226 6379
# Connection succeeded!

Every K8s service, reachable from any device on the WiFi, through an encrypted WireGuard tunnel, with zero client configuration.

Is This Allowed by Tailscale’s Terms?

Yes. This is an officially supported use case. From Tailscale’s documentation:

“In cases where you can’t install Tailscale on every device on your physical network, you can set up a subnet router to access these devices from your tailnet.”

Subnet routing is available on the free tier. Devices behind a subnet router don’t count toward your device limit. Site-to-site networking is a documented, intended feature.

The Full Firewall Configuration

For reference, here’s the complete OpenWrt firewall state after configuration:

Zones:
  lan    - input:ACCEPT  output:ACCEPT  forward:ACCEPT  network:lan
  wan    - input:REJECT  output:ACCEPT  forward:DROP     network:wan,wan6  masq:1
  tailscale - input:ACCEPT  output:ACCEPT  forward:ACCEPT  network:tailscale  masq:1

Forwarding:
  lan → wan        (WiFi clients reach the internet)
  lan → tailscale  (WiFi clients reach K8s)
  tailscale → lan  (K8s return traffic reaches WiFi clients)

What You Get

Path How It Works
WiFi device → K8s service Traffic goes through OpenWrt’s Tailscale tunnel automatically
Phone on cellular → K8s service Install Tailscale on your phone, access via ClusterIP from anywhere
Laptop at coffee shop → K8s service Install Tailscale on the laptop, full access via ClusterIP

The subnet routing through OpenWrt handles the home WiFi case. Individual Tailscale installs handle the remote access case. Both paths coexist.

Cost

Component Monthly Cost
Tailscale (free tier, 3 users, 100 devices) $0
Tailscale pod in K8s (~64MB RAM) $0 (runs on existing nodes)
OpenWrt on Raspberry Pi 5 $0 (already your router)
Total $0

This was configured live in a terminal session with Claude Code. The bugs were real – especially the Tailscale restart ordering issue. If you’re reading this because your subnet routing isn’t working after a firewall change, restart Tailscale. Trust me.