| config | initial commit | |
| scripts | add interactive setup script | |
| .dockerignore | initial commit | |
| .env.example | improve exit node performance | |
| .gitignore | initial commit | |
| docker-compose.yml | improve exit node performance | |
| Dockerfile | initial commit | |
| README.md | add interactive setup script | |
tailwg-exit
Containerized Tailscale exit node that forwards internet traffic through a commercial WireGuard VPN provider.
Traffic flow:
User device -> Tailscale -> tailwg-exit -> provider WireGuard VPN -> Internet
Tailnet traffic still goes directly over Tailscale:
User device -> Tailscale -> destination tailnet device
This project is designed to be small, auditable, and fail-closed.
What It Does
- Runs Tailscale inside a container.
- Advertises that container as a Tailscale exit node.
- Runs a second WireGuard tunnel to your VPN provider.
- Policy-routes only Tailscale exit-node client traffic into the provider VPN.
- NATs that forwarded traffic out the provider tunnel.
- Rejects forwarded traffic if it would leave anywhere except the provider tunnel.
This avoids Android's one-VPN limitation because your Android device only runs Tailscale locally.
Security Model
This setup improves privacy, but it is not magic anonymity.
What it helps with:
- Your phone/laptop uses only Tailscale locally.
- Internet egress comes from your VPN provider, not your home IP.
- Exit-node traffic should not leak through your host's normal interface if
wg0goes down. - Tailscale ACLs can prevent the exit-node container from initiating access to the rest of your tailnet.
What it does not hide:
- Tailscale still sees coordination metadata.
- Your ISP sees your server connecting to the VPN provider.
- Your VPN provider sees internet egress metadata.
- HTTPS is still required to protect website contents.
Project Files
Dockerfile Debian image with Tailscale and WireGuard tools
docker-compose.yml Container definition
.env.example Environment template
config/wg0.conf.example Provider WireGuard template
scripts/entrypoint.sh Startup, routing, NAT, and leak protection
.dockerignore Keeps secrets and state out of builds
.gitignore Ignores secrets, runtime state, and editor files
Published image:
codeberg.org/pluja/tailwg-exit:latest
Recommended Host
Use an always-on Linux machine.
Minimum:
1 CPU core
512 MB RAM
1 GB disk
Docker + Compose
Recommended:
Small Debian/Ubuntu VM or LXC
1-2 CPU cores
1 GB RAM
Docker installed
Using a dedicated VM or LXC is more production-grade than running this on a busy general-purpose Docker host, because the container needs /dev/net/tun, NET_ADMIN, and NET_RAW.
Quick Start
Interactive installer:
curl -fsSL https://codeberg.org/pluja/tailwg-exit/raw/branch/main/scripts/setup.sh | bash
The installer will:
- Create
~/tailwg-exitby default. - Download
docker-compose.ymland example config files. - Ask for your Tailscale auth key, hostname, and tag.
- Import or paste your provider WireGuard config.
- Add
Table = offif missing. - Comment out
DNS = ...in the provider config to avoid resolver surprises. - Pull
codeberg.org/pluja/tailwg-exit:latest. - Optionally start the container.
Manual setup:
- Create a Tailscale tag named
exit-node. - Create a tagged Tailscale auth key for
tag:exit-node. - Put your provider WireGuard config in
config/wg0.conf. - Start the container.
- Approve the machine and approve the exit node route in Tailscale admin.
- Select
tailwg-exitas exit node from your clients.
The detailed manual steps are below.
1. Prepare The Project
From the project directory:
cp .env.example .env
cp config/wg0.conf.example config/wg0.conf
mkdir -p state/tailscale
chmod 700 state
chmod 700 state/tailscale
chmod 600 config/wg0.conf
Edit .env:
TS_AUTHKEY=tskey-auth-REPLACE_ME
TS_HOSTNAME=tailwg-exit
TS_ADVERTISE_TAGS=tag:exit-node
TS_ACCEPT_DNS=false
TS_NETFILTER_MODE=off
WG_MTU=
TCP_MSS_CLAMP=auto
Important variables:
TS_AUTHKEY: only needed for first login, or if you deletestate/tailscaleTS_HOSTNAME: how the node appears in TailscaleTS_ADVERTISE_TAGS: should betag:exit-nodeTS_ACCEPT_DNS=false: prevents the container from accepting Tailscale DNS changesTS_NETFILTER_MODE=off: lets this project own the forwarding/NAT rules instead of mixing in Tailscale netfilter rulesWG_MTU=: uses the MTU from your provider WireGuard config; set only if you need an overrideTCP_MSS_CLAMP=auto: clamps TCP SYN packets to path MTU to avoid upload/download stalls without forcing a tiny fixed MSS
Keep this default value unchanged:
TAILSCALE_IPV4_CIDR=100.64.0.0/10
That CIDR covers the full Tailscale IPv4 range. Do not replace it with your specific device IPs.
2. Create The Tailscale Tag
In the Tailscale admin console:
- Go to Access Controls.
- Create tag
exit-node. - Set the tag owner to
autogroup:admin.
Meaning:
- The real tag name becomes
tag:exit-node. - Only admins can assign it.
3. Configure ACLs
Use a policy that lets members use exit nodes but does not let tag:exit-node initiate connections into your tailnet.
Example working ACL file:
{
"tagOwners": {
"tag:router": ["autogroup:admin"],
"tag:exit-node": ["autogroup:admin"]
},
"grants": [
{
"src": ["autogroup:member"],
"dst": ["autogroup:internet"],
"ip": ["*"]
}
],
"acls": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:member:*", "tag:router:*", "tag:exit-node:*"]
},
{
"action": "accept",
"src": ["tag:router"],
"dst": ["*:*"]
}
],
"ssh": [
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}
]
}
Simple summary:
- Members can talk to other members.
- Members can talk to routers.
- Members can see/use the exit node.
- Members can use
autogroup:internet, which is required for exit nodes. - Routers can still reach everything.
- The exit node cannot initiate connections to your devices because there is no rule with
src: ["tag:exit-node"].
Do not keep a broad allow-all rule like this if you want isolation:
{
"action": "accept",
"src": ["*"],
"dst": ["*:*"]
}
4. Create The Auth Key
In the Tailscale admin console, use Add Linux server only to generate the auth key. Do not run the generated install command on the host.
Recommended settings:
- Tags: On
- Tag:
tag:exit-node - Ephemeral: Off
- Use as exit node: On
- Reusable: Off
- Short expiration: fine, as long as you use it soon
Tailscale may generate a command like:
curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-... --advertise-exit-node
Do not run that command for this project.
Only copy the auth key value:
tskey-auth-...
and place it in .env:
TS_AUTHKEY=tskey-auth-...
5. Add Your Provider WireGuard Config
Put your provider config at:
config/wg0.conf
For this project, modify it to keep only IPv4 first.
Required changes:
- Add
Table = offunder[Interface] - Remove
DNS = ... - Keep only the IPv4 address initially
- Keep only
AllowedIPs = 0.0.0.0/0initially
Example:
[Interface]
PrivateKey = REPLACE_ME
Address = 10.0.0.2/32
Table = off
[Peer]
PublicKey = REPLACE_ME
Endpoint = vpn-provider.example:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Why:
Table = offpreventswg-quickfrom hijacking the whole routing table.- Removing
DNS = ...avoids resolver surprises and metadata leaks. - IPv4-only first is safer. Broken IPv6 is a common leak source.
If your provider gave you IPv6 too, ignore it at first. Add it later only after IPv4 works and is leak-tested.
6. Start The Container
Option 1: build locally
docker compose up -d --build
Option 2: use the published image
docker compose pull
docker compose up -d
Watch logs:
docker compose logs -f
Expected shape:
starting tailscaled
starting WireGuard provider tunnel
installing policy routes
installing IPv4 firewall and leak protection
ready: use tailwg-exit as a Tailscale exit node
Harmless warnings you may see:
TPM: error opening /dev/tpmrm0Warning: IPv6 forwarding is disabledUDP GRO forwarding is suboptimally configured
These are not blockers for the IPv4-only setup. If you want maximum throughput later, tune the Docker host for UDP offload/GRO according to Tailscale's current Linux optimization docs.
7. Approve The Machine And The Exit Node
This is the step that is easiest to miss.
After startup, the node may appear in Tailscale admin but still not be usable as an exit node.
You must do both:
- Approve the new machine.
- Approve the exit-node route.
If the dashboard shows Waiting approval, approve it.
If the machine appears in your tailnet but tailscale exit-node list says no exit nodes found, the exit-node route is probably still waiting approval.
After approval, clients should see it as an exit node.
8. Verify On The Server
Run:
docker exec tailwg-exit tailscale status
docker exec tailwg-exit wg show wg0
docker exec tailwg-exit ip rule
docker exec tailwg-exit ip route show table providerwg
docker exec tailwg-exit iptables -S FORWARD
docker exec tailwg-exit iptables -t nat -S POSTROUTING
Healthy signs:
wg show wg0has a recent handshakeip rulecontains:
from 100.64.0.0/10 lookup providerwg
ip route show table providerwgcontains:
default dev wg0 scope link
iptables -S FORWARDcontains:
-A FORWARD -i tailscale0 -o wg0 -j ACCEPT
-A FORWARD -i wg0 -o tailscale0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i tailscale0 ! -o wg0 -j REJECT
iptables -t nat -S POSTROUTINGcontains:
-A POSTROUTING -s 100.64.0.0/10 -o wg0 -j MASQUERADE
iptables -t mangle -S FORWARDcontains TCPMSS rules using either--clamp-mss-to-pmtuor your configured fixed MSS.
That reject rule is the key leak-protection rule. If wg0 dies, forwarded client traffic should not fall back to your normal network.
9. Verify From Another Tailscale Client
On Linux:
tailscale exit-node list
You should see:
tailwg-exit.dojo-arctic.ts.net
To connect to it:
sudo tailscale set --exit-node=tailwg-exit --exit-node-allow-lan-access=true
To disconnect later:
sudo tailscale set --exit-node=
On Android:
Open Tailscale -> Exit node -> select tailwg-exit
If it does not appear in Android:
- Make sure the exit-node route was approved in Tailscale admin.
- Make sure your ACL includes
tag:exit-node:*in the member destination list. - Disconnect and reconnect the Android app.
10. Verify Public IP And Tailnet Reachability
While using the exit node, check your public IP:
curl https://ifconfig.me
Expected result:
- It should show your provider VPN egress IP.
- It should not show your home/public server IP.
Then test tailnet reachability too:
ping 100.x.y.z
That should still work over Tailscale.
For transparency under real traffic, also test a large download and upload while using the exit node:
curl -L -o /tmp/test.bin https://speed.cloudflare.com/__down?bytes=100000000
curl -T /tmp/test.bin https://speed.cloudflare.com/__up
Both should complete without hanging. Hangs during large transfers usually mean an MTU/PMTU problem; keep TCP_MSS_CLAMP=auto enabled, and only set a fixed lower WG_MTU or TCP_MSS_CLAMP if your provider path is broken.
This is the desired behavior:
Internet -> provider VPN
Tailnet traffic -> direct Tailscale
11. Remove The Auth Key After First Successful Login
Once the node is registered and state/tailscale exists, remove the auth key from .env:
TS_AUTHKEY=
or delete the line entirely.
Then restart once to verify persisted state works:
docker compose restart
docker compose logs -f
If it comes back and reaches:
ready: use tailwg-exit as a Tailscale exit node
then the persistent state is working and you no longer need the auth key on disk.
12. DNS Privacy Notes
DNS is a common metadata leak.
Recommended approach:
- Do not put
DNS = ...inconfig/wg0.confinitially. - Keep
TS_ACCEPT_DNS=falsein the container. - Let your clients use Tailscale DNS settings or OS-level encrypted DNS.
- If using MagicDNS, remember that tailnet name lookups involve Tailscale DNS infrastructure.
Simple advice: get the routing working first, then tune DNS only if you have a specific privacy goal and understand the tradeoffs.
13. IPv6
IPv6 is disabled by default in this project.
Keep it disabled until IPv4 works and you have tested for leaks.
To enable IPv6 later:
- Add the IPv6 address back into
config/wg0.conf. - Change
AllowedIPsto:
AllowedIPs = 0.0.0.0/0, ::/0
- Set:
ENABLE_IPV6=true
- Restart and run IPv6 leak tests.
If you are unsure, stay IPv4-only.
14. ACL Isolation Test
You want clients to use the exit node, but you do not want the exit node to be able to browse your tailnet.
Test from inside the container:
docker exec tailwg-exit ping 100.x.y.z
If your ACLs are correct, that should fail unless you explicitly allowed that path.
This asymmetry is intentional:
Clients -> exit node: allowed
Exit node -> clients/internal tailnet devices: denied
15. Hardening Checklist
Do this:
- Run on a dedicated VM/LXC or dedicated Docker host if possible.
- Keep the host updated.
- Use SSH keys only on the host.
- Keep
config/wg0.confmode0600. - Remove
TS_AUTHKEYafter first successful registration. - Keep IPv6 disabled until verified.
- Keep
TCP_MSS_CLAMP=autounless you have measured a better provider-specific value. - Use a WireGuard config dedicated to this gateway.
- Use Tailscale ACLs to block
tag:exit-nodefrom initiating access to other tailnet devices. - Do not advertise subnet routes from this container.
- Do not publish ports from this container.
Avoid this:
- Do not use broad allow-all ACLs.
- Do not run unrelated services in this container.
- Do not mount the Docker socket.
- Do not run with
privileged: trueunless absolutely necessary. - Do not use host networking unless you fully understand the routing impact.
16. Troubleshooting
The machine appears in Tailscale, but not as an exit node
Check the admin dashboard. The exit-node route is probably still waiting approval.
tailscale exit-node list says no exit nodes found
One of these is usually true:
- Exit-node route not approved yet
- ACL does not allow
autogroup:internet - ACL does not allow members to reach
tag:exit-node:*
Android does not show the exit node
Check:
- exit-node route approval
- ACL includes
tag:exit-node:* - app disconnect/reconnect
wg show wg0 has no handshake
Check:
- provider endpoint
- provider public key
- private key
- host outbound UDP filtering
- server clock/time
Public IP is your home/server IP instead of the provider IP
Stop using the exit node immediately and inspect:
ip ruleip route show table providerwgiptables -S FORWARDiptables -t nat -S POSTROUTING
Expected route table:
from 100.64.0.0/10 lookup providerwg
default dev wg0 scope link
Production Note
This project is intentionally simple. The smaller the networking stack, the easier it is to audit. For the strongest operational setup, run it in a small dedicated VM or LXC and snapshot the working configuration.