hardware/ubuntu-wsl/services/pi-web.md
Table of Contents
pi-web (WSL) โ autostart, port 30142
A second, independent Pi Web instance that runs inside the WSL Ubuntu distro and
uses the WSL install of @agegr/pi-web and the WSL pi session data (/root/.pi). It is
separate from the Windows-native instance on port 30141, which is documented in
../../windows/pi-web-autostart.md.
Set up: 2026-09-20 ยท Host: Nuk ยท Distro: Ubuntu ยท Domain: pi-wsl.dph.am
| Windows instance | WSL instance (this doc) | |
|---|---|---|
| Runtime | Windows node (nvm-windows) |
WSL node (nvm, /root/.nvm) |
| Binary | C:\Program Files\nodejs\pi-web.cmd |
/root/.nvm/.../bin/pi-web (via wrapper) |
| Port | 30141 |
30142 |
| Host allowlist | pi-win.dph.am |
pi-wsl.dph.am |
| pi data | Windows ~/.pi |
/root/.pi |
| Autostart | Task Scheduler \pi-web |
systemd pi-web.service + Task Scheduler \pi-web-wsl |
| Doc | ../../windows/pi-web-autostart.md |
this file |
1. What happens at logon
Because WSL does not start on its own, autostart is two layers:
- Windows Task Scheduler fires
pi-web-wsl45 seconds afterNUK\dominlogs on. - That task runs a hidden PowerShell process (
autostart.ps1), which boots theUbuntudistro (wsl -d Ubuntu -u root --exec /bin/true). - Booting the distro starts systemd, which starts the enabled
pi-web.service. The wrapper also runssystemctl start pi-web.serviceexplicitly, as a no-op when it is already up. - The wrapper polls
http://127.0.0.1:30142/for up to 60 s and logs the result.
Result: shortly after logon, Pi Web (WSL) answers on 0.0.0.0:30142 inside the distro,
on 127.0.0.1:30142 on Windows, and โ via the Windows portproxy โ on
http://192.168.1.153:30142/. The browser is never opened.
2. WSL side
Files
| Path | Purpose |
|---|---|
/etc/systemd/system/pi-web.service |
The unit. Configuration (env vars, port) lives here. |
/usr/local/bin/pi-web-wsl |
Wrapper that resolves the newest nvm Node and execs its pi-web, so nvm install / nvm alias default do not break the service. |
Unit (pi-web.service)
[Unit]
Description=pi-web (WSL) - Pi coding agent web UI
After=network.target
[Service]
Type=simple
WorkingDirectory=/root
Environment=PI_WEB_ALLOWED_HOSTS=pi-wsl.dph.am
Environment=PI_WEB_NO_OPEN=1
Environment=PI_WEB_HOSTNAME=0.0.0.0
Environment=PORT=30142
ExecStart=/usr/local/bin/pi-web-wsl -H 0.0.0.0 -p 30142 --no-open
Restart=always
RestartSec=3
TimeoutStopSec=15
KillMode=mixed
[Install]
WantedBy=multi-user.target
pi-web forwards SIGINT/SIGTERM to its Next.js child, so the default KillSignal
plus TimeoutStopSec=15 gives a clean shutdown.
Environment variables
| Variable | Value | Meaning |
|---|---|---|
PI_WEB_ALLOWED_HOSTS |
pi-wsl.dph.am |
Exact hostnames accepted (the DNS name used to reach it). |
PI_WEB_NO_OPEN |
1 |
Never open a browser. |
PI_WEB_HOSTNAME / PORT |
0.0.0.0 / 30142 |
Bind defaults (the CLI flags also set these). |
Commented-out in the unit:
| Variable | Effect |
|---|---|
PI_WEB_PASSWORD |
Require a password for browser logins (HTTP Basic Auth, user pi). Recommended โ it is bound to 0.0.0.0. |
PI_WEB_IDLE_TIMEOUT_MS |
0 disables the 10-minute idle shutdown. |
Enabling / disabling autostart (inside WSL)
systemctl enable --now pi-web # enable + start (already done)
systemctl disable pi-web # stop autostart
systemctl status pi-web
journalctl -u pi-web -f # live logs (journald)
systemctl restart pi-web # after editing the unit
3. Windows side
Files
All live in %LOCALAPPDATA%\pi-web-wsl, i.e. C:\Users\domin\AppData\Local\pi-web-wsl.
| File | Purpose |
|---|---|
autostart.ps1 |
Logon wrapper. Boots the distro, starts the unit, waits for HTTP 200, logs. |
install-autostart.ps1 |
Registers/refreshes the pi-web-wsl scheduled task. Idempotent, no admin needed. |
setup-network.ps1 |
Creates the firewall rule + portproxy (needs an elevated shell). Idempotent. |
logs\pi-web-wsl.log |
Runtime log (appended, UTF-8). |
Gotcha (hit during setup): run via WSL interop,
$env:USERDOMAINisWORKGROUP, which does not resolve to a SID and makesRegister-ScheduledTaskfail with "No mapping between account names and security IDs was done." The installer uses[Security.Principal.WindowsIdentity]::GetCurrent().Name(โNUK\domin) instead.
Scheduled task pi-web-wsl
| Field | Value |
|---|---|
| Trigger | At logon of NUK\domin, delay PT45S |
| Action | C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe |
| Arguments | -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\Users\domin\AppData\Local\pi-web-wsl\autostart.ps1" |
| Principal | NUK\domin, logon type Interactive, run level Limited (no elevation) |
| Execution time limit | PT0S (unlimited) |
| Multiple instances | IgnoreNew |
| Start when available | Enabled |
| Restart on failure | 3 attempts, 1 minute apart |
install-autostart.ps1 is the source of truth โ edit and re-run rather than hand-tweaking
the task.
4. Networking
Changed 2026-09-21: the whole box switched from NAT to mirrored networking (
C:\Users\domin\.wslconfig->networkingMode=mirrored). The NAT +portproxysetup described below (kept for history) is retired for pi-web -- the stale portproxy rule was deleted because it now conflicts (EADDRINUSE) with WSL binding0.0.0.0:30142directly on the shared interface. Current behavior:
| Reachable from | Address | How |
|---|---|---|
| WSL itself | http://127.0.0.1:30142/ and http://192.168.1.153:30142/ |
direct listener on the shared interface |
| Windows | http://localhost:30142/ and http://192.168.1.153:30142/ |
same interface, mirrored -- no relay needed |
| LAN | http://192.168.1.153:30142/ |
ordinary Windows Firewall allow rule (Hyper-V firewall also permits it -- the pre-existing pi-web-wsl (TCP 30142) rule shows up in Get-NetFirewallHyperVRule too, confirmed 2026-09-21) |
| Public | https://pi-wsl.dph.am/ |
nginx reverse proxy -> 192.168.1.153:30142 (unchanged) |
No netsh portproxy rule exists or is needed for 30142 anymore. If one ever
reappears (e.g. from re-running the old setup-network.ps1), delete it -- it will
fight with WSL's own bind and crash-loop the service:
netsh interface portproxy delete v4tov4 listenport=30142 listenaddress=0.0.0.0
The New-NetFirewallRule from setup-network.ps1 (below) is still valid/needed under
mirrored mode -- only the portproxy half of that script is now obsolete for this
service.
Windows firewall (still needed; portproxy below is historical/NAT-only)
New-NetFirewallRule -DisplayName "pi-web-wsl (TCP 30142)" `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 30142 -Profile Private
<details> <summary>Historical: NAT-mode portproxy (retired 2026-09-21, do not re-add)</summary>
netsh interface portproxy add v4tov4 `
listenaddress=0.0.0.0 listenport=30142 connectaddress=127.0.0.1 connectport=30142
The portproxy pointed at loopback, not the WSL IP, because WSL's IP changed on every
reboot under NAT (192.168.119.217 that day, something else the next). Loopback was
stable: WSL's localhost forwarding took it from there. This was the reason NAT +
portproxy was originally chosen over mirrored networking for this service -- superseded
once the whole box moved to mirrored mode, which gives WSL a stable, shared LAN address
instead.
</details>
Reverse proxy / DNS (handled manually)
pi-wsl.dph.am is reverse-proxied directly to 192.168.1.153:30142 โ the Windows
box โ rather than being a router port-forward of 30142. So the public entry point is the
existing *.dph.am nginx ingress, not a new router rule.
- DNS: the
*.dph.amwildcard already points at the home public IP; no per-subdomain record is needed. - nginx: add a
server_name pi-wsl.dph.amsite withproxy_pass http://192.168.1.153:30142;(a LAN-IP upstream, unlike the Mac-local backends in../../../services/dph-am-stack.md). Standardcertbot --nginxgives it TLS, and theauth_requestlogin wall can be layered on as usual. pi-win.dph.am(โ192.168.1.153:30141) is the working precedent for this shape; it also carries a PWA manifest/icon, listed in../../mac-studio/services/nginx-pwa-injection.md. Add api-wsl/entry there if this one gets the same treatment.- No router port-forward for 30142 is needed. Only the existing 80/443 ingress.
- The Windows
portproxy+ firewall rule are still required: nginx reaches the Windows host over the LAN at192.168.1.153:30142, which is exactly what those expose. - nginx forwards the external hostname, so
PI_WEB_ALLOWED_HOSTS=pi-wsl.dph.amis what lets the request through โ keep it in sync with the nginxserver_name.
5. Common operations
# Windows task
Get-ScheduledTask -TaskName pi-web-wsl | Select-Object TaskName, State
Get-ScheduledTaskInfo -TaskName pi-web-wsl | Select-Object LastRunTime, LastTaskResult
Start-ScheduledTask -TaskName pi-web-wsl
Get-Content "$env:LOCALAPPDATA\pi-web-wsl\logs\pi-web-wsl.log" -Encoding UTF8 -Wait
Disable-ScheduledTask -TaskName pi-web-wsl
Unregister-ScheduledTask -TaskName pi-web-wsl -Confirm:$false
# WSL service
systemctl status pi-web
journalctl -u pi-web -f
systemctl restart pi-web
# What is listening on 30142 on the Windows side? (relay + portproxy)
Get-NetTCPConnection -LocalPort 30142 -State Listen |
Select-Object LocalAddress, LocalPort, OwningProcess
6. Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Port 30142 dead after logon | Windows task disabled/missed, or distro not booted. Check the task's LastTaskResult and logs\pi-web-wsl.log. Start it manually with Start-ScheduledTask -TaskName pi-web-wsl. |
| Works on Windows, not from LAN | Firewall rule or portproxy missing. Check Get-NetFirewallRule -DisplayName 'pi-web-wsl (TCP 30142)' and netsh interface portproxy show all, then re-run setup-network.ps1 elevated. |
Invalid host header in the browser |
The name you used is not in PI_WEB_ALLOWED_HOSTS. Add it in /etc/systemd/system/pi-web.service, then systemctl daemon-reload && systemctl restart pi-web. |
EADDRINUSE on 30142 |
Something else already bound the port. Could be a manual pi-web, but as of 2026-09-21 the more likely cause is a stale NAT-era netsh portproxy rule now conflicting with WSL's direct bind under mirrored networking -- check netsh interface portproxy show all on Windows first, then ss -ltnp | grep 30142 inside WSL. |
| Service starts at distro boot but not at logon | The systemd side is fine; the Windows logon task is the missing piece. wsl -d Ubuntu -u root --exec /bin/true boots the distro by hand to confirm. |
pi-web not found / wrong Node |
/usr/local/bin/pi-web-wsl picks the newest $NVM_DIR/versions/node/*/bin; install a Node there or adjust NVM_DIR in the wrapper. |
| Windows instance on 30141 also stops responding | Unrelated to this service โ see ../../windows/pi-web-autostart.md. |
pi-wsl.dph.am and pi-win.dph.am must stay on different ports: two servers cannot
share 0.0.0.0:30142.
7. Security notes
- Bound to
0.0.0.0and reachable from the LAN/WAN through the portproxy โ no password is configured, same as the Windows instance. Pi Web drives an agent with filesystem access, so anyone who can reach the port can execute commands. SetPI_WEB_PASSWORDin/etc/systemd/system/pi-web.serviceand restart the unit. - Password auth does not encrypt traffic. For access beyond a trusted network, put it behind HTTPS (reverse proxy) or a VPN.
- The WSL service runs as root (the distro's only user), so pi-web inherits root's
filesystem access and
/root/.pi.
8. Verification performed at setup (2026-09-20)
| Check | Result |
|---|---|
pi-web.service active + enabled |
active, enabled |
| Listener | 0.0.0.0:30142 (next-server) |
| Host allowlist | HTTP 200 with Host: pi-wsl.dph.am via curl |
| Windows loopback โ WSL | 200 from http://127.0.0.1:30142/ |
| Windows LAN IP | 200 from http://192.168.1.153:30142/ (and from inside WSL) |
| Logon task end-to-end | pi-web-wsl run manually โ LastTaskResult 0, wrapper logged ready on port 30142 |
| Windows instance untouched | 30141 still listening/serving |
Not tested: a full Windows reboot (would require terminating the live session). The task and unit are both enabled, so the first real logon is the remaining confirmation.
9. Incident + mirrored-networking migration (2026-09-21)
Symptom: pi-web unreachable at localhost:30142 the morning after working fine the
night before.
Root causes (two, found in sequence):
- The WSL VM itself had restarted (
uptimeshowed ~2 min), not crashed independently. WSL fully shuts down ~8s after the last session/process closes, taking systemd (and pi-web) down with it. The unit'sRestart=always+ systemd's own startup correctly brought pi-web back once the VM rebooted, but the box had likely been fully stopped for a while before that, hence "it just stopped working." - Root networking was NAT (not mirrored, despite an assumption otherwise) โ switched to
mirrored mode (see ยง4) to fix
localhost/LAN reachability more durably. This uncovered a stale NAT-eranetsh portproxyrule for 30142 that started conflicting with WSL's own direct bind under mirrored mode, crash-looping the service withEADDRINUSE. Deleted the portproxy rule (ยง4); service came up clean afterward.
Verified after the fix: pi-web.service active with no restart loop; curl localhost:30142 โ 200 from both WSL and (per Dom) the Windows browser; eth0 inside
WSL shows 192.168.1.153 directly (mirrored mode confirmed active);
Get-NetFirewallHyperVRule shows the existing pi-web-wsl (TCP 30142) rule still
applies under mirrored mode, so LAN reachability shouldn't require a new rule โ not
yet independently confirmed from a second LAN device.
Still open: the WSL VM can still fully shut down when no session is open (root cause
#1 above isn't fixed, only made less likely to matter since pi-web now restarts more
reliably). A Windows Scheduled Task running wsl.exe -d Ubuntu -e sleep infinity at
startup was proposed to hold a session open permanently, but has not been registered
yet. Do this next time this comes up.