Homelab
Tailscale Hardening
Hardening Tailscale: ACLs, Pinned Versions, and NetworkPolicies
Overview
This article hardens Tailscale by making admin-only access explicit. Currently any device on your tailnet can reach the Lab VLAN subnet. After this article, only admin-tagged devices can access the subnet route - if a device gets compromised or removed from your control, removing its tag revokes access. We also pin the Helm chart version and add NetworkPolicies to limit the operator's blast radius.
| Tip: | Having trouble? See v1.5.0 for what your setup should look like after completing this article. |
Before You Begin
Prerequisites
- Plex LAN Configuration completed
- Access to Tailscale admin console (admin.tailscale.com)
What We're Setting Up
| Component | Before | After |
|---|---|---|
| Helm chart | 1.x (floating) | Pinned version |
| ACLs | Default allow-all | Admin-only access |
| NetworkPolicy | None | Egress limited to control plane |
Why These Controls
Helm Pinning: Floating versions (1.x) can introduce breaking changes or vulnerabilities without notice. Pinning ensures reproducible deployments.
Tailscale ACLs: Control what devices can reach over the Tailscale tunnel. Without ACLs, any device can reach any destination. ACLs make admin-only access explicit - only tag:admin devices can reach the Lab VLAN through the subnet router.
NetworkPolicy: Control what pods can reach at the Kubernetes network layer. If the Tailscale operator pod is compromised (e.g., via a vulnerability or stolen service account token), NetworkPolicies prevent it from reaching other VLANs like Network (192.168.1.x) or Drive (192.168.5.x). This is separate from Tailscale ACLs - both layers work together for defense-in-depth.
How ACLs Restrict Access
After applying these ACLs, access is restricted as follows:
| Source | Destination | Allowed? | Why |
|---|---|---|---|
| Admin devices | Other admin devices (SSH to bastion) | ✓ | tag:admin → tag:admin |
| Admin devices | Lab VLAN (Plex, game servers, APIs) | ✓ | tag:admin → 192.168.10.0/24:* |
| Untagged devices | Lab VLAN | ✗ | No rule allows it |
Key points:
- Game servers via Tailscale: Admin devices can still access Minecraft, Factorio, and Plex via their LoadBalancer IPs - playit.gg is only needed for friends without Tailscale
- Revocable access: Removing
tag:adminfrom a device immediately revokes its Lab VLAN access
Two layers of defense:
| Layer | Protects Against | Scope |
|---|---|---|
| Tailscale ACLs | Compromised tailnet device using tunnel to reach other devices | All tailnet devices |
| NetworkPolicy | Compromised pod reaching other VLANs via cluster network | Pods in tailscale namespace |
Both are needed - ACLs protect at the tunnel level, NetworkPolicies protect at the pod level.
Pin Helm Chart Version
Check Current Version
First, find what version is currently deployed:
helm list -n tailscale Expected output:
NAME NAMESPACE REVISION ... CHART APP VERSION
tailscale-operator tailscale 5 ... tailscale-operator-1.94.2 v1.94.2 Note the chart version (e.g., 1.94.2) - this is what you'll pin.
Update HelmRelease
Pin to the specific version instead of floating 1.x:
k8s/core/tailscale/helmrelease.yaml:
# ... existing header ...
spec:
interval: 1h
chart:
spec:
chart: tailscale-operator
version: "1.94.2" # CHANGE from "1.x"
sourceRef:
kind: HelmRepository
name: tailscale
namespace: flux-system
# ... existing install, upgrade, values, valuesFrom ... Replace 1.94.2 with your current version from the helm list output.
Add NetworkPolicy
Limit the Tailscale operator's egress to only what it needs.
NetworkPolicy
k8s/core/tailscale/networkpolicy.yaml:
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tailscale-operator
namespace: tailscale
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# Tailscale control plane (DERP relays, coordination servers)
# Allow public internet, block all private ranges
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8 # Class A private
- 172.16.0.0/12 # Class B private
- 192.168.0.0/16 # Class C private (our VLANs)
ports:
- protocol: UDP
port: 41641 # Tailscale direct connections
- protocol: TCP
port: 443 # Tailscale coordination
# DNS resolution
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
# Kubernetes API (operator needs this to manage resources)
- to:
- ipBlock:
cidr: 192.168.10.30/32 # control-plane-0
- ipBlock:
cidr: 192.168.10.31/32 # control-plane-1
- ipBlock:
cidr: 192.168.10.32/32 # control-plane-2
ports:
- protocol: TCP
port: 6443
# Lab VLAN only (for subnet routing)
- to:
- ipBlock:
cidr: 192.168.10.0/24 Kustomization
Add the NetworkPolicy to resources:
k8s/core/tailscale/kustomization.yaml:
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- secret.sops.yaml
- helmrepository.yaml
- helmrelease.yaml
- connector.flux.yaml
- networkpolicy.yaml # ADD Deploy Changes
Commit Changes
git add k8s/core/tailscale/
git commit -m "feat(tailscale): harden with pinned version and NetworkPolicy"
git push Reconcile
flux reconcile source git flux-system
flux reconcile kustomization sync Verify Helm Version
kubectl get helmrelease -n tailscale tailscale-operator -o jsonpath='{.spec.chart.spec.version}' Expected: Your pinned version (e.g., 1.94.2).
Verify NetworkPolicy
kubectl get networkpolicy -n tailscale Expected: tailscale-operator policy listed.
Configure Tailscale ACLs
Update ACLs in the Tailscale admin console. ACLs are managed outside the cluster - even if someone compromises the cluster, they can't modify ACLs.
Update ACL Policy
In Tailscale Admin Console → Access Controls1, replace your existing policy with:
{
"tagOwners": {
"tag:admin": ["autogroup:admin"],
"tag:k8s-operator": ["autogroup:admin"],
"tag:k8s": ["tag:k8s-operator"]
},
"autoApprovers": {
"routes": {
"192.168.10.0/24": ["tag:k8s"]
}
},
"acls": [
{
"action": "accept",
"src": ["tag:admin"],
"dst": ["tag:admin:*"]
},
{
"action": "accept",
"src": ["tag:admin"],
"dst": ["192.168.10.0/24:*"]
}
],
"ssh": [
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}
]
} This removes the allow-all grants and adds restrictive acls. Only tag:admin devices can now reach the Lab VLAN.
What changed from v2:
| Section | Before (v2) | After (v3) |
|---|---|---|
tagOwners | tag:k8s-operator, tag:k8s | Added tag:admin |
autoApprovers | Individual 192.168.1.x routes | Single 192.168.10.0/24 (Lab VLAN) |
grants | {"src": ["*"], "dst": ["*"]} (allow-all) | Removed |
acls | (none) | Admin-only access rules |
ssh | (unchanged) | (unchanged) |
What the ACLs do:
- Admin → Admin - SSH between admin devices (laptop to bastion)
- Admin → Lab VLAN - Full access to Plex, game servers, cluster APIs (via subnet router when remote)
- Untagged devices - No access to Lab VLAN
The subnet router doesn't need its own ACL rule - it just forwards traffic for authorized admin devices. The ACL check happens on the source device, not the forwarder.
Tag Your Devices
| Important: | Tag your devices immediately after saving. Without tag:admin, you'll lose remote access. |
In Machines tab:
- Click the ⋯ menu on your device
- Select Edit ACL tags...
- Select
tag:adminfrom the dropdown - Save
Repeat for each admin workstation (laptop, bastion, etc.).
Verify ACLs
ACLs only affect traffic through the Tailscale tunnel. To test, disconnect from your local network (use mobile hotspot) or test from a remote location.
Admin Device Access
From an admin-tagged device over Tailscale (not on local network):
kubectl get nodes Expected: Nodes listed. This confirms the tunnel reaches the API server at 192.168.10.30:6443.
Without Tailscale
Disable Tailscale on the same device:
kubectl get nodes Expected: Connection times out (no route to cluster).
Next Steps
With Tailscale hardened, continue with MetalLB and ingress-nginx hardening.
See: MetalLB & Ingress Hardening
Resources
Footnotes
Tailscale, "Access Controls," tailscale.com. Accessed: Feb. 28, 2026. [Online]. Available: https://tailscale.com/kb/1018/acls ↩