A controller that watches Consul for services tagged with tailscale.enable=true and automatically exposes them as Tailscale VIP Services. Tag your services the same way you would for Traefik — one Tailscale node serves them all.
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Consul Catalog │ │ Controller │ │ Tailscale API │
│ │◄──────│ │──────►│ (control plane)│
│ service: mealie│ block │ 1. Discover │ PUT │ │
│ tags: │ query │ services │ /vip- │ Creates svc: │
│ - tailscale. │ │ 2. Ensure VIP │ svcs │ mealie with │
│ enable=true │ │ definitions │ │ auto-assigned │
│ │ │ 3. Advertise │ │ VIP address │
│ │ │ services │ │ │
│ │ │ 4. Apply serve │ │ │
│ │ │ config │ │ │
└────────────────┘ └───────┬────────┘ └────────────────┘
│
│ PATCH /localapi/v0/prefs
│ → AdvertiseServices
│ POST /localapi/v0/serve-config
│ → HTTPS/TCP handler rules
▼
┌────────────────┐
│ Tailscale node │
│ (sidecar task) │
│ │
│ Registers as a │
│ service host, │
│ terminates TLS │
│ & proxies to │
│ backends │
└────────────────┘
- Discover — watches the Consul catalog (via blocking queries + poll fallback) for services tagged with
tailscale.enable=true - Ensure VIP Services — auto-creates Tailscale VIP Service definitions via the control plane API (requires OAuth credentials)
- Advertise — tells the local Tailscale node to register as a host for each managed service via
PATCH /localapi/v0/prefs - Apply serve config — posts HTTPS (TLS termination + HTTP proxy) or TCP forwarding rules to the local Tailscale daemon, mapping each service's port to its Consul backend address
One Tailscale node serves all your tagged services — no sidecar-per-service required.
Add these tags to any Consul-registered service (including Nomad service blocks which default to Consul):
service {
name = "mealie"
port = "http"
tags = [
"tailscale.enable=true",
]
}That's it. The controller will auto-create a Tailscale Service called svc:mealie, terminate TLS with Tailscale's auto-provisioned certificate, and proxy HTTPS traffic to the HTTP backend.
| Tag | Default | Description |
|---|---|---|
tailscale.enable=true |
(required) | Opt-in to Tailscale exposure |
tailscale.hostname=X |
Consul service name | Override the Tailscale service name (svc:X) |
tailscale.proto=X |
https |
Protocol mode: https (TLS termination + HTTP proxy) or tcp (raw TCP forwarding) |
tailscale.port=X |
443 (https) / service port (tcp) |
Override the frontend port |
tailscale.backend=host:port |
Consul instance address:port | Override the backend target |
tailscale.tag=tag:X |
TS_DEFAULT_TAG |
Override the Tailscale ACL tag for this service |
service {
name = "mealie"
port = "http"
tags = [
"tailscale.enable=true",
"tailscale.hostname=recipes", # exposed as svc:recipes
"tailscale.tag=tag:web", # use tag:web instead of the default tag:server
]
}Works alongside other tag-based systems — Traefik tags are simply ignored:
tags = [
"tailscale.enable=true",
"traefik.enable=true",
"traefik.http.routers.mealie.rule=Host(`mealie.example.com`)",
]https (default) — Tailscale terminates TLS using an auto-provisioned certificate and proxies HTTP to the backend. This is the right choice for most web services (the backend doesn't need its own TLS cert). Frontend port defaults to 443.
tcp — Raw TCP forwarding with no TLS termination. Use this for non-HTTP protocols (databases, game servers, etc.) or when the backend handles its own TLS. Frontend port defaults to the Consul service port.
# Database: raw TCP on the actual port
tags = [
"tailscale.enable=true",
"tailscale.proto=tcp",
]| Env var | Default | Description |
|---|---|---|
TAILNET |
(required) | Your tailnet domain (e.g. tail5f17e.ts.net) |
CONSUL_HTTP_ADDR |
http://localhost:8500 |
Consul agent address |
CONSUL_HTTP_TOKEN |
Consul ACL token (if ACLs are enabled) | |
TAILSCALE_SOCKET |
/var/run/tailscale/tailscaled.sock |
Path to the Tailscale daemon socket |
TS_OAUTH_CLIENT_ID |
Tailscale OAuth client ID (enables auto-creation of VIP services) | |
TS_OAUTH_CLIENT_SECRET |
Tailscale OAuth client secret | |
TS_DEFAULT_TAG |
tag:server |
Default ACL tag applied to auto-created services |
POLL_INTERVAL |
10s |
Fallback poll interval (Consul blocking queries provide real-time updates) |
TAG_PREFIX |
tailscale. |
Tag prefix to look for on Consul services |
LOG_LEVEL |
info |
Set to debug for verbose logging |
To enable automatic VIP service creation, create an OAuth client in the Tailscale admin console:
- Go to Settings → OAuth clients → Generate OAuth client
- Grant the Services: Write scope
- Store the client ID and secret securely (e.g. in Nomad variables)
Without OAuth credentials, the controller runs in local-only mode — it still advertises services and configures the Tailscale node's serve config, but you'll need to manually create VIP service definitions in the admin console.
To avoid manually approving each service host, add an auto-approver rule to your Tailscale ACL policy:
{
"autoApprovers": {
"services": {
"svc:*": ["tag:server"]
}
}
}nomad var put nomad/jobs/tailscale-controller \
authkey="tskey-auth-xxx" \
oauth_client_id="your-oauth-client-id" \
oauth_client_secret="your-oauth-client-secret"resource "nomad_csi_volume_registration" "tailscale_controller" {
plugin_id = "nfs"
volume_id = "tailscale-controller"
name = "tailscale-controller"
external_id = "tailscale-controller"
namespace = "tailscale"
capability {
access_mode = "multi-node-multi-writer"
attachment_mode = "file-system"
}
}nomad job run controller.nomad.hclAdd tailscale.enable=true to any Consul-registered service and redeploy — the controller will pick it up within seconds (via blocking query) or at the next poll interval.
The controller runs as a Nomad job with two tasks in the same group:
- tailscale (sidecar) — a Tailscale node that registers as a service host and proxies traffic to backends. Shares its daemon socket with the controller via Nomad's
/alloc/tmp/directory. - controller — watches Consul, manages VIP service definitions, advertises services, and configures HTTPS/TCP handlers on the Tailscale node.
Traffic flows: Tailscale client → HTTPS → Tailscale VIP → TLS termination → HTTP → Consul service backend
When running inside Consul Connect (bridge mode with transparent proxy), backend services in other network namespaces aren't directly reachable by IP. The controller automatically detects mesh-enrolled services (those with a corresponding -sidecar-proxy in the catalog) and uses .virtual.consul hostnames as backends. This routes traffic through the transparent proxy (iptables → Envoy → actual backend).
For non-mesh services (no sidecar proxy), the controller uses the direct Consul address as normal.
Note: Your Docker daemon must be configured to forward
.consulDNS queries to Consul's DNS interface (port 8600). See Consul DNS forwarding for setup.
go build ./...
go test ./...
go vet ./...