Posted on:February 4, 2026 at 12:00 AM

Tailscale VPN Access Setup for myapp.example.com

Tailscale VPN Access Setup for myapp.example.com

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:

  • 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

  1. Go to Tailscale OAuth Clients

  2. Click “Generate OAuth client”

  3. Configure:

    • Description: k8s-operator-my-app
    • Scopes: Select devices:write (required)
    • Tags: tag:k8s
  4. Copy and save:

    • Client ID (starts with oauth_client_...)
    • Client Secret (starts with tskey-client-...)

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

  1. Go to Tailscale Machines

  2. 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)

  1. Click on the “myapp” machine

  2. Look for “HTTPS” or “Enable HTTPS” toggle

  3. 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)

  1. Go to Tailscale Access Controls

  2. 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)

  1. Go to Tailscale DNS
  2. Look for “Extra records” or “Override local DNS”
  3. Add record: myapp.example.com100.x.x.x

Step 2: Configure Cloudflare DNS

Option A: Remove Public DNS Record

  1. Go to Cloudflare Dashboard
  2. Select domain example.com
  3. Go to DNSRecords
  4. Find the myapp record
  5. Delete it

Result: myapp.example.com only resolves for Tailscale users.

Option B: Point to “VPN Required” Page

  1. Keep the myapp DNS record
  2. Point it to a static “VPN Required” page
  3. 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

  1. Go to Tailscale Access Controls

  2. 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

  1. Go to Tailscale Users
  2. Click “Invite user”
  3. Send invite link
  4. Users install Tailscale:

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

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

Related Posts