Tailscale in Docker with tsdproxy
Tailscale in Docker with tsdproxy
tsdproxy is one of my favourite tools for sharing container services to tailnets. Instead of adding Tailscale to each container, you run one tsdproxy instance that monitors Docker labels and creates Tailscale nodes for your services.
Setup
Get a Tailscale auth key from login.tailscale.com/admin/settings/keys. Enable “Reusable” if you’ll be restarting containers.
Create a configuration directory and add your auth key to the tsdproxy configuration file:
mkdir -p ./config
cat > ./config/tsdproxy.yaml <<EOF
tailscale:
default:
authKey: tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxx
dataDir: /data
EOF
Add the config directory to your .gitignore:
echo "config/" >> .gitignore
Basic Example
Create a docker-compose.yaml:
services:
tsdproxy:
image: almeidapaulopt/tsdproxy:1
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/config
- tsdproxy-data:/data
ports:
- "8080:8080"
nginx:
image: nginx:alpine
labels:
tsdproxy.enable: "true"
tsdproxy.name: nginx
volumes:
tsdproxy-data:
Start it:
docker compose up -d
Access from any device on your Tailscale network at http://my-nginx or https://my-nginx.your-tailnet.ts.net.
You can also view all your services in the tsdproxy dashboard by adding labels to the tsdproxy service itself:
tsdproxy:
image: almeidapaulopt/tsdproxy:1
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/config
- tsdproxy-data:/data
ports:
- "8080:8080"
labels:
tsdproxy.enable: "true"
tsdproxy.name: dash
Then access the dashboard at http://dash or https://dash.your-tailnet.ts.net.
Multi-Service Setup
Here’s a more complete example with an API and database:
services:
tsdproxy:
image: almeidapaulopt/tsdproxy:1
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/config
- tsdproxy-data:/data
ports:
- "8080:8080"
labels:
tsdproxy.enable: "true"
tsdproxy.name: dash
api:
image: myorg/api:latest
environment:
- DATABASE_URL=postgresql://myapp:${POSTGRES_PASSWORD}@postgres:5432/myappdb
labels:
tsdproxy.enable: "true"
tsdproxy.name: myapp-api
tsdproxy.container_port: "3000"
networks:
- backend
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_USER=myapp
- POSTGRES_DB=myappdb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- backend
networks:
backend:
volumes:
tsdproxy-data:
postgres-data:
The database isn’t exposed through Tailscale - only the API is accessible via http://myapp-api or https://myapp-api.your-tailnet.ts.net.
File-Based Configuration
For services running outside Docker or on different servers, use file-based proxy lists.
Add to /config/tsdproxy.yaml:
tailscale:
default:
authKey: tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxx
dataDir: /data
files:
services:
filename: /config/proxies.yaml
Create /config/proxies.yaml:
homeassistant:
url: http://192.168.1.100:8123
adguard:
url: http://192.168.1.10/admin
The proxy list file hot-reloads automatically - you only need to restart when changing the main tsdproxy.yaml file.
Tailscale Funnel (Public Access)
Expose a service to the public internet using Tailscale Funnel:
website:
image: nginx:alpine
labels:
tsdproxy.enable: "true"
tsdproxy.name: public-site
tsdproxy.funnel: "true"
Or in a proxy list file:
public-api:
url: http://api:8080
tailscale:
funnel: true
Your service will be accessible at https://public-site.your-tailnet.ts.net from anywhere on the internet.
ACL-Based Access Control
Configure access control in the Tailscale admin console at tailscale.com/admin/acls:
{
"tagOwners": {
"tag:prod": ["user@example.com"],
"tag:dev": ["user@example.com"]
},
"acls": [
{
"action": "accept",
"src": ["user@example.com"],
"dst": ["tag:prod:*"]
},
{
"action": "accept",
"src": ["group:developers"],
"dst": ["tag:dev:*"]
}
]
}
Apply tags using the auth key when generating it in the Tailscale admin console, or use separate auth keys with different tags for different services.
Security Best Practices
Never commit your config directory:
echo "config/" >> .gitignore
Use environment variables for sensitive data:
tailscale:
default:
authKey: ${TS_AUTHKEY}
dataDir: /data
Then use a .env file:
cat > .env <<EOF
TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxx
EOF
chmod 600 .env
echo ".env" >> .gitignore
For CI/CD, use ephemeral keys:
tailscale admin create-key --ephemeral --reusable
Use file-based auth keys for better security:
tailscale:
default:
authKeyFile: /run/secrets/ts_authkey
dataDir: /data
With Docker secrets:
services:
tsdproxy:
image: almeidapaulopt/tsdproxy:1
secrets:
- ts_authkey
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/config
- tsdproxy-data:/data
secrets:
ts_authkey:
file: ./ts_authkey.txt
Troubleshooting
Service not appearing on Tailscale:
Check the tsdproxy logs:
docker compose logs tsdproxy
Verify the service has the correct labels:
docker inspect <container-name> | grep -A 10 Labels
Can’t connect to service:
Test from within the tsdproxy container:
docker compose exec tsdproxy wget -O- http://nginx:80
Check Tailscale status:
docker compose exec tsdproxy tailscale status
Configuration not updating:
Proxy list files hot-reload automatically. For main config changes, restart:
docker compose restart tsdproxy
Dashboard not accessible:
Make sure tsdproxy itself has the labels:
labels:
tsdproxy.enable: "true"
tsdproxy.name: dash
Production Example
A complete production setup with monitoring, API, and database:
.env file:
TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxx
POSTGRES_PASSWORD=your_secure_production_password
config/tsdproxy.yaml:
tailscale:
default:
authKey: ${TS_AUTHKEY}
dataDir: /data
log:
level: info
json: true
http:
hostname: 0.0.0.0
port: 8080
docker-compose.yaml:
services:
tsdproxy:
image: almeidapaulopt/tsdproxy:1
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/config
- tsdproxy-data:/data
ports:
- "8080:8080"
labels:
tsdproxy.enable: "true"
tsdproxy.name: prod-dashboard
app:
image: myorg/app:latest
environment:
- DATABASE_URL=postgresql://produser:${POSTGRES_PASSWORD}@postgres:5432/proddb
labels:
tsdproxy.enable: "true"
tsdproxy.name: myapp-prod
tsdproxy.container_port: "8080"
tsdproxy.dash.label: "Production API"
tsdproxy.dash.icon: "si/fastapi"
networks:
- backend
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_USER=produser
- POSTGRES_DB=proddb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- backend
networks:
backend:
volumes:
tsdproxy-data:
postgres-data:
Deploy:
chmod 600 .env
docker compose up -d
Access:
- Dashboard:
https://prod-dashboard.your-tailnet.ts.net - API:
https://myapp-prod.your-tailnet.ts.net
The database remains isolated and is not accessible via Tailscale.