Tailscale VPN Access Setup for myapp.example.com
Date: 2026-02-04 Project: Example App (myapp.example.com) Goal: Restrict app access to Tailscale VPN users only
Current Setup
- Domain: myapp.example.com (via Cloudflare)
- Infrastructure: GKE cluster with NGINX ingress
- Services: NextJS frontend, FastAPI backend, RQ Dashboard
- Current Access Control: IP whitelist (203.0.113.10/32)
What You’re Setting Up
After completion, your app will:
- Only be accessible when connected to Tailscale VPN
- Use your custom domain
myapp.example.com(or Tailscale MagicDNS) - Have automatic HTTPS (via Tailscale)
- Be completely blocked from public internet
Decision: Custom Domain vs MagicDNS
You have two options:
Option A: MagicDNS (Recommended - Start Here)
- Access URL:
https://myapp.your-tailnet.ts.net - Pros: Automatic HTTPS, simpler setup, works immediately
- Cons: URL is not your custom domain
Option B: Custom Domain
- Access URL:
https://myapp.example.com - Pros: Uses your existing domain
- Cons: More complex setup, requires split-horizon DNS
Recommendation: Start with Option A (MagicDNS), add custom domain later if needed.
Part 1: Install Tailscale Kubernetes Operator
Step 1: Create OAuth Client
-
Go to Tailscale OAuth Clients
-
Click “Generate OAuth client”
-
Configure:
- Description:
k8s-operator-my-app - Scopes: Select
devices:write(required) - Tags:
tag:k8s
- Description:
-
Copy and save:
- Client ID (starts with
oauth_client_...) - Client Secret (starts with
tskey-client-...)
- Client ID (starts with
Step 2: Install Operator via Helm
# Add Tailscale Helm repository
helm repo add tailscale https://pkgs.tailscale.com/helmcharts
helm repo update
# Install the operator
helm upgrade --install \
tailscale-operator \
tailscale/tailscale-operator \
--namespace=tailscale \
--create-namespace \
--set-string oauth.clientId="<YOUR_OAUTH_CLIENT_ID>" \
--set-string oauth.clientSecret="<YOUR_OAUTH_CLIENT_SECRET>" \
--wait
Step 3: Verify Installation
# Check operator pods
kubectl get pods -n tailscale
# Should see:
# NAME READY STATUS RESTARTS AGE
# tailscale-operator-xxxxxxxxxx-xxxxx 1/1 Running 0 30s
Part 2: Expose Your App to Tailscale
You’ll create a single NGINX proxy that routes to all your services (NextJS, FastAPI, RQ Dashboard).
Step 1: Create Tailscale Ingress Manifest
Create file: k8s/charts/my-app/templates/tailscale-ingress.yaml
{{- if .Values.tailscale.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: app-tailscale-ingress
namespace: {{ .Release.Namespace }}
annotations:
tailscale.com/expose: "true"
tailscale.com/hostname: "myapp" # Creates device named "myapp" in Tailscale
tailscale.com/tags: "tag:k8s"
spec:
type: LoadBalancer
loadBalancerClass: tailscale
ports:
- name: http
port: 80
targetPort: 80
selector:
app: app-nginx-proxy
---
# NGINX proxy to route to internal services
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-nginx-proxy
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
selector:
matchLabels:
app: app-nginx-proxy
template:
metadata:
labels:
app: app-nginx-proxy
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
volumes:
- name: nginx-config
configMap:
name: app-nginx-proxy-config
---
apiVersion: v1
kind: ConfigMap
metadata:
name: app-nginx-proxy-config
namespace: {{ .Release.Namespace }}
data:
nginx.conf: |
events {
worker_connections 1024;
}
http {
upstream nextjs {
server app-nextjs:3000;
}
upstream fastapi {
server app-fastapi:8000;
}
upstream rqdash {
server app-rq-dashboard:9181;
}
server {
listen 80;
server_name _;
# FastAPI endpoints
location /fastapi {
rewrite ^/fastapi/(.*)$ /$1 break;
proxy_pass http://fastapi;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# RQ Dashboard
location /rqdash {
proxy_pass http://rqdash;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# NextJS (default/catch-all)
location / {
proxy_pass http://nextjs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
{{- end }}
Step 2: Update values.yaml
Add to k8s/charts/my-app/values.yaml:
# Tailscale configuration
tailscale:
enabled: false # Set to true when ready to deploy
hostname: "myapp" # Device name in Tailscale
Step 3: Update values-orbit.yaml
Update path/to/values-orbit.yaml:
tailscale:
enabled: true
hostname: "myapp"
# Keep ingress disabled or with IP whitelist during transition
ingress:
enabled: true # Keep enabled during testing
host: "myapp.example.com"
annotations:
nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.10/32"
Step 4: Update Helm Chart Version
Edit k8s/charts/my-app/Chart.yaml:
version: 0.2.0 # Bump from current version
Step 5: Deploy
# If using local Helm
helm upgrade my-app ./k8s/charts/my-app \
-f path/to/values-orbit.yaml \
--namespace my-app
# Or commit and push (let CI/CD deploy)
git add .
git commit -m "feat: add Tailscale VPN access"
git push
Part 3: Verify and Test
Step 1: Check Tailscale Device
-
Go to Tailscale Machines
-
Look for device named “myapp”
- Should show as online
- Note the Tailscale IP (100.x.x.x)
- Note the MagicDNS name (e.g.,
myapp.your-tailnet.ts.net)
Step 2: Enable HTTPS (Optional)
-
Click on the “myapp” machine
-
Look for “HTTPS” or “Enable HTTPS” toggle
-
Enable it (automatic certificate provisioning)
Step 3: Test Access
From a device connected to Tailscale:
# Connect to Tailscale
tailscale up
# Test MagicDNS access
curl http://myapp.your-tailnet.ts.net
# If HTTPS enabled:
curl https://myapp.your-tailnet.ts.net
# Or open in browser
open https://myapp.your-tailnet.ts.net
Test that public access is blocked:
# Disconnect from Tailscale
tailscale down
# Try to access
curl https://myapp.example.com
# Should still work (IP whitelisted)
# But new users without IP whitelist should be blocked
Part 4: Custom Domain Setup (Optional)
If you want to use myapp.example.com instead of MagicDNS, use split-horizon DNS.
Step 1: Configure Tailscale DNS Override
Method 1: Via ACL (Recommended)
-
Add DNS configuration:
{
"dns": {
"extraRecords": [
{
"name": "myapp.example.com",
"type": "A",
"value": "100.x.x.x"
}
]
},
"acls": [
// your existing ACLs...
]
}
Replace 100.x.x.x with your Tailscale device IP from Part 3, Step 1.
Method 2: Via DNS UI (Alternative)
- Go to Tailscale DNS
- Look for “Extra records” or “Override local DNS”
- Add record:
myapp.example.com→100.x.x.x
Step 2: Configure Cloudflare DNS
Option A: Remove Public DNS Record
- Go to Cloudflare Dashboard
- Select domain
example.com - Go to DNS → Records
- Find the
myapprecord - Delete it
Result: myapp.example.com only resolves for Tailscale users.
Option B: Point to “VPN Required” Page
- Keep the
myappDNS record - Point it to a static “VPN Required” page
- Tailscale users will see the real app; others see the message
Step 3: Test Custom Domain
# Connect to Tailscale
tailscale up
# Clear DNS cache
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
# Test DNS resolution
nslookup myapp.example.com
# Should return 100.x.x.x
# Test access
curl http://myapp.example.com
# Should load your app
# Test in browser
open http://myapp.example.com
Part 5: Configure Access Control
Step 1: Set Up Tailscale ACLs
-
Configure who can access your app:
{
"acls": [
// Allow all team members
{
"action": "accept",
"src": ["autogroup:members"],
"dst": ["tag:k8s:*"]
},
// Or restrict to specific users
{
"action": "accept",
"src": ["[email protected]", "[email protected]"],
"dst": ["tag:k8s:*"]
},
// Or use groups
{
"action": "accept",
"src": ["group:developers"],
"dst": ["tag:k8s:*"]
}
],
"tagOwners": {
"tag:k8s": ["autogroup:admin"]
},
"groups": {
"group:developers": ["[email protected]", "[email protected]"]
}
}
Step 2: Invite Team Members
- Go to Tailscale Users
- Click “Invite user”
- Send invite link
- Users install Tailscale:
- Desktop: https://tailscale.com/download
- Mobile: App Store / Play Store
Part 6: Disable Public Access (Final Step)
Once Tailscale is working and tested:
Option 1: Disable Public Ingress
Update values-orbit.yaml:
ingress:
enabled: false # Completely disable public access
tailscale:
enabled: true
Option 2: Keep IP Whitelist as Backup
Update values-orbit.yaml:
ingress:
enabled: true
annotations:
nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.10/32"
tailscale:
enabled: true
Deploy Changes
helm upgrade my-app ./k8s/charts/my-app \
-f /path/to/values-orbit.yaml \
--namespace my-app
Troubleshooting
Issue: Can’t find device in Tailscale
Solution:
# Check Kubernetes service
kubectl get svc -n my-app app-tailscale-ingress
# Check operator logs
kubectl logs -n tailscale -l app=operator --tail=100
# Verify LoadBalancer is assigned
kubectl describe svc app-tailscale-ingress -n my-app
Issue: Custom domain doesn’t resolve
Solution:
# Clear DNS cache
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
# Verify MagicDNS is enabled
# Go to https://login.tailscale.com/admin/dns
# Test direct Tailscale IP
curl http://100.x.x.x
Issue: HTTPS certificate errors
Solutions:
- For MagicDNS: Enable HTTPS in Tailscale machine settings
- For custom domain: Use HTTP (already encrypted by WireGuard) or set up cert-manager
Quick Reference
Access URLs
MagicDNS (recommended):
- Main app:
https://myapp.your-tailnet.ts.net - FastAPI:
https://myapp.your-tailnet.ts.net/fastapi - RQ Dashboard:
https://myapp.your-tailnet.ts.net/rqdash
Custom domain (if configured):
- Main app:
http://myapp.example.com - FastAPI:
http://myapp.example.com/fastapi - RQ Dashboard:
http://myapp.example.com/rqdash
Important Links
- Tailscale Admin: https://login.tailscale.com/admin
- Machines: https://login.tailscale.com/admin/machines
- DNS Settings: https://login.tailscale.com/admin/dns
- Access Controls: https://login.tailscale.com/admin/acls
- OAuth Clients: https://login.tailscale.com/admin/settings/oauth
Useful Commands
# Check Tailscale status
tailscale status
# Connect to Tailscale
tailscale up
# Disconnect
tailscale down
# Ping a device
tailscale ping myapp
# Check operator pods
kubectl get pods -n tailscale
# Check service status
kubectl get svc -n my-app app-tailscale-ingress
# View operator logs
kubectl logs -n tailscale -l app=operator --tail=100
Next Steps
- Review this document
- Create OAuth client in Tailscale
- Install Tailscale operator
- Deploy Tailscale ingress configuration
- Test access with MagicDNS
- (Optional) Set up custom domain
- Configure access controls
- Invite team members
- Disable public ingress
Notes
- All traffic is encrypted by WireGuard (even HTTP)
Reference Documentation
- Tailscale K8s Operator: https://tailscale.com/kb/1236/kubernetes-operator
- Tailscale ACLs: https://tailscale.com/kb/1018/acls
- MagicDNS: https://tailscale.com/kb/1081/magicdns