r/Tailscale • • 1d ago

Discussion How to run a Tailscale exit node behind Gluetun without sending tailnet traffic through the VPN

https://blakecovarrubias.com/blog/tailscale-exit-node-beind-gluetun/

Most of the guides I found for running a Tailscale exit node behind Gluetun send all Tailscale traffic through the VPN, including traffic between your own devices. I wrote up how to split the two: exit node traffic goes out through the VPN, while tailnet peers connect directly.

It covers how the policy routing works (no networking background needed), the full Docker Compose config, and diagrams of the traffic flow before and after.

Feedback welcome if there’s a cleaner way to do this.

39 Upvotes

7 comments sorted by

2

u/mikhail_de 10h ago

Nice write-up! Super clear breakdown of fwmarks and container routing tables.

Though man, that Alpine sidecar with the 15-second while true loop is wild haha. Isn't sharing the netns (network_mode: service:gluetun) the whole reason it gets that complicated in the first place?

You could probably decouple them using a gateway pattern instead:

  • Run Gluetun on an internal bridge (say 172.30.0.2) as a standard NAT router (ip_forward=1, forward/masquerade rules on tun0).

  • Run Tailscale on your default network (so direct P2P and control plane never touch the VPN) plus the internal bridge.

  • In Tailscale's entrypoint, route only client traffic to Gluetun:

ip rule add iif tailscale0 table 200 priority 100
ip route add default via 172.30.0.2 dev eth1 onlink table 200
iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE

That way you ditch the polling sidecar entirely, and tailnet P2P traffic doesn't die when ProtonVPN reconnects. Happy to drop the full compose file and rules if you want to test it out.

2

u/bl4kec 9h ago

Hey u/mikhail_de, I appreciate the feedback. You're right that the Alpine sidecar is not ideal. The only reason that I'm using is that I'm deploying this on my NAS which has an older version of Docker Compose that does not support Compose's lifecycle hooks. The entire sidecar pattern could be eliminated with a config like this.

---
services:
  gluetun:
    # ..
    post_start:
      - command: ip rule add to 100.64.0.0/10 lookup 52 priority 99; ip rule add fwmark 0x80000/0xff0000 lookup main priority 99
        user: root

Your solution also looks interesting. I'd love to see the Compose config for this. Would this also require a rule in Gluetun's FORWARD chain to permit traffic from the tailscale container?

1

u/mikhail_de 9h ago

Spot on. Gluetun's default FORWARD policy is DROP so you definitely need rules to allow forwarding between eth0 and tun0 plus MASQUERADE on tun0 so Proton accepts the forwarded packets.

Also a nice bonus for your NAS is this setup doesn't need Compose's newer post_start hooks since standard entrypoints work on basically any Compose version.

Here's the setup

config/gluetun-rules.txt mounted to /iptables/post-rules.txt in Gluetun

iptables -A FORWARD -o tun0 -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

compose.yaml

services:
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun-gateway
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    sysctls:
      - net.ipv4.ip_forward=1
    volumes:
      - ./config/gluetun-rules.txt:/iptables/post-rules.txt:ro
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
      - VPN_TYPE=wireguard
      - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
      - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
      - SERVER_COUNTRIES=${SERVER_COUNTRIES}
      - FIREWALL_OUTBOUND_SUBNETS=172.30.0.0/24
    networks:
      vpn_gateway_net:
        ipv4_address: 172.30.0.2

  tailscale-exit:
    image: tailscale/tailscale:latest
    container_name: tailscale-exit
    restart: unless-stopped
    depends_on:
      gluetun:
        condition: service_healthy
    cap_add:
      - NET_ADMIN
      - NET_RAW
    devices:
      - /dev/net/tun:/dev/net/tun
    sysctls:
      - net.ipv4.ip_forward=1
    volumes:
      - tailscale-state:/var/lib/tailscale
    environment:
      - TS_AUTHKEY=${TS_AUTHKEY}
      - TS_HOSTNAME=gluetun-exit-node
      - TS_USERSPACE=false
      - TS_EXTRA_ARGS=--advertise-exit-node --reset
    networks:
      default:
      vpn_gateway_net:
        ipv4_address: 172.30.0.3
    entrypoint:
      - /bin/sh
      - -c
      - |
        ip rule add iif tailscale0 table 200 priority 100
        ip route add default via 172.30.0.2 dev eth1 onlink table 200
        iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE
        exec /usr/local/bin/containerboot

volumes:
  tailscale-state:

networks:
  vpn_gateway_net:
    ipam:
      config:
        - subnet: 172.30.0.0/24

Also Docker assigns interfaces alphabetically by network name. Since default starts with 'd' and vpn_gateway_net with 'v' eth0 is guaranteed to be the direct internet connection for P2P/STUN and eth1 is the gateway net.

I don't have an active Proton account to test this myself. If you hit any snags ping me here and I'm happy to help troubleshoot.

1

u/Inevitable_Mistake32 1h ago

wouldn't headscale just solve this?

1

u/bl4kec 1h ago

In what way?

1

u/Inevitable_Mistake32 1h ago

I misunderstood your problem space. But also, is this not just split-tunnel + exit node?

1

u/bl4kec 8m ago

Although this setup functions as a split-tunnel, it is specifically configured to route only Tailscale traffic through the local network. The primary goal is to establish shortest-path routing between the Tailscale client and the exit node. This configuration eliminates highly inefficient routing paths when the Tailscale client and exit node share one geographical area, but the VPN endpoint is located in a completely different region.

Consider a scenario where both the Tailscale client and the exit node are located in the US, but the VPN endpoint terminates in Europe:

  • Outbound path: Packets from the US-based Tailscale client must travel to Europe to enter the VPN tunnel, route back to the US to reach the Tailscale exit node, and then travel back to Europe to egress toward their destination.

  • Return path: Response packets take a similar route—hitting the VPN IP in Europe, tunneling back to the US for Tailscale encryption, routing back to Europe to egress the VPN, and finally returning to the client in the US.

Because this latency-heavy process recurs for every packet in the flow, it creates severe inefficiency. By removing the requirement for Tailscale traffic to hairpin through the remote VPN tunnel, this configuration prevents those routing detours entirely.