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

The configuration box shows dots in place of the private key and any preshared key. Show keys puts them in until you close the dialog, Copy copies the real file either way, and Save & restart with the dots untouched keeps the keys the file has. To change a key, press Show keys first.

Stopping or restarting the tunnel from the dashboard asks first, because every proxy on the vpn route fails until it is back up, and so does anything you reach tunlx through if you reach it over that tunnel.

The previous config is kept

Whenever tunlx replaces the WireGuard config (Save & restart, Use on a server from the provider lookup, or the scheduled refresh), the config it replaces is copied to wg0.conf.bak next to it first. Only the last one is kept, so copy it somewhere safe before applying a third.

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.

Provider mode needs the Mullvad CLI

The server list comes from the Mullvad command-line tool (mullvad relay list), so the Mullvad app, which provides the mullvad command and the daemon behind it, has to be installed on the host. The tunlx image does not contain it. With the repository's docker-compose.yaml, uncomment the two Mullvad lines under volumes: to share the host's CLI and its socket with the container (see what the baseline compose file grants). Without them a server lookup fails with "mullvad CLI binary not found".

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.