tailbridge routes private internal domain names through a secondary Tailscale
tailnet without touching host DNS, host routing tables, or requiring elevated
permissions in the tailnet itself.
Everything runs inside Docker. The host only needs proxy environment variables that point selected tools at the local HTTP proxy.
Platform support: Linux only. The Tailscale container runs in kernel mode and
needs /dev/net/tun plus NET_ADMIN. This setup is not intended for Docker
Desktop on macOS or Windows.
flowchart TB
subgraph host[Host machine]
tools[Browser / curl / CLI tools]
hostts[Host Tailscale daemon\nunaffected]
end
subgraph docker[Docker]
subgraph ns[Shared network namespace]
proxy[http-proxy :8118<br/>Privoxy + dnsmasq]
gateway[tailnet-gateway<br/>kernel mode via tailscale0<br/>state: ./tailscale/state]
routes[Shared routing table<br/>private subnets -> tailscale0<br/>default route -> eth0]
end
end
tailnet[Secondary tailnet<br/>internal services]
dns[Split DNS<br/>private suffixes -> 100.100.100.100<br/>public names -> public resolvers]
tools -->|http_proxy / https_proxy| proxy
proxy --> dns
dns --> gateway
proxy --> routes
gateway --> tailnet
hostts --> tailnet
- An app sends an HTTP or HTTPS request.
- Proxy environment variables route it to Privoxy at
127.0.0.1:8118. dnsmasqinsidehttp-proxyresolves names using split DNS:- private DNS suffixes such as
.corpgo to Tailscale DNS100.100.100.100 - public domains go to configurable public resolvers
- private DNS suffixes such as
- Privoxy connects
DIRECTto the resolved IP. - The shared kernel routing table selects the interface:
- private subnets go through
tailscale0 - public traffic goes through the container's normal default route
- private subnets go through
- HTTPS passes through HTTP
CONNECT; there is no TLS interception.
Tailscale's DNS proxy (100.100.100.100) is reached through tailscale0, not a
local listener. Without local caching and split-DNS logic, DNS latency spikes can
stall all lookups and cause public traffic to feel unreliable. dnsmasq keeps a
local cache, sends private suffixes through the tunnel, and sends public lookups
directly to public resolvers.
tailbridge/
|- README.md # This file
|- docker-compose.yml # Service definitions
|- Makefile # Convenience targets
|- .env.example # Safe config template - copy to .env
|- .env # Your local config (gitignored)
|- .gitignore # Ignores local secrets and runtime state
|- privoxy/
| |- Dockerfile # Alpine + privoxy + dnsmasq
| |- entrypoint.sh # Starts dnsmasq then privoxy
| `- config # Privoxy config (DIRECT for all traffic)
|- tailscale/
| `- state/ # Persisted Tailscale auth state (only .gitkeep is committed)
| `- .gitkeep
`- scripts/
|- add-domain.sh # Add a private DNS suffix
|- doctor.sh # Automated health checks
|- login.sh # First-run login helper
`- status.sh # Human-readable status summary
Copy the template:
cp .env.example .env| Variable | Default | Description |
|---|---|---|
PRIVOXY_PORT |
8118 |
Host-side port exposed on 127.0.0.1. Privoxy listens on port 8118 on all container interfaces so Docker can forward the host port into the shared network namespace. |
PRIVATE_DNS_SUFFIXES |
corp |
Space-separated private DNS suffixes resolved through Tailscale DNS. Examples: corp, internal, private.example.com. |
PUBLIC_DNS_PRIMARY |
8.8.8.8 |
Primary public resolver used by dnsmasq for non-private domains. |
PUBLIC_DNS_SECONDARY |
1.1.1.1 |
Secondary public resolver used by dnsmasq for non-private domains. |
TAILNET_DNS_SERVER |
100.100.100.100 |
DNS server used for private suffix resolution through the tailnet. |
TS_LOGIN_SERVER |
(unset) | Custom Tailscale control plane URL for Headscale or another compatible control plane. |
TS_VERSION |
stable |
Tailscale image tag. Pin this to a specific version if you want deterministic upgrades. |
Legacy note: WORK_TLDS is still accepted during migration, but PRIVATE_DNS_SUFFIXES
is now the canonical variable.
These stay in docker-compose.yml and usually do not need edits:
| Variable | Value | Description |
|---|---|---|
TS_USERSPACE |
false |
Kernel mode, required for subnet routing. |
TS_STATE_DIR |
/var/lib/tailscale |
Persisted auth state mounted from ./tailscale/state. |
TS_ACCEPT_DNS |
true |
Applies tailnet DNS settings inside the container. |
TS_EXTRA_ARGS |
--accept-routes |
Accepts subnet routes advertised by the secondary tailnet. |
- Commit
.env.example; keep your real.envlocal and untracked. - Commit
tailscale/state/.gitkeep; keep live Tailscale auth state, caches, and logs out of git. - If you already authenticated locally, review
git status --ignoredbefore pushing anywhere public.
cp .env.example .env
# Edit PRIVATE_DNS_SUFFIXES to match your internal suffixesmake upmake loginThis prints a Tailscale login URL. Open it and sign in. The session persists in
./tailscale/state/, stays local to your machine, and survives container restarts.
Add to your shell profile:
export http_proxy=http://127.0.0.1:8118 https_proxy=http://127.0.0.1:8118 no_proxy=localhost,127.0.0.1,::1Reload your shell, then verify:
make test
make status
make doctor
curl http://somehost.internal.corpmake add-domain DOMAIN=internalThis appends internal to PRIVATE_DNS_SUFFIXES in .env and restarts the
HTTP proxy service.
You can also edit .env directly:
PRIVATE_DNS_SUFFIXES=corp internal private.example.comThen restart the proxy:
docker compose restart http-proxyRemove the suffix from PRIVATE_DNS_SUFFIXES in .env, then restart:
docker compose restart http-proxyPRIVOXY_PORT=9118Then update your shell environment variables and run:
make restartPUBLIC_DNS_PRIMARY=9.9.9.9
PUBLIC_DNS_SECONDARY=1.1.1.1Then restart the proxy:
docker compose restart http-proxyTS_LOGIN_SERVER=https://your.headscale.hostThen restart the stack:
make restartIf you already have the old stack running, the goal is to preserve auth state and avoid a fresh login.
- Back up local state once:
cp .env .env.backup cp -a tailscale/state tailscale/state.backup
- Merge new variables from
.env.exampleinto.env:- add
PRIVATE_DNS_SUFFIXESif missing - add
PUBLIC_DNS_PRIMARYandPUBLIC_DNS_SECONDARYif desired - add
TAILNET_DNS_SERVERif you want it explicit - remove old
WORK_DNSonce you no longer need it
- add
- Preserve your existing suffix list:
- if
.envcurrently usesWORK_TLDS, copy its value intoPRIVATE_DNS_SUFFIXES
- if
- Rebuild and restart:
docker compose down docker compose up -d --build
- Verify:
make doctor make status
Changing service names will recreate containers, but keeping ./tailscale/state
preserves the Tailscale identity in normal cases.
make downCopy the directory as-is, but do not reuse tailscale/state/ on another host.
Prerequisites: Docker Engine, Docker Compose v2, and make.
cp -r tailbridge/ ~/projects/tailbridge
cd ~/projects/tailbridge
mkdir -p tailscale/state
cp .env.example .env
make up
make loginThe container may already be authenticated. Check:
make status
docker logs tailnet-gateway | grep -E '(login|auth|100\.)'If state is corrupted:
make clean && make up && make login- Check
PRIVATE_DNS_SUFFIXESin.env - Confirm Tailscale is connected:
make status - Test dnsmasq inside the proxy container:
docker exec http-proxy nslookup somehost.corp - Test tailnet DNS directly:
docker exec tailnet-gateway nslookup somehost.corp 100.100.100.100 - Check accepted routes:
docker exec tailnet-gateway ip route show table 52
Verify that dnsmasq is running and the resolver config is local:
docker exec http-proxy ps | grep dnsmasq
docker exec http-proxy cat /etc/resolv.conf
docker exec http-proxy nslookup example.comSome tools require both lowercase and uppercase variables:
export http_proxy=http://127.0.0.1:8118
export HTTP_PROXY=http://127.0.0.1:8118
export https_proxy=http://127.0.0.1:8118
export HTTPS_PROXY=http://127.0.0.1:8118Browsers usually ignore shell proxy variables. Configure them directly or use the desktop environment's proxy settings.
- Entry point:
docker-compose.yml - Private suffix routing:
PRIVATE_DNS_SUFFIXESin.env - Legacy compatibility:
WORK_TLDSis still supported during migration - Auth state:
tailscale/state/(commit.gitkeeponly) - No secrets should be committed;
.envand live state stay gitignored - Changing DNS suffixes only requires restarting
http-proxy