| name | tailscale-deploy |
| description | Deploy F# full-stack applications with Tailscale sidecar for private network access without public ports or authentication.
Use when deploying to production, setting up Docker compose with Tailscale, or need private networking setup.
Creates docker-compose.yml with app + Tailscale sidecar pattern for secure private access.
Includes Portainer deployment, troubleshooting, and configuration for F# Giraffe + Elmish applications.
|
| allowed-tools | Read, Edit, Write, Grep, Bash |
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:
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:
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
TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxx
Important: Add .env to .gitignore
3. Deploy
docker compose up -d
docker compose ps
docker logs my-fsharp-app-tailscale
docker logs my-fsharp-app
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
- 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
docker logs my-fsharp-app-tailscale
Fix: No TUN device
sudo modprobe tun
Application Not Accessible
docker logs my-fsharp-app
docker exec my-fsharp-app curl -f http://localhost:5000/api/health
docker exec my-fsharp-app-tailscale ping my-fsharp-app
Auth Key Issues
docker compose down
docker compose up -d
Common Variations
Custom Hostname
tailscale:
environment:
- TS_HOSTNAME=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
ports:
- "5000:5000"
Why bad: Makes app publicly accessible, bypasses Tailscale.
Better: Remove port mappings. Access only via Tailscale.
❌ Committing Auth Keys
environment:
- TS_AUTHKEY=tskey-auth-xxxxx
Why bad: Auth keys in version control.
Better: Use .env file or secrets management.
❌ Skipping Health Checks
services:
app:
build: .
Why bad: Can't detect failures.
Better: Add health checks for monitoring.
❌ Missing Volume Mounts for Data
services:
app:
build: .
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:
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