# ADR 020: the media stack waits for the VPN tunnel

- HTML version: https://robbiepalmer.me/projects/homelab/adrs/020-vpn-gated-stack
- Project: Home Lab (https://robbiepalmer.me/projects/homelab.md)
- Status: Accepted
- Date: 2026-08-23

# Context

The acquisition stack talks to public trackers from the hub, and that traffic
should never ride the residential line — not during normal operation, and
especially not during the boot window. The hub runs a system-wide VPN app,
but it boots asynchronously alongside everything else: launchd agents bring
up Colima and the media containers immediately at login, while the VPN
client reconnects on its own schedule. After a power cut, the compose stack
could plausibly come up seconds before the tunnel does, and the torrent
client would spend that gap on the physical interface. Nothing would log it.

# Decision

Both entry points to the stack now **refuse to run unless the hub's default
route resolves through a tunnel interface** (`utun*` on macOS):

* `keep-running.sh` checks every cycle and holds the containers back until
  the tunnel appears (the VM itself still starts; it leaks nothing).
* `provision.sh` fails fast with an explanatory error, since provisioning
  validates indexers and runs live searches against public trackers.

The check is deliberately provider-agnostic: any WireGuard or OpenVPN client
that takes the default route satisfies it, so swapping the current consumer
VPN app for something more container-friendly later requires no script
changes.

# Alternatives

## Gluetun sidecar

* **Pros**: The thorough fix. qBittorrent's only network path becomes the
  tunnel namespace; a mid-session VPN drop leaves the container with no
  connectivity instead of silently falling back.
* **Cons**: Requires a provider that publishes WireGuard/OpenVPN configs;
  the current consumer VPN app does not. Adds one more container and one
  more failure mode for Jellyfin playback if tuned badly.
* **Decision**: Deferred. Revisit when the current VPN subscription is
  replaced with a config-friendly provider — the gate here is designed to
  make that swap trivial.

## Do nothing

* **Decision**: Rejected. The leak window is silent and lands exactly when
  nobody is watching: unattended reboots.

# Consequences

### Pros

* The boot race is closed: no tunnel, no stack, logged each cycle it waits.
* Zero new containers; works with any default-route-taking VPN client.
* Swapping VPN providers later is purely an app-level change.

### Cons

* The gate only covers *startup*. A VPN drop mid-session does not stop
  already-running containers — closing that needs the gluetun approach.
* A Tailscale exit node also satisfies the gate. Egress still leaves the
  LAN, but through another tailnet device; worth remembering when reading
  logs.
* Debugging "why is nothing running" after a reboot now starts at the VPN —
  the hold message names this ADR so future-you finds it fast.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
