WSL CLAUDE + VPS EGRESS ARCHITECTURE REVIEW

Date: 2026-09-30. Mode: plan review only. Nothing was installed, edited, restarted or deleted.
One throwaway probe was run: claude -p with proxy env vars pointing at a dead loopback port (section 7). It changed no settings and wrote only to the scratchpad and normal session logs.

Limitation up front: this session runs on the VPS (Debian 12, uname shows no Microsoft kernel). I could not inspect WSL, Windows, the WSL-side SSH config, or the WSL Claude install. Every WSL item below is marked UNVERIFIED and is covered by P0 (re-run there).


1. Verdict

PASS_WITH_CHANGES

The topology is sound and the smallest reasonable way to get "Claude-only egress via VPS". Five changes are needed before implementation:

  1. Proxy variables in settings.json env will probably also reach the git/pnpm/curl commands Claude runs in its Bash tool. The docs do not say either way (verified "not found"). If they do, "git/pnpm stay on the local network" only holds for your own terminals, not for commands Claude spawns. This needs a P4 test and a NO_PROXY decision (section 8).
  2. WSL does not keep background agents alive. Claude's supervisor "outlives the terminal", but WSL's own idle shutdown can still kill it when no Windows-side session is open (section 10).
  3. Use a dedicated, restricted SSH key for the tunnel (section 6). The WSL box would otherwise hold a full-shell key to the production VPS.
  4. Re-check the VPS resource story. The VPS is at 92% disk and about 85 MB free RAM; the biggest consumers are Claude sessions, which supports the move (section 16).
  5. Add a launcher preflight. A dead proxy fails closed but slowly (about 90 s of retries with no output), so a fast port and egress check is a usability requirement, not decoration.

2. Verified Current Environment

VPS (this machine) - verified by command

Item Value
OS / kernel Debian 12, Linux 6.1.0-44-amd64, systemd 252 (running)
CPU / RAM 1 vCPU; 1965 MB total, ~85 MB free, ~409 MB available (rest is cache)
Disk / 20 GB, 18 GB used, 1.6 GB free (92%); /var/log 336 MB; journal 12.8 MB
Largest processes (RSS) three claude processes 341 + 235 + 231 MB (~0.8 GB total), two python ~120 MB each, node 90 MB, chrome-headless 86 MB
SSH OpenSSH 9.2p1; PasswordAuthentication no; PermitRootLogin prohibit-password; ClientAliveInterval 60, ClientAliveCountMax 200 (a dead client session can linger ~3.3 h); no AllowTcpForwarding/PermitOpen restriction (forwarding is allowed by default)
Listeners all loopback except 22, 80, 443. 8080 is taken (also 2019, 3001-3002, 4183, 4191, 5230, 8010-8020, 8441-8442, 20241). 18080, 18888, 8888, 3128 are free
Proxy packages installed none (tinyproxy, privoxy, squid, autossh all absent)
Firewall nft and ufw not present or empty output; not verified either way
Direct egress IP 64.186.237.76 (the value the VPS proxy should show in P3)
Claude user linger Linger=no (matters only if a VPS-side user unit is ever used)

Claude Code (VPS install) - verified

WSL / Windows - UNVERIFIED (not reachable from here)

Needs P0 on the WSL side: wsl --version, /etc/wsl.conf ([boot] systemd=true?), .wslconfig (networkingMode, autoProxy, instanceIdleTimeout, vmIdleTimeout), WSL-side Claude version and settings, ~/.ssh/config (any ControlMaster), free port 18080, any inherited *_PROXY variables.

Remote Dev repo (VPS) - verified

/home/claude/dev/remote-dev: HEAD 697d72cc3de4aa95a382d5e16c1d16f1b0921c92, tag m1-vps-ready points to the same commit, branch main, 2 commits, working tree clean, no git remote configured, .git 864 KB, working dir 72 MB (71 MB is node_modules, ignored). Only .env.example is tracked; .env and .env.* are gitignored. AGENTS.md and docs/DECISIONS.md exist.


3. Corrected Architecture

                      Private Git remote (canonical)
                         ^ push               ^ clone/pull
                         |                    |
   VPS checkout (fallback, kept) ----push---> [remote] <---- WSL workspace
                                                                |
        +-----------------------------+-----------------------+-+---------------+
        |                             |                       |                 |
   Claude Code (primary)         Codex (optional)        local tools       Windows
   env: HTTPS_PROXY=             direct network          build/test        Android
        http://127.0.0.1:18080                                              (native)
        NO_PROXY=localhost,127.0.0.1,::1
        |
        v  (only Claude-process traffic; incl. its spawned children unless proven otherwise)
   127.0.0.1:18080   (WSL, loopback only)
        |
        |  ssh -N -L 127.0.0.1:18080:127.0.0.1:18888   (dedicated restricted key)
        v
   VPS 127.0.0.1:18888   tinyproxy, CONNECT to port 443 only, loopback only
        |
        v
   Claude / Anthropic hosts (+ optional: plugins, GitHub, telemetry)

   Unchanged direct-from-WSL: git, pnpm/npm, apt, browser, Codex, other apps.
   Browser OAuth on Windows: not proxied (see section 9).

Fail-closed rests on one fact: when 127.0.0.1:18080 has no listener, the connection is refused and Claude has no other path.


4. Network Path (per component)

Component Path Status
Foreground Claude Reads HTTPS_PROXY/HTTP_PROXY/NO_PROXY (lowercase variants also; order https_proxy, HTTPS_PROXY, http_proxy, HTTP_PROXY) from process env or the settings.json env block. Docs: no SOCKS. Startup only validates that the proxy URL parses. Docs verified; P4 confirms
Background Claude Hosted by a per-user supervisor that starts on demand and outlives the shell. It inherits the env of whichever shell cold-started it, or no shell env if OS-installed. Docs: shell exports reach it only by luck, so put vars in settings.json env. Sessions re-read settings when they start; already-running sessions keep old env. Docs verified; P6 confirms
Local tools (git, pnpm, apt, curl, browser, Codex) Direct, in your own shells. Caveat: commands Claude runs through its Bash tool, hooks and stdio MCP servers are its children. The docs do not state whether env from settings is inherited by them (searched: not found). Assume yes until P4 proves otherwise. Open risk
Browser / OAuth Windows browser is outside WSL env, so it goes direct (section 9). Verified by design
MCP / localhost Docs: Claude never sends its WebSocket connections to localhost, ::1, 127.0.0.0/8 through the proxy. That covers Claude's own sockets only. Curl/Node/Playwright children calling localhost need NO_PROXY. Remote MCP (claude.ai connectors via mcp-proxy.anthropic.com) goes through the proxy. Docs verified for WebSocket only
Telemetry / updates / plugins Same process, same proxy (probe showed Datadog telemetry and bootstrap calls all trying the proxy). Hosts differ by feature: see section 15. Probe verified

5. Proxy Recommendation

MVP choice: tinyproxy (Debian 12 package 1.11.1-2.1+deb12u1).

Option Disk RAM Config CONNECT Logging Verdict
tinyproxy apt-get -s install adds exactly 2 packages (tinyproxy, tinyproxy-bin); apt metadata shows well under 1 MB installed (exact figure to confirm in P1) est. 1-3 MB per process (estimate, not measured) ~5 directives yes, ConnectPort allowlist metadata only, no bodies; level configurable Recommended
privoxy ~2.3 MB est. a few MB filter-oriented, more knobs than needed yes ok works, oversized for the job
squid ~8.5 MB + cache dirs tens of MB large yes verbose, cache dirs reject: wrong tool, disk-tight VPS
ssh -D (SOCKS) 0 0 trivial n/a none not usable, Claude Code has no SOCKS support
HTTP-to-SOCKS bridge in WSL over ssh -D 0 on VPS small, on WSL second daemon on WSL yes n/a valid fallback if you refuse any VPS install; adds a moving part
custom Python CONNECT script 0 ~20 MB you own the security code yes you write it reject
Caddy (already running, admin on 2019) 0 0 needs forward_proxy plugin, i.e. a custom build n/a n/a reject

Expected tinyproxy config (conceptual): Listen 127.0.0.1, Port 18888, Allow 127.0.0.1, ConnectPort 443, LogLevel Warning, low worker counts (check the shipped tinyproxy.conf for the 1.11 directive names), MaxClients around 20. Complexity: low.

Port note: pick 18888. Port 8080, tinyproxy-adjacent habit, is already used on this VPS.


6. SSH Tunnel Design (conceptual, not to be applied)

WSL side command shape:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
  -o BatchMode=yes \
  -o StrictHostKeyChecking=yes \
  -o IdentitiesOnly=yes -i <dedicated-key> \
  -o ControlMaster=no -o ControlPath=none \
  -L 127.0.0.1:18080:127.0.0.1:18888 \
  <vps-alias>

7. Fail-Closed Design

Why direct fallback should not happen: with HTTPS_PROXY set, Claude's HTTP agent connects to the proxy address. If nothing listens there the connection is refused; no documented code path retries directly.

Evidence gathered (VPS, actual binary 2.1.285): with HTTPS_PROXY=HTTP_PROXY=http://127.0.0.1:18999 (no listener), claude -p "say ok" produced no output and was killed by my 90 s timeout (exit 124). The debug log shows connect ECONNREFUSED 127.0.0.1:18999 for bootstrap, org fast-mode status, MCP registry, Grove settings, claude.ai MCP fetch (retried 3x), and the final Datadog log flush; 1P telemetry logged "3 failure(s) this session". No request succeeded. Lines matching "direct|fallback" were unrelated (filesystem and CA-config text).

What that does and does not prove: it proves no observed request bypassed the proxy in one run. It is not a packet-level proof. P5 closes that gap by adding a host-level check (see P5).

Practical consequence: fail-closed is slow (retry loop, ~90 s+, no message in -p mode). Hence the launcher preflight.

Ways fail-closed could be defeated (to test or avoid):


8. Claude Settings Design

Layer What it does Needed when
Shell env (claude-vps exports) Guards the foreground process you launch. Read once at startup. Cannot reliably reach the supervisor. P4 (foreground PoC), then optional
~/.claude/settings.json env Only mechanism that reaches every background session (docs). Applied at session start; restart needed for already-running sessions. P6 onward, and for permanent use
processWrapper Wraps the supervisor, its workers, and self-spawns with a launcher (must end exec "$@"; documented for sandbox/network-control/credential injection). Needs claude daemon stop --any to take effect. Ignored on native Windows. Not needed for M1. It is for policies that require every process to start via a wrapper.
Background supervisor Currently not running here. On WSL, run claude daemon status at P0. After changing env: claude daemon stop --any so the next claude agents/--bg starts fresh. Auto-update also restarts it. P6

Proposed future env block (merge minimally into the existing file; never overwrite it; the current file has no env key, so add only this key):

"env": {
  "HTTPS_PROXY": "http://127.0.0.1:18080",
  "HTTP_PROXY":  "http://127.0.0.1:18080",
  "NO_PROXY":    "localhost,127.0.0.1,::1"
}

9. OAuth / Authentication Boundary


10. WSL Lifecycle

Verified from Microsoft docs (updated 2026): "systemd services will NOT keep your WSL instance alive." .wslconfig has instanceIdleTimeout (default 15000 ms, -1 disables auto shutdown) and vmIdleTimeout (default 60000 ms, Windows 11). wsl --shutdown stops all distros. I did not verify these on your machine.

Event What happens Recovery
Normal Tunnel up; launcher check passes n/a
Tunnel crash / kill Port 18080 closes; Claude requests fail closed (slowly); in-flight streams abort Stage A: rerun by hand. Stage B: systemd restarts in ~5 s
VPS proxy stopped Tunnel stays up but forwards nowhere; Claude fails (connection reset), launcher egress check fails Start tinyproxy
wsl --shutdown All WSL processes die, including tunnel, Claude and background agents Start WSL; user service restarts tunnel (needs linger or system unit); launcher re-verifies
Windows sleep VM suspended; nothing runs. Nothing can keep developing during sleep On wake, stale TCP is detected by ServerAlive (~45 s), ssh exits, systemd restarts; in-flight Claude requests fail and retry
All terminals closed, no Windows session WSL may idle-stop even with systemd enabled, taking the supervisor and background agents with it Keep a wsl.exe session open (Windows Task Scheduler at logon, running sleep infinity), or set instanceIdleTimeout=-1 (a Windows-side .wslconfig change, your decision)

11. PoC Plan

Every stage stops at its pass criteria; nothing proceeds on "it seems to work".

P0 - Read-only audit (WSL + VPS)

P1 - Manual VPS localhost proxy (needs approval; touches production VPS)

P2 - Manual SSH local forward

P3 - Generic HTTP proxy verification

P4 - Foreground Claude Code

P5 - Fail-closed

P6 - Background agent

P7 - WSL lifecycle

P8 - Git migration

See section 12. Pass: WSL clone at tag m1-vps-ready, existing light and full scripts pass on WSL.

P9 - Development trial


12. Migration Plan (Remote Dev M1)

Order matters; the VPS checkout stays untouched throughout.

  1. Now (no network PoC needed): create an empty private remote (any host you already use; this is a "decision needed" item). Confirm .gitignore covers .env* (verified) and that no secrets are in history (2 commits; run a quick scan on the tracked file list, only .env.example is present).
  2. On the VPS: git remote add origin <url>; git push origin main; git push origin m1-vps-ready. Never --force.
  3. Confirm git ls-remote origin shows 697d72cc... for main and the tag.
  4. On WSL: git clone <url>; git checkout m1-vps-ready (detached) to verify identical commit 697d72cc3de4aa95a382d5e16c1d16f1b0921c92, then back to main.
  5. Set DEV_ENV=wsl; run the existing light and full scripts; pnpm install etc. happen on WSL, not the VPS.
  6. Keep the VPS checkout as fallback until P9 passes; from then on WSL is where new commits are made and the VPS pulls only when deploying.
  7. .env files and Remote Dev credentials are not in git: recreate them on WSL deliberately (do not copy from the VPS blindly).

Note: this migration does not depend on the proxy, so it can run in parallel with P0-P3. The instruction to do it only after the network PoC is a conservative ordering, not a technical dependency; your call.


13. Revised Hybrid Development Standard

Task WSL Claude WSL Codex VPS Windows
Architecture / design / DECISIONS.md primary second opinion no no
Coding + debugging primary mechanical tasks / fallback no (fallback checkout only) no
Light checks (lint, unit) yes yes yes (light only) no
Full build / integration / Playwright / Runner tests yes verifier no (refused by guard) no
Git canonical remote push/pull yes yes pull for deploy no
Production runtime, deploy, backup no no yes no
Claude network egress client n/a tiny proxy n/a
Android SDK / emulator / native GUI no no no yes

Unchanged from the existing standard: Git remote is canonical; AGENTS.md; docs/DECISIONS.md; check/test/build scripts; production isolation; agent replaceability; VPS/WSL guards.


14. Impact on Remote Dev

Only what this topology changes:


15. Security Risks

HIGH

MEDIUM

LOW


16. Resource Impact

OLD: Claude on VPS NEW: Claude on WSL + tinyproxy on VPS
VPS RAM each Claude session ~230-340 MB RSS (three observed now, ~0.8 GB of 1.97 GB), plus node/chrome/tooling for tests tinyproxy est. 1-3 MB per process (not measured) + sshd per tunnel connection
VPS disk Claude data (~/.claude incl. sessions, debug, file history, plugins), 71 MB node_modules, dev tooling; currently 1.6 GB free tinyproxy under ~1 MB installed + small logs; and dev checkout/caches can later be pruned (only after fallback is retired; not recommended yet)
VPS CPU 1 vCPU shared by dev sessions and production negligible
Failure mode OOM/disk-full risk at the current 92% dependency on WSL uptime and a tunnel

Estimated saving: the roughly 0.6-0.8 GB RAM currently used by Claude sessions, and freedom to reclaim disk once the fallback is retired. The increment is a few MB. These are estimates from this snapshot; P1 and P9 give measured numbers.

Also relevant: the VPS already runs a 92%-full disk, so any P1 install should be preceded by checking df and clearing nothing without asking.


17. What Not To Build

WireGuard, Tailscale exit node, full-WSL VPN, transparent iptables/nftables interception, Docker or Kubernetes proxy stacks, LLM gateways, PKI/mTLS, multi-hop proxies, Windows-wide proxy settings, processWrapper for M1, autossh, proxy authentication for the MVP, browser proxying, a hostname allowlist for Claude endpoints, a custom proxy daemon, Squid.


18. Decisions Needed From User

Only items I cannot learn by inspection:

  1. Which private Git host for the remote (and its URL).
  2. Are you OK approving P1 on the production VPS (one small package; disk is at 92%)?
  3. WSL keep-alive: accept manual open-terminal discipline, or allow a Windows-side change (instanceIdleTimeout=-1 in .wslconfig or a logon task)?
  4. If P4 shows proxy env leaking into Claude's Bash children: prefer NO_PROXY-only, CLAUDE_CODE_SHELL_PREFIX to unset, or accept it?
  5. Do you accept that browser sign-in stays on your local IP (section 9)?
  6. Whether you are comfortable hosting the M1 history (2 commits, no secrets tracked) on the chosen third-party Git host.

19. Recommended First Implementation Prompt (do not run yet)

Task: WSL + VPS-egress PoC, stages P0-P3 only. Work in stages; stop and report after each; do not touch Remote Dev source, Git remotes, or Claude settings.json.

  1. P0 (read-only, on WSL): report wsl --version, /etc/wsl.conf, .wslconfig (idle timeouts, networking mode, autoProxy), claude --version, redacted settings keys, claude daemon status, env | grep -i proxy, ~/.ssh/config (ControlMaster), port 18080 usage, nproc/free/df. On the VPS, additionally confirm firewall state as root.
  2. P1 (needs my explicit "go"): on the VPS, check df, then install tinyproxy, configure Listen 127.0.0.1, Port 18888, Allow 127.0.0.1, ConnectPort 443, LogLevel Warning; verify loopback-only with ss and an external port probe; record RSS and disk delta; show the exact rollback command.
  3. P2: create a dedicated ed25519 key on WSL; show me the authorized_keys line with restrict,port-forwarding,permitopen="127.0.0.1:18888" and wait for approval before adding it; pin the host key; run the tunnel manually in the foreground with the option set in section 6; prove the key cannot get a shell or forward elsewhere.
  4. P3: compare curl direct versus via 127.0.0.1:18080; expected proxied IP 64.186.237.76; confirm git/pnpm without proxy variables are unaffected.
    Rules: no systemd units, no settings.json edits, no launcher script yet, no browser proxying; each stage ends with PASS/FAIL against explicit criteria and a rollback note.