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.