Skip to main content

Overview

Tailscale provides two complementary features for exposing services: Serve and Funnel. Understanding the difference is crucial for properly securing your self-hosted applications.

Tailscale Serve

Private access within your Tailscale network (Tailnet). Only authenticated devices can connect.

Tailscale Funnel

Public internet access. Anyone with the URL can connect, even without Tailscale.

Tailscale Serve

Tailscale Serve lets you route traffic from devices on your Tailscale network to local services. Think of it as private sharing - only members of your Tailnet can access the service.

How Serve Works

┌──────────────┐       Tailnet VPN        ┌──────────────┐
│   Your       │◄─────────────────────────►│  Tailscale   │
│   Laptop     │     Encrypted Tunnel      │  Container   │
│              │                            │              │
│ Authenticated│                            │  Proxies to  │
│ Tailscale    │                            │  127.0.0.1   │
│ Device       │                            │              │
└──────────────┘                            └──────┬───────┘

                                            ┌──────▼───────┐
                                            │ Application  │
                                            │ Container    │
                                            └──────────────┘

✓ Private: Only your Tailnet devices
✓ Encrypted: All traffic through VPN
✓ Authenticated: Requires Tailscale login

Serve Configuration Example

This is the default configuration for all ScaleTail services:
compose.yaml
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"${TS_CERT_DOMAIN}:443":
          {"Handlers":{"/":
          {"Proxy":"http://127.0.0.1:8096"}}}},
      "AllowFunnel":{"${TS_CERT_DOMAIN}:443":false}}  # ← false = Serve only
The key setting is "AllowFunnel": {"${TS_CERT_DOMAIN}:443": false}. This explicitly disables public internet access.

Serve Configuration Breakdown

"TCP": {
  "443": {"HTTPS": true}
}
Purpose: Enable HTTPS on port 443
  • Tailscale automatically provisions TLS certificates
  • Uses your machine’s Tailscale hostname
  • Certificates are valid only for your Tailnet

When to Use Serve

Use Tailscale Serve for:

Personal Media Servers

Jellyfin, Plex, Navidrome - keep your media library private to family/friends

Admin Interfaces

Portainer, Dozzle, Homarr - administrative dashboards should never be public

Internal Tools

Development tools, internal wikis, documentation - for team/personal use only

Sensitive Services

Vaultwarden, Home Assistant, file managers - anything with personal data

Tailscale Funnel

Tailscale Funnel lets you route traffic from the public internet to local services. Anyone with the URL can access your service, even without Tailscale installed.

How Funnel Works

┌──────────────┐    Public Internet      ┌──────────────┐
│   Random     │────────────────────────►│  Tailscale   │
│   Internet   │   HTTPS Request         │  Proxy       │
│   User       │                          │  (Public)    │
│              │                          │              │
│ No Tailscale │                          │  Forwards    │
│ Required     │                          │  via Tailnet │
└──────────────┘                          └──────┬───────┘
                                                 │ Encrypted
                                                 │ Tailnet
                                          ┌──────▼───────┐
                                          │  Tailscale   │
                                          │  Container   │
                                          │              │
                                          │  Proxies to  │
                                          │  127.0.0.1   │
                                          └──────┬───────┘

                                          ┌──────▼───────┐
                                          │ Application  │
                                          │ Container    │
                                          └──────────────┘

⚠ Public: Anyone can access
✓ Encrypted: HTTPS enforced
⚠ No auth: No Tailscale login required

Funnel Configuration Example

To enable public internet access, set AllowFunnel to true:
compose.yaml
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"${TS_CERT_DOMAIN}:443":
          {"Handlers":{"/":
          {"Proxy":"http://127.0.0.1:3000"}}}},
      "AllowFunnel":{"${TS_CERT_DOMAIN}:443":true}}  # ← true = Public access
Enabling Funnel exposes your service to the entire internet. Ensure your application has its own authentication and security measures.

When to Use Funnel

Use Tailscale Funnel for:

Public Websites

Blogs, portfolios, landing pages - content meant for public consumption

Public APIs

Webhooks, API endpoints - services that need to receive external requests

Demo Applications

Sharing prototypes or demos with clients who don’t have Tailscale

Public Services

URL shorteners, paste bins - utilities meant for public use

Key Differences

FeatureServeFunnel
Who can accessOnly Tailnet membersAnyone on the internet
AuthenticationTailscale login requiredNo authentication
NetworkPrivate VPN onlyPublic internet + VPN
Device requirementMust have Tailscale installedNo Tailscale needed

Migration Between Serve and Funnel

Switching between Serve and Funnel is simple - just modify the AllowFunnel setting.

Making a Service Public (Serve → Funnel)

1

Edit serve.json Configuration

Modify your compose.yaml to enable Funnel:
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"${TS_CERT_DOMAIN}:443":
          {"Handlers":{"/":
          {"Proxy":"http://127.0.0.1:3000"}}}},
      "AllowFunnel":{"${TS_CERT_DOMAIN}:443":true}}  # Changed to true
2

Enable Funnel in Tailscale Admin

Funnel must be enabled for your Tailnet:
  1. Go to Tailscale Admin Console
  2. Navigate to Settings > General
  3. Enable Funnel
3

Restart Services

Apply the configuration changes:
docker compose down
docker compose up -d
4

Verify Public Access

Test access from a device without Tailscale:
curl https://your-service.your-tailnet.ts.net

Making a Service Private (Funnel → Serve)

1

Edit serve.json Configuration

Modify your compose.yaml to disable Funnel:
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"${TS_CERT_DOMAIN}:443":
          {"Handlers":{"/":
          {"Proxy":"http://127.0.0.1:8096"}}}},
      "AllowFunnel":{"${TS_CERT_DOMAIN}:443":false}}  # Changed to false
2

Restart Services

Apply the configuration:
docker compose down
docker compose up -d
3

Verify Private Access

Confirm public access is blocked:
# From non-Tailscale device - should fail
curl https://your-service.your-tailnet.ts.net

# From Tailscale device - should work
curl https://your-service.your-tailnet.ts.net

Advanced Configurations

Path-Based Routing

Expose different paths with different access levels:
{
  "TCP": {"443": {"HTTPS": true}},
  "Web": {
    "app.tailnet.ts.net:443": {
      "Handlers": {
        "/": {
          "Proxy": "http://127.0.0.1:3000"  # Main app - private
        },
        "/api/public": {
          "Proxy": "http://127.0.0.1:3000"  # API - could be public
        }
      }
    }
  },
  "AllowFunnel": {
    "app.tailnet.ts.net:443": false  # Whole service private
  }
}
Path-based access control is handled at the application level. Funnel is an all-or-nothing setting per domain.

Multiple Services with Different Access

Run multiple services with different Serve/Funnel configurations:
services:
  # Private admin dashboard
  tailscale-admin:
    environment:
      - TS_SERVE_CONFIG=/config/serve-private.json
    configs:
      - source: ts-serve-private
        target: /config/serve-private.json
  
  # Public blog
  tailscale-blog:
    environment:
      - TS_SERVE_CONFIG=/config/serve-public.json
    configs:
      - source: ts-serve-public
        target: /config/serve-public.json

configs:
  ts-serve-private:
    content: |
      {"AllowFunnel":{"admin.tailnet.ts.net:443":false}}
  
  ts-serve-public:
    content: |
      {"AllowFunnel":{"blog.tailnet.ts.net:443":true}}

Security Considerations

Strengths:
  • ✅ Automatic authentication via Tailscale
  • ✅ End-to-end encryption
  • ✅ ACL support for fine-grained access
  • ✅ No exposure to internet threats
Considerations:
  • ⚠️ All Tailnet members can access unless ACLs restrict
  • ⚠️ Compromised Tailscale credentials = compromised access
  • ⚠️ Application-level auth still recommended for sensitive data
Strengths:
  • ✅ HTTPS enforced automatically
  • ✅ Tailscale provides DDoS mitigation
  • ✅ Valid TLS certificates included
Risks:
  • ⚠️ No authentication - anyone with the URL can access
  • ⚠️ Attack surface - exposed to all internet threats
  • ⚠️ Application vulnerabilities - your app must handle all security
  • ⚠️ Rate limiting - must be handled at application level
Required Mitigations:
  • Implement application-level authentication
  • Add rate limiting and DDoS protection
  • Regular security updates
  • Monitor logs for suspicious activity
  • Use Web Application Firewall (WAF) if possible
For maximum security with public access:
  1. Use Funnel for public accessibility
  2. Add authentication at the application level (OAuth, password, etc.)
  3. Implement rate limiting to prevent abuse
  4. Monitor access logs for suspicious activity
  5. Keep software updated to patch vulnerabilities
Example: A public blog with admin panel
{
  "AllowFunnel": {"blog.tailnet.ts.net:443": true},
  "Web": {
    "blog.tailnet.ts.net:443": {
      "Handlers": {
        "/": {"Proxy": "http://127.0.0.1:2368"},  # Public
        "/ghost": {"Proxy": "http://127.0.0.1:2368"}  # Requires app login
      }
    }
  }
}

Common Scenarios

Scenario 1: Family Media Server

Goal: Share Jellyfin with family members only Solution: Use Serve
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"jellyfin.family-tailnet.ts.net:443":
          {"Handlers":{"/":{"Proxy":"http://127.0.0.1:8096"}}}},
      "AllowFunnel":{"jellyfin.family-tailnet.ts.net:443":false}}
Why:
  • Family members install Tailscale
  • Automatic authentication
  • No exposure to internet
  • Safe for personal media

Scenario 2: Public Portfolio Website

Goal: Share your portfolio with potential employers Solution: Use Funnel
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"portfolio.your-tailnet.ts.net:443":
          {"Handlers":{"/":{"Proxy":"http://127.0.0.1:3000"}}}},
      "AllowFunnel":{"portfolio.your-tailnet.ts.net:443":true}}
Why:
  • No Tailscale installation required for viewers
  • Public accessibility
  • Professional presence
  • Static content = low security risk

Scenario 3: Development Team Tool

Goal: Internal wiki for 5-person team Solution: Use Serve with ACLs
configs:
  ts-serve:
    content: |
      {"TCP":{"443":{"HTTPS":true}},
      "Web":{"wiki.company-tailnet.ts.net:443":
          {"Handlers":{"/":{"Proxy":"http://127.0.0.1:3000"}}}},
      "AllowFunnel":{"wiki.company-tailnet.ts.net:443":false}}
Tailscale ACL:
{
  "acls": [
    {
      "action": "accept",
      "src": ["group:engineering"],
      "dst": ["tag:wiki:*"]
    }
  ]
}
Why:
  • Team already uses Tailscale
  • Fine-grained access control via ACLs
  • Secure by default
  • Easy onboarding/offboarding

Troubleshooting

Symptoms: Public access still blocked after setting AllowFunnel: trueSolutions:
  1. Verify Funnel is enabled in Tailscale Admin
  2. Check device has Funnel permissions in ACLs
  3. Restart Tailscale container: docker compose restart tailscale
  4. Check Tailscale logs: docker compose logs tailscale
  5. Test with: tailscale serve status (exec into container)
Symptoms: Service accessible without Tailscale despite AllowFunnel: falseSolutions:
  1. Verify no port forwarding in router to the service
  2. Check no ports: directive exposing service publicly
  3. Confirm AllowFunnel is actually false in config
  4. Review firewall rules on host machine
Symptoms: Service works with Serve but Funnel shows errorsSolutions:
  1. Verify Funnel is enabled for your Tailnet
  2. Check application listens on 127.0.0.1 or 0.0.0.0 (not just localhost)
  3. Review application logs for errors
  4. Test internal access: curl http://127.0.0.1:8096 from Tailscale container

Best Practices

Default to Serve

Always start with Serve (private). Only enable Funnel when public access is explicitly required.

Defense in Depth

Even with Serve, implement application-level authentication for sensitive services.

Monitor Access

For Funnel services, monitor access logs and set up alerts for suspicious activity.

Use ACLs

With Serve, use Tailscale ACLs to restrict which Tailnet members can access specific services.

Document Decisions

Comment in your compose.yaml why each service uses Serve or Funnel.

Regular Audits

Periodically review which services are public and confirm they should remain so.

Next Steps

Environment Variables

Complete reference for configuring Tailscale behavior

Sidecar Pattern

Deep dive into the Docker sidecar architecture

Tailscale Setup

Configure authentication and manage your Tailnet

Deploy Services

Start deploying with Serve or Funnel

Build docs developers (and LLMs) love