Skip to content

WireGuard / VPN

tunlx ships a first-class WireGuard integration. Bring your own config, or let tunlx fetch and rotate Mullvad configurations on a schedule.

Two modes

Mode Use when…
manual You already have a WireGuard config (e.g. corporate VPN, private peer).
provider You want tunlx to fetch and rotate configs from a supported provider.

Currently the only supported provider is Mullvad.

Manual mode

{
  "vpn": {
    "mode": "manual",
    "autoStartOnBoot": true,
    "manual": {
      "configPath": "/etc/wireguard/wg0.conf"
    }
  }
}
TUNLX_WG_IFACE=wg0 \
TUNLX_WG_CONF=/etc/wireguard/wg0.conf \
TUNLX_WG_AUTOSTART=true \
./tunlx -configFile=/etc/tunlx/config.json

What this gives you:

  • The WireGuard interface is brought up at start (or by the systemd unit when autoStartOnBoot is true).
  • Per-proxy route: vpn binds outbound sockets to the interface (SO_BINDTODEVICE), so only proxies that opt in are tunneled.
  • Set TUNLX_WG_MARK if you need a firewall mark (SO_MARK) for policy routing.

WireGuard configuration

Provider mode (Mullvad)

{
  "vpn": {
    "mode": "provider",
    "autoStartOnBoot": true,
    "provider": {
      "name": "mullvad",
      "refreshIntervalMinutes": 15,
      "mullvad": {
        "accountNumber": "1234567890123456",
        "country": "us",
        "city": "",
        "hostname": "",
        "ownedOnly": false
      }
    }
  }
}

A background scheduler (vpn_scheduler.go):

  1. Authenticates against Mullvad with your account number.
  2. Picks a server matching your filters (country, city, hostname, ownedOnly).
  3. Rewrites the WireGuard config and brings the interface up.
  4. Refreshes on the configured interval (default 15 minutes).
  5. Persists the last selected server snapshot back to config.json so restarts pick up where they left off.

Filter precedence

hostname is strongest, then city, then country. Changing country clears any stale city / hostname to avoid picking a server that no longer matches.

Per-proxy opt-in

Even with VPN configured, individual proxies decide whether to use it:

{
  "proxies": [
    { "name": "premiumA", "route": "vpn" },
    { "name": "publicB",  "route": "system" }
  ]
}

The direct mode (with sourceIP) is the inverse: bind to a specific local IP so policy routing can deliberately bypass the VPN for that proxy. Useful when one upstream requires your real IP.

Containerized routing

In Docker / Compose, WireGuard inside the container needs:

services:
  tunlx:
    cap_add:
      - NET_ADMIN
      - NET_RAW
    devices:
      - /dev/net/tun
    volumes:
      - /etc/wireguard:/etc/wireguard

If you'd rather isolate the WireGuard stack, run a sidecar container (e.g. gluetun) and put tunlx in its network namespace. There's a ready-made Compose variant under deployments/docker/.

Verification

# Inside the container or host
wg show

# Confirm the VPN egress IP differs from the host IP
docker exec tunlx curl -s https://api.ipify.org

# tunlx surfaces both IPs through admin endpoints:
curl -s http://localhost:6060/outbound-ip          # VPN-aware view
curl -s http://localhost:6060/outbound-ip/non-vpn  # System-route view

The dashboard shows both side-by-side in Activity & Telemetry.

Common pitfalls

  • No NET_ADMIN / NET_RAW. Interface stays down; wg show shows nothing inside the container.
  • Missing /dev/net/tun. WireGuard userspace fallback won't kick in on every host.
  • Double-hop confusion. Setting global outbound tsexit and per-proxy route: vpn sends traffic through both. Pick one per proxy.
  • MTU. Providers can advertise small MTUs; if HLS segments stall but ping works, suspect MTU first.