Tailbox: Split-Tunneling Through Mullvad via a Tailscale Container

On this page

Tailbox Architecture

What Is Tailbox

I spend most of my workday on a corporate VPN that owns the default route on my laptop. It needs to stay up for me to do my job, so tearing it down whenever I want to hide some other piece of traffic is not an option. What I actually wanted was this: keep the work tunnel up, and at the same time have specific browser tabs and specific shell commands exit through my home LAN via Mullvad. Split-tunnel in the browser and in the shell, not at the kernel route table.

Tailbox is the result. On the home side there is a Docker Compose stack that runs a Mullvad WireGuard tunnel together with a Tailscale node and a SOCKS5 proxy inside the same network namespace. On the client side there is a standalone Bash script that launches a rootless Podman container and exposes that same proxy on localhost:1055. Firefox Container Tabs and a proxychains alias do the actual split-tunneling: regular tabs still use the work VPN, “Mullvad” tabs use the home exit, and both coexist in the same browser window.

The end result is a SOCKS5 proxy on localhost:1055 that sends selected traffic through Mullvad from wherever I am, without fighting anything else that is already on the default route. No port forwarding on the home server, no dynamic DNS, no static IP, works behind any NAT.

Client (laptop)                         Server (Docker host)
+-----------------------+               +-----------------------------------+
| curl/browser          |               | mullvad-gateway (gluetun)         |
|   -> localhost:1055   |               |   tailscale-endpoint              |
|     -> tailbox (podman)| -- Tailscale ->|   tailbox-socks (microsocks:1080) |
|       -> socat        |   WireGuard   |   tailbox-dns (dnsmasq:5353)      |
|         -> tailscale nc|               |   + your services (searxng, ..)   |
|                       |               |   tun0 -> Mullvad -> Internet     |
+-----------------------+               +-----------------------------------+

The client runs a setup wizard on first use: just run tailbox and it handles image pinning, proxychains configuration, shell aliases, and exit node setup interactively. Subsequent starts connect instantly.

Tailbox is not limited to SOCKS5. The architecture is designed so that any Docker container can join the VPN namespace. microsocks is just the first sidecar, but you can drop in a SearXNG metasearch instance, a FreshRSS feed reader, a headless browser for scraping, a Matrix bridge, or anything else that should exit through Mullvad. Each extra service shares the same tun0 tunnel with zero additional VPN configuration, and each one becomes reachable over your Tailscale mesh at the same time. More on this in “Adding Your Own Services” below.

Repos: tailbox-server and tailbox-client.

Network Namespace Sharing

Before the debugging stories start making sense, there is one Linux primitive the rest of the post depends on: network namespace sharing. All sidecar containers on the server use network_mode: "container:mullvad-gateway". This is not just a convenience, it is the mechanism that makes everything work. The containers do not get their own network stacks. They share gluetun’s exact interfaces:

  • eth0 (Docker bridge, host/LAN connectivity)
  • tun0 (Mullvad WireGuard tunnel)
  • tailscale0 (Tailscale virtual interface)

microsocks can bind to :1080 and it is reachable from Tailscale because they are literally on the same loopback. dnsmasq on :5353 is reachable from gluetun’s DNS proxy on :53 for the same reason.

The consequence is that when gluetun restarts, the entire namespace is destroyed and recreated. Every sidecar loses its network. docker restart does not fix this because the sidecar’s namespace reference becomes stale. You need docker compose up -d --force-recreate to rebuild the namespace reference. This is what gluetun-watcher.sh does automatically.

Ports exposed by sidecars must be declared on the gluetun service itself (not the sidecar) and listed in FIREWALL_INPUT_PORTS. The sidecar has no independent port mapping capability.

Keep this in mind while reading the next sections: whenever you see nsenter into the mullvad-gateway PID, iptables rules “inside the namespace”, or network_mode: "container:...", this is the primitive they are all leaning on.

Using It

Because the proxy is plain SOCKS5 on localhost:1055, anything that speaks SOCKS can use it. In practice I use it in two places: Firefox (per-tab) and the shell (per-command).

Browser: Firefox Container Tabs

The nice workflow I landed on is Firefox Container Tabs, because it lets me keep a normal Firefox window open on the work VPN and send only specific tabs through Mullvad. Install Multi-Account Containers and Container Proxy, create a “Mullvad” container, and point it at SOCKS5 localhost:1055 with proxy DNS enabled.

Now you get regular tabs and Mullvad tabs side by side in the same window. Only the Mullvad container routes through the VPN. If you want to route the entire browser instead, Firefox also has a plain SOCKS proxy setting under Network Settings, but the container approach is better for the work-VPN-plus-Mullvad scenario because the rest of the browser keeps behaving normally.

Command Line: proxychains and Shell Aliases

tailbox install writes a proxychains config at ~/.proxychains-tailscale.conf pointing at socks5 127.0.0.1 1055, and installs two shell functions (defaults: tb for a generic proxychains wrapper, ssht for SSH through the tunnel). Typical use:

tb curl https://am.i.mullvad.net/connected   # exits via Mullvad, should report "you are connected"
ssht user@home-server                        # SSH through the tunnel
tb git clone https://example.com/repo.git    # arbitrary TCP tool

Under the hood the alias expands to proxychains4 -q -f ~/.proxychains-tailscale.conf <cmd>. proxychains intercepts the libc connect() call and redirects TCP through the SOCKS proxy, so any dynamically linked program that opens TCP sockets works without modification. UDP does not work through SOCKS5, which is fine for most command-line tools (git, curl, ssh, apt are all TCP). DNS goes through the proxy too because proxy_dns is set in the config, so there are no DNS leaks on the shell path either.

The alias names are configurable: SSH_ALIAS and PROXY_ALIAS in ~/tailscale-container/tailbox.conf. If you change them, the rc-file functions are rewritten on the next tailbox start.

Why socat Instead of Exit Node

This is the most important architectural decision in the project. My first attempt used Tailscale’s built-in exit node feature, where you set --advertise-exit-node on the server and --exit-node=<hostname> on the client. Clean and simple. Except it does not work inside gluetun’s network namespace.

The reason comes down to how Linux network namespaces and nftables interact. gluetun creates a WireGuard tunnel (tun0) and sets up a kill switch by defaulting FORWARD, INPUT, and OUTPUT chains to DROP in iptables/nftables. For a Tailscale exit node to forward traffic, you need three things:

  1. A FORWARD rule allowing traffic from tailscale0 to tun0
  2. MASQUERADE (or SNAT) on the tun0 interface so return traffic finds its way back
  3. Correct rp_filter settings so the kernel does not drop asymmetric routes

The problem is that gluetun rebuilds its entire nftables ruleset from scratch every time it starts or reconnects. Any injected FORWARD or MASQUERADE rules get wiped. You could add them back via a watcher script, but there is a timing window where traffic leaks or drops, and the interaction between gluetun’s nftables and Tailscale’s own nftables rules (it uses TS_DEBUG_FIREWALL_MODE=nftables on the server) is fragile.

socat sidesteps the entire problem. It does not forward packets at the IP layer. Instead, it accepts a TCP connection on port 1081 and runs tailscale nc <hostname> <port>, which opens a new outbound TCP connection via Tailscale’s userspace networking stack. From gluetun’s perspective, this is just local loopback traffic (socat) plus a normal outbound connection from microsocks through tun0. No FORWARD chain, no NAT, no rp_filter interaction. It works in both kernel mode (tailscale0 tun device) and userspace mode because tailscale nc uses Tailscale’s own TCP proxy rather than raw tun.

The tradeoff is that every SOCKS5 connection goes through two TCP proxies (socat and microsocks) instead of being forwarded at the IP layer. For browsing and typical use this is not noticeable. For high-throughput transfers you would feel it.

The socat Forwarding Chain

Here is the full path from your browser to the internet:

1. Application (curl, browser)
   SOCKS5 connect -> localhost:1055

2. Podman port mapping
   Host 1055 -> container 1081  (-p 127.0.0.1:1055:1081)

3. socat inside container
   TCP4-LISTEN:1081,fork,reuseaddr
   EXEC:tailscale nc tailbox-endpoint 1080

4. tailscale nc
   Opens TCP stream over WireGuard to peer "tailbox-endpoint"
   Traversal: direct UDP or DERP relay

5. microsocks on server (:1080)
   Receives SOCKS5 connection
   Opens outbound TCP to destination via tun0

6. tun0 -> Mullvad WireGuard -> Internet

The Podman port mapping (-p 127.0.0.1:1055:1081) maps the host-facing port to the container’s internal listen port. Binding to 127.0.0.1 ensures the proxy is only reachable from the host, not the LAN.

socat is baked into the container image at install time via a Containerfile layer on top of the official Tailscale Alpine image. No runtime apk add, so the image is fully self-contained and does not depend on Alpine repos being up when the container starts.

The DNS Leak Problem

This was the most interesting discovery of the project. I had a working VPN setup for a while, but when I sat down to package it for open source, I found a DNS leak in my own production stack.

My original setup used Pi-hole as gluetun’s DNS upstream. That meant every DNS query, including google.com, went to Pi-hole via the LAN, bypassing the VPN tunnel completely. Pi-hole then forwarded to Cloudflare using my ISP connection. gluetun’s own leak check confirmed it:

leak check report: 172.64.210.29 (50%), 162.158.221.108 (30%)

Those are Cloudflare IPs, not Mullvad. The VPN was encrypting the actual HTTP traffic, but DNS was leaking my browsing intent in cleartext.

The obvious fix is to use Mullvad’s DNS (10.64.0.1) instead, which resolves through the tunnel. But that breaks local domain resolution. My local services resolved to LAN IPs via Pi-hole, but through Mullvad DNS they resolved to public IPs via CNAME records. Traffic would hairpin through the internet and get blocked by my reverse proxy’s IP allowlist (which only accepts local and Docker subnets).

Why gluetun Cannot Do This

gluetun supports multiple upstream DNS servers via DNS_UPSTREAM_PLAIN_ADDRESSES. But when you configure multiple servers, gluetun load-balances across them randomly for each query. There is no way to say “send *.lan.example.com to server A and everything else to server B”. This is a known limitation, tracked in GitHub issue #3233 and #1839.

Split DNS via dnsmasq

The solution is a dnsmasq sidecar container running inside gluetun’s network namespace on port 5353. The key is that dnsmasq supports the server=/<domain>/<ip> directive, which routes queries matching a domain suffix to a specific upstream.

The naive approach would be to set DNS_UPSTREAM_PLAIN_ADDRESSES=127.0.0.1:5353 so gluetun’s DNS proxy forwards to dnsmasq. This almost works, but gluetun has one more surprise: hardcoded rebinding protection. It silently drops any DNS response containing an RFC1918 private IP address. There is a DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES setting, but it only supports exact hostnames, not wildcards or domain suffixes. Useless for *.lan.example.com.

The fix is to bypass gluetun’s DNS proxy entirely using an iptables DNAT rule inside the namespace:

iptables -t nat -A OUTPUT -p udp --dport 53 -j DNAT --to-destination 127.0.0.1:5353
iptables -t nat -A OUTPUT -p tcp --dport 53 -j DNAT --to-destination 127.0.0.1:5353

Now all DNS queries in the namespace go directly to dnsmasq on 5353, skipping gluetun’s proxy and its rebinding filter. dnsmasq then splits:

server=/lan.example.com/192.168.1.1    # local -> LAN DNS via eth0
server=/168.192.in-addr.arpa/192.168.1.1  # reverse PTR -> LAN DNS
server=10.64.0.1                        # everything else -> Mullvad via tun0

This does not reduce security. dnsmasq only returns private IPs for explicitly configured local domains. All other queries go to Mullvad DNS, which would never return private addresses. The DNAT rules are idempotent and re-applied by the watcher after every gluetun restart.

gluetun’s FIREWALL_OUTBOUND_SUBNETS allows traffic to the LAN subnet via eth0, so dnsmasq queries to the local DNS server take that path, while queries to 10.64.0.1 go through tun0. The reverse DNS entries cover all RFC1918 subnets (10.x, 172.16-31.x, 192.168.x) so PTR lookups for private IPs also stay on the LAN.

The project supports three DNS modes via DNS_MODE in .env: split (default, no leaks, local names work), mullvad (maximum privacy, no local resolution), and custom (user-specified server, documented leak risk).

The Routing Fix

With Tailscale running inside gluetun’s network namespace, I hit a subtle bug: tailscale ping worked fine but TCP connections through the SOCKS proxy timed out silently. Here is why.

gluetun installs a policy routing rule at priority 101:

101: not from all fwmark 0xca6c lookup 51820

This means “any packet not marked with gluetun’s own fwmark, look up table 51820”. Table 51820 has a default route via tun0 (Mullvad). This is the kill switch: it forces all traffic through the VPN.

Tailscale installs its own rule at priority 5270:

5270: from all lookup 52

Table 52 handles Tailscale’s CGNAT range (100.64.0.0/10), routing it through tailscale0.

The problem: priority 101 is evaluated before 5270. When microsocks on the server sends return traffic to a Tailscale client (destination 100.64.x.x), the packet matches gluetun’s rule 101 first, gets routed to tun0, and is sent to Mullvad. The TCP session never completes because the return path is wrong.

tailscale ping still works because it uses ICMP via the DERP relay, which does not depend on correct return routing through the namespace.

The fix injects two rules inside the namespace via nsenter:

PID=$(docker inspect -f '{{.State.Pid}}' mullvad-gateway)

# Priority 100: route CGNAT traffic to Tailscale's table, before gluetun's 101
nsenter -t "$PID" -n -- ip rule add to 100.64.0.0/10 lookup 52 priority 100

# Also fix gluetun's own WireGuard table so CGNAT exits via tailscale0
nsenter -t "$PID" -n -- ip route add 100.64.0.0/10 dev tailscale0 table 51820

Both rules are idempotent (the script checks before adding). A systemd service (gluetun-watcher.sh) watches docker events for gluetun restarts and reapplies the fix, plus runs it once at startup to cover cold boots where no restart event fires. This startup-fix was itself a bug I found: on a clean system boot the watcher would start, enter the event loop, and wait forever because gluetun was already running, no start event to trigger on.

The Same Bug, Client-Side

The “ping works but TCP does not” pattern bit me again on the client. The client script used tailscale ping to check if the exit node was reachable before starting socat. tailscale ping uses the disco protocol, which traverses DERP relays and does not require a working TCP path. When the exit node was reachable via DERP but the TCP path through the relay was slow or unreliable, the ping check would fail after its timeout even though the underlying connection was fine for sustained TCP. The result: socat never started, leaving the proxy dead with a misleading “could not reach exit node” error.

The fix was replacing tailscale ping with tailscale nc, the same tool socat uses for the actual forwarding. If tailscale nc <exit-node> <port> succeeds, you know the exact TCP path that socat will use is working. If it fails, the proxy would not have worked anyway. This is a better check in every way: it tests what you actually care about instead of a different protocol that happens to share the same network path.

Adding Your Own Services

The namespace sharing model means any Docker container can route through Mullvad by joining gluetun’s network stack. This turns Tailbox into a general-purpose exit gateway: SOCKS5 for browsers is just the first tenant. Three things are needed to add a new service:

  1. Set network_mode: "container:mullvad-gateway" on the new service
  2. Declare its port on the gluetun service (sidecars cannot expose ports)
  3. Add the port to FIREWALL_INPUT_PORTS in .env

As a concrete example, say you want a private SearXNG metasearch instance that proxies all its upstream queries through Mullvad, so Google and friends never see your home IP:

# In tailscale-endpoint/docker-compose.yaml (or a new compose file)
  searxng:
    image: searxng/searxng:latest
    container_name: tailbox-searxng
    network_mode: "container:mullvad-gateway"
    environment:
      - SEARXNG_BASE_URL=http://localhost:8080/
    volumes:
      - ./searxng-config:/etc/searxng
    restart: unless-stopped

Then on the gluetun service in gluetun-mullvad/docker-compose.yaml:

    ports:
      - "127.0.0.1:1080:1080"   # microsocks (existing)
      - "127.0.0.1:8080:8080"   # searxng WebUI
    environment:
      - FIREWALL_INPUT_PORTS=1080,8080

SearXNG now exits through Mullvad for every upstream query it makes. Its WebUI is on localhost:8080 and is also reachable from any other device on your Tailscale mesh, which means you get a private search frontend accessible from your phone or laptop without exposing it to the public internet. All DNS goes through the split DNS setup described above, so local overrides still work. One last step: if gluetun restarts, add tailbox-searxng to DEPENDENT_CONTAINERS in gluetun-watcher.sh so it gets force-recreated automatically and does not end up with a dangling namespace reference.

The same pattern works for anything else you want to hide behind Mullvad: a FreshRSS feed reader that fetches RSS without revealing your home IP, a headless Chromium container used for scraping, a Matrix or IRC bridge, a self-hosted Nitter or Invidious frontend, or even a second SOCKS5 proxy on a different port for a different use case. As far as gluetun is concerned they are all just more loopback listeners sharing tun0.

Kill Switches

There are two independent kill switches, one on each side.

Server: gluetun manages iptables/nftables in the shared namespace. Default policy is DROP on INPUT, OUTPUT, and FORWARD. Only tun0, loopback, ESTABLISHED/RELATED, and explicitly listed FIREWALL_INPUT_PORTS are allowed. If the WireGuard tunnel drops, all traffic stops.

Client (rootful mode only): when the client runs in rootful Podman mode, tailbox appends its own iptables rules inside the container after Tailscale connects. The rules only touch eth0 (the Podman bridge); traffic on lo and tailscale0 is not affected because no rule matches those interfaces and the chains stay on their default ACCEPT policy. On eth0:

  • ACCEPT ESTABLISHED/RELATED (return traffic)
  • ACCEPT specific UDP/TCP ports that Tailscale needs: WireGuard (41641), STUN (3478), control plane (443, 80), DNS (53)
  • DROP everything else outbound
  • ACCEPT ESTABLISHED/RELATED plus the proxy ports (1081 for socat, 8080 for the HTTP proxy) inbound
  • DROP everything else inbound

Net effect: once applied, the tailscale daemon inside the container can only talk to Tailscale infrastructure through eth0, and the host can only reach the container on the two proxy ports. The iptable_filter kernel module must be loaded before the container starts (tailbox handles this with modprobe).

The client defaults to rootless Podman, and in rootless mode this iptables kill switch does not run. The original line I wrote here was “the kill switch is skipped because Podman rootless containers lack the capability to set iptables rules”, which is technically correct but leaves out the two things that actually matter: whether there are workarounds, and whether skipping it creates a leak. Both questions deserve an answer.

Workarounds for Rootless iptables

The capability limitation is real but not absolute. A few things do work:

  • podman unshare --rootless-netns. You can enter the rootless container’s network namespace from the host and run iptables there, for example podman unshare --rootless-netns iptables -A OUTPUT -o eth0 -j DROP. The rules apply inside the rootless netns. The catch: the rootless netns is recreated on every container start, so you need a wrapper script to reapply rules after each start.
  • --cap-add=NET_ADMIN with pasta networking. On Podman 4.4 and newer, the pasta networking backend grants capabilities within the user namespace in a way that can let iptables modify the container’s own netfilter tables. This works if the relevant kernel modules are loaded on the host and the container image includes iptables userspace.
  • A sentinel approach that does not use iptables at all. A systemd user service, or a small supervisor inside the container, that polls tailscale status and kills socat the moment the daemon reports unhealthy. Reactive rather than preventive, but it reaches the same end state: no proxy, no traffic.

I did not ship any of these in the client, and the reason is not effort.

Why This Is Not a Leak

Look again at the socat forwarding chain from earlier in the post: there is no IP-level forwarding anywhere on that path. No default via tailscale0, no route injection on the host, nothing that binds an application socket to the tunnel at the kernel level. Each SOCKS connection spawns a tailscale nc subprocess that opens a TCP stream through the running tailscaled daemon. If tailscaled is stopped, crashed, or disconnected, tailscale nc fails immediately, socat closes the client connection, and the application gets ECONNRESET. There is no alternative path for the traffic to take. The proxy is fail-closed by construction, not by firewall rule.

What the iptables kill switch actually guards against is a narrower threat: a compromised tailscaled binary making outbound connections on eth0 to something other than Tailscale’s control plane or DERP. The image is pinned by @sha256: digest, so triggering that threat requires a supply-chain compromise of an already-pinned digest, and even then the kill switch whitelists 443, 80, 53, and 41641. Belt-and-suspenders hardening against a narrow threat, not leak prevention, and the rootful tax is not worth it for a tool whose whole point is lightweight ephemeral use.

If you want the kill switch anyway, set ROOTLESS=false in ~/tailscale-container/tailbox.conf. The server kill switch is independent and always active regardless of how the client runs.

Security Hardening

  • All container images pinned by @sha256: digest. Mutable tags like :latest are not used. Updates require explicitly changing the digest.
  • no-new-privileges and cap_drop: ALL on every container. Only NET_ADMIN (gluetun, tailscale), NET_RAW (tailscale), and NET_BIND_SERVICE (dnsmasq) are added back.
  • Read-only root filesystems with tmpfs for /tmp and /run. microsocks runs as nobody:65534 with zero capabilities.
  • WireGuard private key and Tailscale auth key are passed via Docker secret files mounted at /run/secrets/, never as environment variables (which would be visible in docker inspect).
  • autoheal talks to Docker through tecnativa/docker-socket-proxy, scoped to CONTAINERS, EVENTS, and POST (for restart). The raw docker.sock is never mounted.
  • Port 1080 (microsocks) is bound to 127.0.0.1, not 0.0.0.0. Remote access comes through the Tailscale tunnel. Without this, any device on the LAN could bypass Tailscale ACLs and use the proxy directly.
  • GitHub Actions workflow pins both actions by SHA256 commit hash, not just version tag.

Conclusion

The part I am most satisfied with is the routing fix. It is a subtle interaction between two programs’ policy routing rules that breaks TCP but not ICMP, and the symptoms give you almost nothing to go on. Figuring out that gluetun’s priority-101 rule was capturing Tailscale CGNAT return traffic required reading ip rule show output inside a namespace and tracing the packet path manually. That, and the socat architecture decision after the exit node approach failed, are the things in this project that required actually understanding how Linux networking works inside shared namespaces.

This is very much a work in progress. Next up is rewriting the client wrapper script in Go. The Bash version works, but a compiled binary would simplify distribution and error handling.

 

Matinen.com

Projects and documentation on things I find interesting, captured here so I don’t forget them. But mostly to keep myself from buying another domain name.

BTC
ETH
SOL
ADA
TRX
DOGE
TON
XMR

READ MORE ABOUT CHAT CONTROL 2.0
AND WHY IT HAS TO BE STOPPED


A general-purpose Mullvad exit gateway reachable over Tailscale: SOCKS5, split DNS, kill switches, and room for any number of sidecar services (SearXNG, FreshRSS, Matrix bridges, ...), with zero exposed ports.

2026-04-07