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:
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, notdocker-compose). - WSL2 backend on Windows. The Hyper-V backend doesn't expose
/dev/net/tunand 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:
PrivateKeyAddress
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
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 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.
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.
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 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:3080HTTP 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;localhostonly if they are sitting at the machine).- The
WEBUI_USER/WEBUI_PASSWORDthey 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 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 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:
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:
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 includes 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
- Generate a new WireGuard config in the Mullvad portal.
- Update
WIREGUARD_PRIVATE_KEY/WIREGUARD_ADDRESSESin.env. docker compose up -d --force-recreate gluetun.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.tools/stack.sh health, thencheck-ip.js. The exit IP should change.- Revoke the old key in the portal.
Don't
- Don't report setup complete on
docker compose psalone. 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 cachedlatestimage goes stale while npm's moves on, breaking browser launch on a schedule you don't control. - Don't remove
shm_sizefromplaywright-vpn— concurrent checks fail in ways that look like unreachable sites. - Don't
apt installinto the runningchromium-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-tunnelto make a failing check pass. It disables the one guardrail that stops this stack reporting results from the real IP.