Tailscale Sidecar Deployment
Philosophy: Private by Default
Traditional deployments expose services publicly, then add authentication. Tailscale inverts this: your application is private by default, accessible only from your tailnet. No public ports, no attack surface, no authentication to build.
Before deploying, ask:
- •Who needs access? (Just you, your team, specific devices?)
- •What's the hostname? (This becomes the access URL)
- •Is persistence needed? (Volume mounts for data)
- •What environment variables are required?
Core Principles:
- •
No Public Ports: The application binds to internal ports only. Tailscale is the only entry point.
- •
Device-Level Auth: Authentication happens at the Tailscale layer. If you're on the tailnet, you're authorized to access.
- •
Encrypted by Default: All traffic between devices uses WireGuard encryption.
- •
Minimal Configuration: One auth key, one docker-compose file. No nginx, no certbot, no firewall rules.
Architecture
┌──────────────────────────────────────────────────┐
│ Docker Host │
│ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Tailscale │ │ F# Application │ │
│ │ Container │───▶│ Container │ │
│ │ │ │ │ │
│ │ TS_HOSTNAME: │ │ Port: 5000 │ │
│ │ my-app │ │ (internal only) │ │
│ └────────┬────────┘ └─────────────────────┘ │
│ │ │
└───────────┼───────────────────────────────────────┘
│
┌───────▼───────┐
│ Tailnet │
│ (WireGuard) │
└───────┬───────┘
│
┌───────▼───────┐
│ Your Devices │
│ (laptop, │
│ phone, etc) │
└───────────────┘
Access: http://my-app or http://my-app:5000
The pattern: F# app runs in one container, Tailscale in another. Tailscale creates a tunnel; you access the app through that tunnel.
Docker Compose Setup
Complete docker-compose.yml
version: '3.8'
services:
# F# Application
app:
build: .
container_name: my-fsharp-app
restart: unless-stopped
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:5000
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
networks:
- app-network
depends_on:
- tailscale
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
# Tailscale Sidecar
tailscale:
image: tailscale/tailscale:latest
container_name: my-fsharp-app-tailscale
hostname: my-fsharp-app
restart: unless-stopped
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_STATE_DIR=/var/lib/tailscale
- TS_HOSTNAME=my-fsharp-app
- TS_ACCEPT_DNS=true
- TS_USERSPACE=false
volumes:
- tailscale-data:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- SYS_MODULE
networks:
- app-network
healthcheck:
test: ["CMD", "tailscale", "status"]
interval: 30s
timeout: 5s
retries: 3
networks:
app-network:
driver: bridge
volumes:
tailscale-data:
Understanding the Configuration
App Container:
- •
ASPNETCORE_URLS=http://+:5000— Listen on all interfaces, port 5000 - •
DATA_DIR— Environment-based data directory - •
depends_on: tailscale— Start after Tailscale is ready - •Health check verifies app is responding
Tailscale Container:
- •
TS_AUTHKEY— One-time key to join your tailnet - •
TS_HOSTNAME— The name you'll use to access the app - •
/dev/net/tun— Required for VPN tunnel - •
cap_add— Linux capabilities for networking
Setup Steps
1. Generate Tailscale Auth Key
- •Go to https://login.tailscale.com/admin/settings/keys
- •Click "Generate auth key"
- •Configure:
- •Reusable: Yes (for multiple deploys)
- •Ephemeral: Yes (device removed when container stops)
- •Expiration: 90 days (or your preference)
- •Copy the key (starts with
tskey-auth-)
2. Create Environment File
# .env (in project root) TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxx
Important: Add .env to .gitignore
3. Deploy
# Build and start docker compose up -d # Check status docker compose ps docker logs my-fsharp-app-tailscale docker logs my-fsharp-app # Verify Tailscale connection docker exec my-fsharp-app-tailscale tailscale status
4. Access Your Application
From any device on your tailnet:
http://my-fsharp-app http://my-fsharp-app:5000
Install Tailscale on your devices: https://tailscale.com/download
Portainer Deployment
Option 1: Using Portainer Stacks UI
- •Log in to Portainer (e.g.,
http://your-server:9000) - •Navigate to Stacks → Add stack
- •Name:
my-fsharp-app - •Build method: Web editor
- •Paste docker-compose.yml content
- •Under Environment variables:
- •Add
TS_AUTHKEY=tskey-auth-xxxxx
- •Add
- •Click Deploy the stack
Option 2: Using Git Repository
- •Push docker-compose.yml to your repo
- •In Portainer: Stacks → Add stack
- •Build method: Repository
- •Enter Git URL and branch
- •Add environment variables
- •Optional: Enable Automatic updates
- •Deploy
Troubleshooting
Tailscale Not Connecting
# Check logs docker logs my-fsharp-app-tailscale # Common issues: # "no tun device" → Ensure /dev/net/tun is mounted # "auth key invalid" → Generate new key # "permission denied" → Check NET_ADMIN capability
Fix: No TUN device
# On host, enable TUN support sudo modprobe tun
Application Not Accessible
# Check app is running docker logs my-fsharp-app # Check health docker exec my-fsharp-app curl -f http://localhost:5000/api/health # Check network connectivity docker exec my-fsharp-app-tailscale ping my-fsharp-app
Auth Key Issues
# Key expired? Generate new one at: # https://login.tailscale.com/admin/settings/keys # Update .env and restart: docker compose down docker compose up -d
Common Variations
Custom Hostname
tailscale:
environment:
- TS_HOSTNAME=todo-app # Access via http://todo-app
Multiple Applications
Deploy multiple apps, each with unique hostname:
project-a/ docker-compose.yml # TS_HOSTNAME=project-a project-b/ docker-compose.yml # TS_HOSTNAME=project-b
Access:
- •
http://project-a - •
http://project-b
With Tailscale Serve (HTTPS)
For HTTPS without certificates:
tailscale:
environment:
- TS_SERVE_CONFIG=/config/serve.json
volumes:
- ./serve.json:/config/serve.json
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"my-fsharp-app.your-tailnet.ts.net:443": {
"Handlers": {
"/": {
"Proxy": "http://app:5000"
}
}
}
}
}
Anti-Patterns to Avoid
❌ Exposing Public Ports
# BAD: Defeats Tailscale's purpose ports: - "5000:5000"
Why bad: Makes app publicly accessible, bypasses Tailscale. Better: Remove port mappings. Access only via Tailscale.
❌ Committing Auth Keys
# BAD: Secret in repo environment: - TS_AUTHKEY=tskey-auth-xxxxx
Why bad: Auth keys in version control.
Better: Use .env file or secrets management.
❌ Skipping Health Checks
# BAD: No health checks
services:
app:
build: .
# No healthcheck
Why bad: Can't detect failures. Better: Add health checks for monitoring.
❌ Missing Volume Mounts for Data
# BAD: Data lost on container restart
services:
app:
build: .
# No volumes for data
Why bad: SQLite data disappears.
Better: Mount ./data:/app/data.
Variation Guidance
Development: Skip Tailscale, use docker compose up with port mapping.
Production single-user: Single Tailscale auth key, ephemeral device.
Team deployment: Reusable key, Tailscale ACLs for access control.
Multi-app server: Separate compose files, unique hostnames.
Match the deployment complexity to your needs.
Verification Checklist
Before marking deployment complete:
- • docker-compose.yml created
- • .env file with TS_AUTHKEY
- • .env in .gitignore
- • Tailscale auth key generated
- • Containers start successfully
- • Tailscale shows connected (
tailscale status) - • App accessible via Tailscale hostname
- • Data persists across restarts
- • Health checks passing
- • Logs show no errors
Remember
Tailscale removes an entire category of work: no public IPs, no DNS configuration, no TLS certificates, no authentication system. Your app is simply... private. Accessible to you and your devices, invisible to everyone else.
The goal: Deploy once, access from anywhere on your tailnet, worry about nothing else.
Related Documentation
- •
/docs/07-BUILD-DEPLOY.md- Docker build guide - •
/docs/08-TAILSCALE-INTEGRATION.md- Detailed Tailscale setup - •Tailscale Documentation