# Vpn Egress Setup

> 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.

- Skill: `therocksss/vpn-egress-setup` (Agent Skill)
- Install (CLI): `npx skillmds@latest add therocksss/vpn-egress-setup`
- Raw SKILL.md: https://api.skillmd.com/api/skills/therocksss/vpn-egress-setup/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: THEROCKSSS (https://skillmd.com/u/therocksss)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/therocksss/vpn-egress-setup

---


# 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.
- **~10 GB of disk and a few minutes for the first build.** The GUI browser
  image is built here rather than pulled (it adds Brave and Firefox to the
  LinuxServer Chromium base) and lands around 5.6 GB. Tell the user this
  before starting — an unexplained multi-minute build reads like a hang.

### 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.

`WEBUI_PASSWORD` is different: it is the user's own login and they will need
it, so it is fine to tell *them* what it is. Note that gluetun publishes the
UI on `0.0.0.0`, so it is reachable from the whole LAN — say so if they pick
something weak.

`VPN_EXIT_CITY` is optional and defaults to `Denver CO`. Leave it blank unless
they want a specific country; it is changed later with `tools/exit.sh`, not by
editing compose.

### 4. Start and verify

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

Builds the GUI browser image on first run (several minutes — see
prerequisites), starts all three containers, waits for gluetun to be healthy,
and 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".

`check-url.js` confirms the tunnel itself before checking anything, so a
successful run is also proof the gate works. Its envelope carries a `tunnel`
field; if it instead prints `refusing to check` and exits 2, the tunnel is the
problem, not the URL.

Then run the test suite, which needs neither the tunnel nor an account:

```bash
bash tools/test.sh          # throwaway container; --host uses your own Node
```

47 tests covering argument parsing, error classification, the JSON envelope,
and every branch of the tunnel gate. It prints which tiers it **skipped** —
read that line, because a skipped tier and a passing tier otherwise look
identical. `--live` adds a real check through the real stack.
→ `docs/TESTING.md`

### 7. Hand over the GUI

Setup isn't finished until the human can actually open it. Give them:

- `https://<host>:3081` — **HTTPS**, not the `:3080` HTTP port, which would
  send their password in the clear. Use the address they will actually reach it
  from (a tailnet name or LAN IP if they browse from a phone; `localhost` only
  if they are sitting at the machine).
- The `WEBUI_USER` / `WEBUI_PASSWORD` they chose.
- A warning that the certificate is self-signed, so the browser will complain
  once. That is expected, not a misconfiguration.

Tell them what they'll see: Chromium opens on a tunnel status page showing the
live exit IP and a plain on/off-tunnel verdict, and **right-clicking the
desktop lists every installed browser** — Chromium, Brave, Firefox ESR, with
terminals in a submenu.

If they want their own domains listed as links on that page, bookmark them:

```bash
bash tools/bookmark.sh add <url>
```

The same list drives the sweep, so a bookmark is checked from outside too.
→ the `duckdns-url-bookmark` skill.

### 8. 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.

## Adding another browser

Common request once someone has their own instance. Two rules:

**Edit `docker/chromium/Dockerfile`, never `apt install` inside the running
container.** A hand install works immediately and then disappears on the next
rebuild or image pull, silently. Add the package (plus its apt source, if it
isn't in Debian) to the Dockerfile, then:

```bash
docker compose build chromium-vpn && docker compose up -d --force-recreate chromium-vpn
```

**Chromium-family browsers need a wrapper.** They exit instantly without
`--no-sandbox` here, and the desktop menu discards stderr, so the only symptom
is a menu entry that does nothing. Copy `docker/chromium/bin/wrapped-brave`,
and symlink it to match **the basename of the binary in the `.desktop` file's
`Exec=` line** — not the friendly name. Check what that actually is:

```bash
docker exec chromium-vpn grep -m1 ^Exec= /usr/share/applications/<name>.desktop
```

Nothing else is needed: the menu regenerates from `/usr/share/applications` at
every container start, so the new browser appears on its own.

## Repair

| Symptom | First move |
| --- | --- |
| gluetun restart-loops | `tools/stack.sh logs`. Usually a bad exit city — 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. Set it with `tools/exit.sh`, which validates by waiting for health. |
| Browsers can't reach anything after a gluetun restart | They share gluetun's network namespace and are stale, not broken: `docker compose up -d --force-recreate chromium-vpn playwright-vpn`. |
| A browser you installed isn't on the right-click menu | The menu regenerates at container *start*: `docker compose restart chromium-vpn`. If it's still absent, the `.desktop` entry is `NoDisplay=true`, or the install was done by hand in a running container and didn't survive. |
| Menu edits keep reverting | Expected — the image restores `menu.xml` from its own `.bak` on every start. Don't hand-edit; change the `.desktop` files or the generator, which writes both. |
| A menu entry launches nothing | Missing or misnamed wrapper — see "Adding another browser" above. |
| Firefox: `Running Firefox as root … is not supported` | You used `docker exec` without `-u abc`. `$HOME` is `/config`, owned by `abc`. Also `chown -R abc:abc` anything root already created there. |
| Firefox: `CanCreateUserNamespace() clone() failure: EPERM` | Harmless. Docker's seccomp profile blocks userns; Firefox falls back to a weaker sandbox and browses fine. Do not relax seccomp to silence it. |
| Status page won't load in the GUI | `docker exec chromium-vpn curl -s localhost:8391/api/status`. It binds loopback inside gluetun's namespace by design — it is not, and should not be, reachable from outside. |
| `Conflict. The container name "/gluetun-vpn" is already in use` | Another compose project owns the stack (an umbrella file that `include`s this repo). Don't delete anything — `tools/stack.sh` and `tools/exit.sh` detect the owner via `tools/lib/compose.sh`; for raw commands, pass `-f` pointing at that project's file. |
| 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`.
4. **`docker compose up -d --force-recreate chromium-vpn playwright-vpn`.** Not
   optional and easy to skip: the browsers share gluetun's network namespace,
   so recreating gluetun leaves them attached to one that no longer exists.
   They come back running, apparently healthy, and unable to reach anything.
5. `tools/stack.sh health`, then `check-ip.js`. The exit IP should change.
6. 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.
- Don't `apt install` into the running `chromium-vpn`. It works until the next
  rebuild and then vanishes without an error. The Dockerfile is the only
  durable place.
- Don't hand-edit `/config/.config/labwc/menu.xml`. The image overwrites it
  from its own backup at every start.
- Don't add `--allow-off-tunnel` to make a failing check pass. It disables the
  one guardrail that stops this stack reporting results from the real IP.

