The Any-Port Dev Tunnel
22 minutes read •
Your laptop shouldn't run your coding agents. Move them to a spare box on the LAN, or to a VPS. Then open every dev server they start right in your browser, as if it were running locally. One tunnel. Any port. Nothing to set up per project. And a drop folder for handing the box files.
In a hurry? Paste it into Claude Code, or any agent with shell access, and skip the article. It carries every trap on this page, so your agent doesn't get to rediscover them on your time.
The handoff
The work that cooks your laptop isn't the work you're doing by hand. It's the agent grinding through a task. The dev server rebuilding on every keystroke. The test suite. The model sitting on eight gigabytes of weights.
None of it needs to run on the machine your editor is on.
Move it to a box that has nothing better to do. Your laptop goes back to being an editor and a browser. Quiet, cool, and still holding charge at 4pm. Meanwhile the work keeps running when you shut the lid, walk to a meeting, or reboot.
| Stays on your machine | Moves to the box |
|---|---|
| Editor / IDE | Coding agents and long-running tasks |
| Browser | Dev servers, preview builds, docs sites |
| Git, terminal | Test suites, bundlers, watchers |
| Files to hand the box | Images, configs, assets to feed a task. Dropped in a folder, synced one-way |
| Nothing heavier | Local model inference, containers, databases |
What normally kills the idea
Everything you just moved binds to 127.0.0.1 on the remote box, on whatever port it felt like. An agent starts a server on 5173. Kills it. Starts another on 3001. Forward each one by hand and you're writing a fresh ssh -L every time you switch tasks.
You can't forward a port you didn't know would exist. That's why people give up on this and drag the work back onto the laptop.
One SOCKS5 tunnel kills the whole class of problem. It forwards by hostname, so every port is covered already, including the ones your agent hasn't invented yet.
LAN box or VPS
| Spare machine on the LAN | VPS | |
|---|---|---|
| Latency | Sub-millisecond. Feels local. | Depends on region. Fine for HTTP, noticeable on chatty hot-reload. |
| Cost | Hardware you already own | Monthly, but sized on demand |
| Reach | Home or office network only | Anywhere, including tethered |
| Good for | GPU work, big local models, noisy builds | Always-on agents, sharing a preview, weak laptop |
The setup below is identical either way. Only DEVBOX_IP changes.
Your dev servers stay bound to 127.0.0.1 and you open no firewall ports. Everything rides inside the SSH connection you already trust. The alternative is what most people actually do: bind the dev server to 0.0.0.0 on a public IP and call it done. That puts an unauthenticated app in debug mode on the open internet, leaking source maps, environment variables and admin routes to anyone running a scanner.
How it works
One SSH connection carries a SOCKS5 proxy. A tiny proxy-autoconfig rule sends exactly one hostname into it and leaves everything else alone.
Why you never have to touch it again:
- The PAC matches on hostname, not port. One rule covers every port you'll ever use.
- DNS happens on the server. SOCKS5 forwards the hostname instead of an address, so
dev.testresolves inside the dev box. - Everything else goes direct. Your VPN, your routing table and your normal browsing stay untouched.
- Files go one way, on change. A folder on your machine syncs to the box with rsync. The box gets the screenshot or config you just dropped, and you never open a shell.
Before you start
- A Linux box you can SSH into with key auth, on your LAN or as a VPS.
- Its
sshd_configallows TCP forwarding. That'sAllowTcpForwarding yes, and it's the default. - You can edit
/etc/hostson it. You'll need root there. - For the drop folder, key auth to the box must work non-interactively, because a background service has no terminal to prompt for a passphrase.
Substitute your own values throughout:
| Placeholder | Meaning | Example |
|---|---|---|
DEVBOX_IP | LAN address, or VPS host / public IP | 192.168.1.50 |
DEVBOX_USER | your login on the box | dev |
dev.test | the fake hostname you'll browse to | keep as-is |
| 1080 | local SOCKS5 port | keep as-is |
| 8088 | local port serving the PAC file | keep as-is |
~/Drop | folder you drop files into, on your machine | keep as-is |
~/drop | where those files land, on the dev box | keep as-is |
RFC 6761 reserves .test for exactly this. It can never collide with a real domain, and no resolver will leak it to the internet. Use it.
01 Give the dev box the hostname
On the remote box, not your machine.
# adds both IPv4 and IPv6 loopback sudo tee -a /etc/hosts >/dev/null <<'EOF' # dev access over SSH SOCKS proxy 127.0.0.1 dev.test ::1 dev.test EOF getent ahosts dev.test
Never add dev.test to your own hosts file. If it resolves locally, your browser connects to your own loopback instead of the tunnel and the whole design collapses. It resolves on the server or nowhere.
Don't worry if the box lists ::1 first. SSH tries each address in turn, so servers bound only to IPv4 loopback still answer.
02 Add the SSH host entry
In ~/.ssh/config, or C:\Users\you\.ssh\config on Windows. Back the file up first.
Host devbox
HostName DEVBOX_IP
User DEVBOX_USER
DynamicForward 1080
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yes| Directive | Why |
|---|---|
DynamicForward 1080 | Opens the SOCKS5 proxy, bound to loopback only. |
ServerAliveInterval 30ServerAliveCountMax 3 | Spots a dead link in about 90 seconds instead of hanging forever after sleep or a VPN flap. |
ExitOnForwardFailure yes | If it can't bind port 1080, die. Don't sit there proxying nothing. |
Check that the entry resolves the way you wrote it:
ssh -G devbox | grep -E '^(hostname|user|dynamicforward|exitonforwardfailure)'
03 Keep the tunnel alive
autossh restarts SSH when it dies. Your service manager restarts autossh and starts it at login.
A background service does not inherit your interactive SSH_AUTH_SOCK. If your key is passphrase-protected and lives in an agent, the service can't use it. You have two options. Point the service at the agent socket explicitly, which is simple but leaves the tunnel down until you unlock the agent after a reboot. Or give the tunnel its own passphrase-free key:
ssh-keygen -t ed25519 -N '' -f ~/.ssh/devbox_tunnel -C 'devbox socks tunnel' ssh-copy-id -i ~/.ssh/devbox_tunnel.pub devbox
Then add IdentityFile ~/.ssh/devbox_tunnel to the devbox block. Lock the key down in authorized_keys with restrict,port-forwarding. Now it can open forwards and nothing else.
Install
brew install autossh
Launch agent
Write ~/Library/LaunchAgents/com.user.devbox-socks.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.devbox-socks</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/autossh</string>
<string>-M</string><string>0</string>
<string>-N</string>
<string>devbox</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>AUTOSSH_GATETIME</key><string>0</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
<!-- only if reusing an agent-held key: -->
<!-- <key>SSH_AUTH_SOCK</key><string>/path/to/agent.sock</string> -->
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/devbox-socks.log</string>
<key>StandardErrorPath</key>
<string>/tmp/devbox-socks.log</string>
</dict>
</plist>plutil -lint ~/Library/LaunchAgents/com.user.devbox-socks.plist launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.devbox-socks.plist launchctl print gui/$(id -u)/com.user.devbox-socks | grep -E 'state|pid' lsof -nP -iTCP:1080 -sTCP:LISTEN
On Intel Macs, Homebrew lives at /usr/local/bin. Adjust both paths.
Install
sudo apt update && sudo apt install -y autossh
Install
sudo pacman -S --needed autossh
systemd user unit
Write ~/.config/systemd/user/devbox-socks.service:
[Unit] Description=SSH SOCKS5 tunnel to the dev box After=network-online.target Wants=network-online.target [Service] Environment=AUTOSSH_GATETIME=0 ExecStart=/usr/bin/autossh -M 0 -N devbox Restart=always RestartSec=10 [Install] WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now devbox-socks.service
systemctl --user status devbox-socks.service --no-pager
# start at boot without waiting for a graphical login
sudo loginctl enable-linger "$USER"
ss -ltnp | grep 1080Kept a passphrase-protected key? Add Environment=SSH_AUTH_SOCK=%t/keyring/ssh for GNOME Keyring, or your agent's own socket path. But note what enable-linger means here: no agent is running before login, so only a dedicated passphrase-free key actually comes up at boot.
Install
Windows 10 and 11 ship the OpenSSH client. There's no native autossh, but you don't need one. A PowerShell loop plus a logon-triggered scheduled task does the same job. ssh exits when the link dies, and the loop restarts it.
$ErrorActionPreference = 'Continue'
while ($true) {
ssh -N devbox
Start-Sleep -Seconds 10
}Scheduled task, at logon
$ps = "$env:USERPROFILE\devbox-socks.ps1"
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$ps`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval 00:01:00
Register-ScheduledTask -TaskName 'devbox-socks' -Action $action `
-Trigger $trigger -Settings $set -Force
Start-ScheduledTask -TaskName 'devbox-socks'
Get-NetTCPConnection -LocalPort 1080 -State ListenAlready running WSL2? Do the whole setup inside it with the systemd unit, then point Windows browsers at 127.0.0.1:1080. WSL2 forwards listening loopback ports to Windows on its own.
04 Write the PAC rule
Save as localhost.pac in your home directory.
function FindProxyForURL(url, host) {
host = host.toLowerCase();
// dev.test and any subdomain -> through the SSH tunnel.
// host has no port, so every port is covered automatically.
if (host === "dev.test" || dnsDomainIs(host, ".dev.test")) {
return "SOCKS5 127.0.0.1:1080";
}
return "DIRECT";
}Write SOCKS5, never plain SOCKS. That one digit is what makes the browser hand the hostname to the proxy instead of resolving it first. Test the near-misses too: devtest.com and notdev.test both have to come back DIRECT.
Serve it over loopback HTTP
Chromium browsers are unreliable with file:// PAC URLs. Serve the same file over 127.0.0.1 instead. That works everywhere and keeps one file as the single source of truth.
#!/usr/bin/env python3
"""Serve ~/localhost.pac on http://127.0.0.1:8088/localhost.pac"""
import http.server, os
PAC = os.path.expanduser("~/localhost.pac")
MIME = "application/x-ns-proxy-autoconfig"
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.split("?")[0] != "/localhost.pac":
self.send_error(404); return
try:
with open(PAC, "rb") as fh:
body = fh.read()
except OSError as exc:
self.send_error(500, str(exc)); return
self.send_response(200)
self.send_header("Content-Type", MIME)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
if __name__ == "__main__":
http.server.HTTPServer(("127.0.0.1", 8088), Handler).serve_forever()The one-liner would put your entire home directory on loopback and send the wrong MIME type while it's at it. This serves exactly one path. It re-reads the file on every request, so PAC edits take effect with no restart.
Run it from a second launch agent, com.user.pacserve.plist:
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/YOU/.local/bin/pacserve.py</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.pacserve.plist curl -i http://127.0.0.1:8088/localhost.pac | head -5
Second unit, ~/.config/systemd/user/pacserve.service:
[Unit] Description=Serve the dev PAC file on loopback [Service] ExecStart=/usr/bin/python3 %h/.local/bin/pacserve.py Restart=always RestartSec=10 [Install] WantedBy=default.target
systemctl --user daemon-reload systemctl --user enable --now pacserve.service curl -i http://127.0.0.1:8088/localhost.pac | head -5
Register it the same way as the tunnel task:
$py = "$env:LOCALAPPDATA\Programs\Python\Python312\pythonw.exe"
$action = New-ScheduledTaskAction -Execute $py `
-Argument "`"$env:USERPROFILE\pacserve.py`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName 'devbox-pacserve' -Action $action `
-Trigger $trigger -Force
Start-ScheduledTask -TaskName 'devbox-pacserve'
curl.exe -i http://127.0.0.1:8088/localhost.pacPoint the Python path at your own install. pythonw.exe keeps it windowless.
05 Point the browser at it
Firefox, the reliable one
Firefox keeps its own proxy settings, so no VPN client can override them. Settings → search proxy → Network Settings:
- Select Automatic proxy configuration URL
- Enter
http://127.0.0.1:8088/localhost.pac - Tick Proxy DNS when using SOCKS v5. This is the setting that makes the hostname resolve on the dev box
- Hit Reload in that dialog after any PAC edit, because Firefox caches it
Then check about:config and confirm network.proxy.socks_remote_dns is true.
Chromium: Chrome, Brave, Edge, Vivaldi
Chromium reads the system proxy by default. Don't change your system settings for this. Launch a dedicated profile with its own proxy instead, and leave every other browser and your VPN alone.
open -a "Google Chrome" --args \ --user-data-dir="$HOME/.chrome-devbox" \ --proxy-pac-url="http://127.0.0.1:8088/localhost.pac"
google-chrome \
--user-data-dir="$HOME/.chrome-devbox" \
--proxy-pac-url="http://127.0.0.1:8088/localhost.pac" &
# chromium / brave-browser / microsoft-edge take the same flagsstart chrome --user-data-dir="%USERPROFILE%\.chrome-devbox" ^ --proxy-pac-url="http://127.0.0.1:8088/localhost.pac"
Or system-wide: Settings → Network & internet → Proxy → Use setup script → http://127.0.0.1:8088/localhost.pac.
Now browse to http://dev.test:3000, or whatever port you're running.
GNOME system-wide
gsettings set org.gnome.system.proxy mode 'auto' gsettings set org.gnome.system.proxy autoconfig-url 'http://127.0.0.1:8088/localhost.pac'
06 Prove it works
Four checks. Skip any of them and you'll be debugging the wrong layer later.
1. The tunnel carries loopback traffic
curl -s -o /dev/null -w '%{http_code}\n' \
--socks5-hostname 127.0.0.1:1080 http://dev.test:3000/--socks5-hostname sends the name to the proxy. Plain --socks5 resolves it locally and fails. That difference is the whole point of the setup.
2. The name doesn't resolve on your machine
ping -c1 dev.test # must fail: "cannot resolve" grep -c dev.test /etc/hosts # must print 0
If either one succeeds, something added a local entry. Rip it out.
3. It's really the remote loopback
Pick a service bound only to 127.0.0.1 on the box. Then prove you can't reach it directly, but can through the tunnel:
# on the box: find a loopback-only listener ss -ltnp | grep 127.0.0.1 # from your machine: this must time out curl --max-time 5 http://DEVBOX_IP:PORT/ # and this must answer curl --socks5-hostname 127.0.0.1:1080 http://dev.test:PORT/
4. It heals itself
kill $(lsof -t -nP -iTCP:1080 -sTCP:LISTEN) # or: pkill -f 'ssh .* devbox'
sleep 15
curl --socks5-hostname 127.0.0.1:1080 http://dev.test:3000/It should be back inside 15 seconds. If it isn't, go read the service log.
07 Hand the box files back: the drop folder
The tunnel is great for reading. The gap is writing: handing the box a screenshot, a config, an asset to feed a task. Opening a shell and pasting every time is the friction that makes people give up on the whole idea. A drop folder kills it.
One folder on your machine, one on the box, and an on-change push between them. Drop a file in the local folder and it lands on the box a few seconds later. No shell, no per-file setup. The direction is the exact reverse of the tunnel: the tunnel brings the box's loopback to your browser, and the drop folder carries your files to the box.
Why on-change, and why not a mount
A network mount (macFUSE, SSHFS, NFS) looks nicer on paper. But it drags in a kernel extension on macOS, needs reduced security, and breaks the moment the box is unreachable. An on-change sync is a watcher firing rsync whenever the folder changes, wrapped in a service that restarts itself: fswatch under launchd, inotifywait under systemd, FileSystemWatcher on Windows. It survives sleep, reboots and VPN reconnects. A failed push (box unreachable) never kills it. It just retries on the next change.
Keep it one-way and never pass --delete. Files only flow to the box, and nothing there is removed when you delete it locally. Treat the box's copy as disposable. It's a staging area, not a mirror.
mkdir -p ~/drop
The push targets devbox, the same host entry from Step 02, which now points wherever you actually reach the box. If your setup has a LAN fallback entry, see the note at the end of this section.
Install
brew install fswatch
Sync script
Write ~/.local/bin/drop-sync.sh:
#!/bin/bash # fswatch's --latency batches a burst of writes into one wake, so rsync runs # at most once per change. A failed rsync (box unreachable) just loops back # and tries again on the next change. fswatch --latency=1 ~/Drop | while read -r _; do rsync -az ~/Drop/ devbox:~/drop/ done
fswatch streams a line on every change. The loop runs one rsync for each, and --latency=1 coalesces a burst of writes into a single push. Dropping a dozen files doesn't fire a dozen transfers. Mark it executable.
Launch agent
Write ~/Library/LaunchAgents/com.user.drop-sync.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.drop-sync</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOU/.local/bin/drop-sync.sh</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
<!-- only if reusing an agent-held key: -->
<!-- <key>SSH_AUTH_SOCK</key><string>/path/to/agent.sock</string> -->
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>StandardOutPath</key>
<string>/tmp/drop-sync.log</string>
<key>StandardErrorPath</key>
<string>/tmp/drop-sync.log</string>
</dict>
</plist>plutil -lint ~/Library/LaunchAgents/com.user.drop-sync.plist launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.drop-sync.plist launchctl print gui/$(id -u)/com.user.drop-sync | grep -E 'state|pid' pgrep -fl fswatch
KeepAlive restarts the whole chain if fswatch or the loop ever dies, and ThrottleInterval stops it hot-looping on a folder that's forever failing to sync. On Intel Macs, Homebrew lives at /usr/local/bin. Adjust both paths.
A background service does not inherit your SSH_AUTH_SOCK. Either point it at the agent socket via the SSH_AUTH_SOCK key above, or reuse the dedicated passphrase-free key from Step 03 by adding IdentityFile to the devbox block. The key is cleaner for a service that must push at boot, before any login.
Install
sudo apt update && sudo apt install -y inotify-tools rsync
Install
sudo pacman -S --needed inotify-tools rsync
systemd user unit
Linux has no launchd WatchPaths, so the unit runs a small watcher loop instead:
#!/bin/bash
while true; do
inotifywait -q -e create -e modify -e close_write -e moved_to -r ~/Drop
rsync -az ~/Drop/ devbox:~/drop/
doneMark it executable, then write ~/.config/systemd/user/drop-sync.service:
[Unit] Description=Sync ~/Drop to the dev box After=network-online.target Wants=network-online.target [Service] ExecStart=%h/.local/bin/drop-sync.sh Restart=always RestartSec=10 [Install] WantedBy=default.target
chmod +x ~/.local/bin/drop-sync.sh systemctl --user daemon-reload systemctl --user enable --now drop-sync.service systemctl --user status drop-sync.service --no-pager pgrep -fl inotifywait
The agent-key caveat from Step 03 applies here too. With enable-linger nothing runs before login, so a dedicated passphrase-free key (add IdentityFile to the devbox block) is the only thing that actually pushes at boot.
Install
Recent Git for Windows and Windows 11 builds ship rsync. You still need a watcher. dotnet-watch and PowerShell's FileSystemWatcher both work. This uses a scheduled task to kick a PowerShell script that watches the folder and pushes on change:
$watcher = New-Object System.IO.FileSystemWatcher "$env:USERPROFILE\Drop"
$watcher.EnableRaisingEvents = $true
Register-ObjectEvent $watcher Changed, Created, Renamed -Action {
Start-Sleep -Seconds 2
rsync -az "$env:USERPROFILE\Drop\" devbox:"~/drop/"
} | Out-Null
Wait-EventScheduled task, at logon
$ps = "$env:USERPROFILE\drop-sync.ps1"
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$ps`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName 'devbox-drop-sync' -Action $action `
-Trigger $trigger -Force
Start-ScheduledTask -TaskName 'devbox-drop-sync'
Get-Process | Where-Object { $_.ProcessName -match 'powershell' } | Select -First 1Prove it works
touch ~/Drop/hello.txt sleep 5 ssh devbox 'ls -l ~/drop/hello.txt'
The file should appear on the box within a few seconds of the drop. No shell on your part beyond the check.
A second SSH entry only helps when you type it manually. SSH won't try host B if host A times out. If your devbox points at a tailnet address and the box is unreachable over it, give the sync a short ConnectTimeout and a fallback: run rsync ... devbox:~/drop/ || rsync ... devbox-lan:~/drop/. Otherwise a dead link stalls the push instead of failing over.
Traps
I hit every one of these building the setup in this guide. They cost hours for the same reason. Each one fails silently, or dresses itself up as a completely different problem.
Chromium never proxies localhost
Chromium bypasses the proxy for localhost, 127.0.0.1 and link-local addresses. The bypass is implicit, and it runs before Chromium ever looks at your PAC file. No PAC rule can catch them. Here's what I measured against a real remote server:
| Flags | http://localhost:PORT |
|---|---|
| none (control) | ERR_CONNECTION_REFUSED |
--proxy-pac-url | ERR_CONNECTION_REFUSED |
--proxy-pac-url + --proxy-bypass-list="<-loopback>" | ERR_CONNECTION_REFUSED |
--proxy-server=socks5://… + --proxy-bypass-list="<-loopback>" | loads |
--proxy-bypass-list only counts alongside --proxy-server. Hand it a PAC URL and it's silently ignored. That's the entire reason this guide browses to dev.test instead of localhost. Pick a real hostname and the problem never exists.
The only combination that works routes all traffic through the SSH tunnel. Your browsing then exits from the dev box's network, which bypasses whatever VPN you're on. Use a dedicated profile, and know what you're giving up.
Relaunching with new flags does nothing
Chromium holds a singleton lock per --user-data-dir. Launch it again with different flags while that profile is already running, and the new process hands off to the old one and exits. Your flags are discarded. No error, no warning. Proxy and window flags get read once, at browser-process startup, and that's it.
Quit fully first, or give the dev profile its own --user-data-dir so it never collides with your everyday browser. And if you script the launch, make the script detect a running instance and refuse. Anything else prints a success message that isn't true.
Session restore overrides --window-position
Pass --window-position and the window can still open on a different monitor. Chromium restores the previous session's window bounds, and those beat your flag. Read window_placement back from <profile>/Default/Preferences after launch. If the coordinates aren't the ones you passed, restore overrode you.
To fix it, delete the profile's Default/Sessions directory, set session.restore_on_startup to 5, and write the window_placement you want into Preferences before you launch.
file:// PAC URLs are unreliable
Chromium can silently ignore a file:// proxy-autoconfig URL and fall straight back to direct. Nothing gets proxied. Nothing gets logged. Serve the PAC over loopback HTTP instead. Firefox handles file:// perfectly well, which is exactly what makes this so easy to misdiagnose: one browser breaks, the other doesn't, and you go hunting in the tunnel.
System proxy settings can be inert under a VPN
A VPN client that builds its own tunnel interface often registers a dynamic network service, one that doesn't appear in the configured service list at all. macOS resolves proxies from the primary service. So you can apply a PAC to every named service with networksetup and it will never be consulted once.
Check the resolved state. What you set is irrelevant:
scutil --proxy # empty dict = your PAC is not activeThis is why I configure the browser instead of the system. Firefox's own settings and Chromium's launch flags both sidestep the problem completely.
Background services don't get your SSH agent
A launchd agent, systemd user unit or scheduled task starts with a clean environment and no SSH_AUTH_SOCK. If the only key your server accepts is passphrase-protected and held in an agent, the tunnel dies at boot with a bare Permission denied (publickey).
Export the agent's socket path into the service and the tunnel waits until you unlock the agent after login. Or give it a dedicated passphrase-free key. Both are defensible. Just decide on purpose, because either way the failure looks exactly like a network problem.
autossh gives up on the first failure
By default, autossh reads a connection that dies quickly as a fatal misconfiguration and exits for good. On a laptop that boots before Wi-Fi or a VPN is ready, that's every single boot. Set AUTOSSH_GATETIME=0 and it keeps retrying.
Pair -M 0, which drops the monitoring port, with SSH's own ServerAliveInterval. Then remember that ExitOnForwardFailure yes makes SSH exit fast when port 1080 is already taken. Give the service a restart delay so it can't hot-loop: ThrottleInterval on macOS, RestartSec under systemd.
--socks5 and --socks5-hostname are not the same
curl --socks5 resolves the hostname locally, then sends an address. --socks5-hostname sends the name and lets the proxy resolve it. Only the second tests what your browser actually does. It's the same distinction as SOCKS versus SOCKS5 in a PAC return value, and the same one sitting behind Firefox's Proxy DNS when using SOCKS v5 checkbox.
A "one-way, no delete" sync still needs a watcher
Point a service at ~/Drop and hope it pushes "somewhere", and you get a sync that runs once at load and never again. The folder has no built-in hook. You have to wake it. On macOS use fswatch, on Linux use inotifywait, on Windows use FileSystemWatcher. That's the whole difference between a folder that actually syncs and one that looks right in theory.
The push must also run as the user who owns the key. A service launched by a different context quietly fails at SSH auth, and that failure looks exactly like "the sync never ran."
Hand it to an assistant
Paste this into Claude Code, or any coding agent with shell access. It carries every trap above, so the agent doesn't get to rediscover them at your expense.
I run my coding agents, dev servers and builds on a remote Linux box instead of my laptop. Set up a persistent SSH SOCKS5 proxy so my browser can reach anything they bind to loopback on that box, on ANY port, with no per-project setup. I never want to write another `ssh -L` for a port an agent invented five minutes ago. Also set up a one-way drop folder so files I drop on my machine land on the box. MY SETUP (fill these in) - Remote box: <IP or hostname>, user <USER>. LAN machine or VPS. SSH already works with key auth. - My machine: <macOS | Debian/Ubuntu | Arch | Windows> - Browser: <Firefox | Chrome | Brave | Edge> - I am on a VPN I do NOT want reconfigured or disconnected. DO THIS 1. Add an SSH host entry `devbox`: DynamicForward 1080, ServerAliveInterval 30, ServerAliveCountMax 3, ExitOnForwardFailure yes. Back up my ssh config first. 2. Ensure `127.0.0.1 dev.test` and `::1 dev.test` exist in the REMOTE box's /etc/hosts. Ask me before editing it. 3. Install autossh and create a user service running `autossh -M 0 -N devbox`, restart-always and start-at-login, so it survives sleep, VPN reconnects and reboot. Set AUTOSSH_GATETIME=0. 4. Write a PAC returning "SOCKS5 127.0.0.1:1080" for dev.test and *.dev.test, "DIRECT" otherwise. Serve it on http://127.0.0.1:8088/localhost.pac from a second user service that serves ONLY that path. 5. Wire my browser to it and verify end to end. 6. Create `~/drop` on the box, and an on-change one-way sync from `~/Drop` on my machine to `devbox:~/drop/`, as a start-at-login service: macOS launchd WatchPaths, Linux systemd + inotifywait, Windows scheduled task + FileSystemWatcher. Never use --delete. Verify a dropped file lands on the box. CONSTRAINTS - NEVER add dev.test to my local hosts file. It must resolve on the server only. - Do not modify VPN settings or system routing. - Leave my dev servers bound to 127.0.0.1 on the box and open NO firewall ports. The whole point is that nothing is exposed; traffic rides inside SSH. Never "fix" a connectivity problem by rebinding a service to 0.0.0.0. - Do not weaken or skip a verification to make something look done. KNOWN TRAPS (obey these, they each cost hours) - Verify with `curl --socks5-hostname 127.0.0.1:1080 http://dev.test:PORT`. The -hostname form is required; plain --socks5 resolves locally and proves nothing. Also prove dev.test does NOT resolve on my machine. - Chromium NEVER proxies localhost or 127.0.0.1: an implicit bypass runs BEFORE the PAC. Use the dev.test hostname. If literal localhost is unavoidable, ONLY `--proxy-server=socks5://127.0.0.1:1080 --proxy-bypass-list="<-loopback>"` works. --proxy-bypass-list is IGNORED with --proxy-pac-url. That mode tunnels ALL traffic and bypasses my VPN, so warn me and get my OK first. - Do not use a file:// PAC URL for Chromium. Serve it over loopback HTTP. - Chromium holds a singleton lock per --user-data-dir: relaunching with new flags silently no-ops if that profile is already running. Fully quit first, or use a dedicated --user-data-dir. Never report a relaunch as done without checking the running process's actual argv. - Chromium session restore overrides --window-position. If window placement matters, clear Default/Sessions, set session.restore_on_startup=5, and write window_placement into Preferences. Read the value back to confirm. - Firefox needs its own proxy settings plus network.proxy.socks_remote_dns=true. - On macOS, if a VPN creates a dynamic primary network service, system-wide proxy settings are inert. Check `scutil --proxy` for the RESOLVED state rather than trusting what networksetup reports. Prefer per-browser config. - A background service does NOT inherit SSH_AUTH_SOCK. If my key is passphrase-protected in an agent, tell me the tradeoff between pointing the service at the agent socket and creating a dedicated passphrase-free key, and let me choose. - The drop folder needs a watcher to sync on change: fswatch (macOS), inotifywait under systemd (Linux), FileSystemWatcher (Windows). A service that just runs once at load syncs once and never again. Keep it one-way with NO --delete. The push must run as the user who owns the key, or it fails silently at SSH auth. HOW TO REPORT After each step show the command output that proves it worked. If something cannot be verified, say so plainly instead of assuming. If a check fails, say it failed and show the output.
Backing it out
launchctl bootout gui/$(id -u)/com.user.devbox-socks
launchctl bootout gui/$(id -u)/com.user.pacserve
launchctl bootout gui/$(id -u)/com.user.drop-sync
rm ~/Library/LaunchAgents/com.user.{devbox-socks,pacserve,drop-sync}.plist
rm ~/localhost.pac ~/.local/bin/pacserve.py ~/.local/bin/drop-sync.sh
# then remove the devbox block from ~/.ssh/configsystemctl --user disable --now devbox-socks.service pacserve.service drop-sync.service
rm ~/.config/systemd/user/{devbox-socks,pacserve,drop-sync}.service
systemctl --user daemon-reload
sudo loginctl disable-linger "$USER"
rm ~/localhost.pac ~/.local/bin/pacserve.py ~/.local/bin/drop-sync.sh
# then remove the devbox block from ~/.ssh/configUnregister-ScheduledTask -TaskName 'devbox-socks' -Confirm:$false
Unregister-ScheduledTask -TaskName 'devbox-pacserve' -Confirm:$false
Unregister-ScheduledTask -TaskName 'devbox-drop-sync' -Confirm:$false
Remove-Item "$env:USERPROFILE\devbox-socks.ps1","$env:USERPROFILE\pacserve.py","$env:USERPROFILE\drop-sync.ps1"
# then remove the devbox block from .ssh\configNothing here writes outside your user account. The single exception is that one line in the dev box's /etc/hosts. The drop folder leaves the box's ~/drop in place. Clear it whenever you like.