---
name: vpn-egress-setup
description: "Stand up the vpn-egress Mullvad tunnel + browser stack from scratch, or repair one that won't start. Use when the stack has never been built on this machine, when gluetun won't come up or restart-loops, when Mullvad credentials need rotating, or when `docker exec playwright-vpn` fails because the container is missing or broken. NOT for running a check against a URL -- that is vpn-egress-testing, which assumes the stack already works."
---

# VPN Egress — Setup

Provisioning and repair. If the stack already runs, you want
`vpn-egress-testing` instead.

Repo: `projects/vpn-egress/`. Long-form: `docs/SETUP.md`,
`docs/TROUBLESHOOTING.md`.

## Is setup even needed?

Check before building anything:

```bash
cd projects/vpn-egress && bash tools/stack.sh health
```

Exit 0 with `"mullvad_exit_ip":true` — the stack is fine. Stop here and use
`vpn-egress-testing`.

Non-zero, or the container doesn't exist — continue.

## First-time provisioning

### 1. Prerequisites

- Docker with Compose v2 (`docker compose`, not `docker-compose`).
- **WSL2 backend on Windows.** The Hyper-V backend doesn't expose
  `/dev/net/tun` and gluetun cannot start. No workaround.
- A Mullvad account with time on it.

### 2. Mullvad key

This step needs the user — it requires their Mullvad login. Ask them to
generate a WireGuard config at <https://mullvad.net/en/account> → **WireGuard
configuration** and hand over two values from the `[Interface]` block:

- `PrivateKey`
- `Address`

Tell them the server they pick doesn't matter — the key authenticates to the
*account*, so gluetun works with any `SERVER_CITIES` value. That question
otherwise stalls the handoff.

Ignore the `[Peer]` block; gluetun picks a current server itself.

### 3. .env

```bash
cp .env.example .env
```

`WIREGUARD_PRIVATE_KEY`, `WIREGUARD_ADDRESSES` (keep the `/32`), plus
`WEBUI_USER` / `WEBUI_PASSWORD` for the GUI browser login.

**Never print the private key back in chat, a commit, a log, or a memory
write.** It bills to a real account. `.env` is gitignored; keep it that way.

### 4. Start and verify

```bash
bash tools/stack.sh up
```

Starts all three containers, waits for gluetun to be healthy, asserts egress
is via Mullvad. Non-zero exit means it is not — read the JSON it printed,
then `docs/TROUBLESHOOTING.md`.

### 5. Wait out the Playwright install

First start runs `npm install playwright@1.62.0` into a named volume. Later
starts are instant.

```bash
MSYS_NO_PATHCONV=1 docker exec playwright-vpn \
  node -e "console.log(require('playwright/package.json').version)"
```

Errors here usually mean the install is still running:
`docker exec playwright-vpn cat /tmp/npm-install.log`.

### 6. Prove it end to end

Both, in order. Setup is not done until both pass — "containers started" is
not the deliverable.

```bash
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-ip.js
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-url.js https://example.com
```

Expect `mullvad_exit_ip: true` from the first and `status=200 title="Example
Domain"` from the second. Report the actual exit IP, city, and server hostname
you got — not "the stack is up".

### 7. Install the skills

```bash
bash tools/install-skills.sh
```

Copies `skills/*` into `~/.claude/skills/`, backing up whatever it replaces.
The repo is authoritative; the installed copy is generated.

## Repair

| Symptom | First move |
| --- | --- |
| gluetun restart-loops | `tools/stack.sh logs`. Usually a bad `SERVER_CITIES` — it must be the exact string from gluetun's own list (`Denver CO`, not `Denver`), and gluetun prints every valid choice on a bad value. |
| gluetun won't start, no TUN | Docker Desktop is on Hyper-V. Switch to WSL2. |
| `"mullvad_exit_ip": false` | Account out of time, or the key was revoked in the portal. Ask the user to check <https://mullvad.net/en/account>. **Do not run checks in this state.** |
| `browserType.launch: Executable doesn't exist` | Image tag and npm version drifted. Both are in `docker-compose.yml`; set them equal, then `docker compose up -d --force-recreate playwright-vpn`. |
| `MODULE_NOT_FOUND` on a Windows-looking path | Missing `MSYS_NO_PATHCONV=1`. Not a container problem. |
| `df -h /dev/shm` shows 64M | `shm_size` didn't apply. Recreate the container, don't just restart it. |

Full detail: `docs/TROUBLESHOOTING.md`.

## Rotating credentials

1. Generate a new WireGuard config in the Mullvad portal.
2. Update `WIREGUARD_PRIVATE_KEY` / `WIREGUARD_ADDRESSES` in `.env`.
3. `docker compose up -d --force-recreate gluetun` — the attached browsers lose
   their network while gluetun restarts, which is expected.
4. `tools/stack.sh health`, then `check-ip.js`. The exit IP should change.
5. Revoke the old key in the portal.

## Don't

- Don't report setup complete on `docker compose ps` alone. A running
  container proves nothing about egress.
- Don't print, commit, or memorize the WireGuard private key.
- Don't set the Playwright image tag or npm version to `latest`. A cached
  `latest` image goes stale while npm's moves on, breaking browser launch on a
  schedule you don't control.
- Don't remove `shm_size` from `playwright-vpn` — concurrent checks fail in
  ways that look like unreachable sites.
