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.

Read it first

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.

What's tested: I built the macOS path and verified it end to end, including every trap in the last section. The Linux and Windows service definitions follow the same design, but I haven't run them on those platforms. Treat them as untested. The browser traps are a different matter. Those live in the engines, so they'll bite you on any operating system. The drop folder is macOS-tested; the Windows and Linux variants follow the same design and are untested.

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 machineMoves to the box
Editor / IDECoding agents and long-running tasks
BrowserDev servers, preview builds, docs sites
Git, terminalTest suites, bundlers, watchers
Files to hand the boxImages, configs, assets to feed a task. Dropped in a folder, synced one-way
Nothing heavierLocal 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 LANVPS
LatencySub-millisecond. Feels local.Depends on region. Fine for HTTP, noticeable on chatty hot-reload.
CostHardware you already ownMonthly, but sized on demand
ReachHome or office network onlyAnywhere, including tethered
Good forGPU work, big local models, noisy buildsAlways-on agents, sharing a preview, weak laptop

The setup below is identical either way. Only DEVBOX_IP changes.

Why this is the safe shape for a VPS

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.

BrowserPAC ruledev.testeverything elseSOCKS5127.0.0.1:1080Direct / VPNsshDev box sshdresolves dev.test127.0.0.1:3000 :5173:8080 :anyhostname resolved HERE, not on your machine

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.test resolves 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_config allows TCP forwarding. That's AllowTcpForwarding yes, and it's the default.
  • You can edit /etc/hosts on 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:

PlaceholderMeaningExample
DEVBOX_IPLAN address, or VPS host / public IP192.168.1.50
DEVBOX_USERyour login on the boxdev
dev.testthe fake hostname you'll browse tokeep as-is
1080local SOCKS5 portkeep as-is
8088local port serving the PAC filekeep as-is
~/Dropfolder you drop files into, on your machinekeep as-is
~/dropwhere those files land, on the dev boxkeep 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.

on the dev box
# 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
The one rule that matters

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.

ssh config
Host devbox
    HostName DEVBOX_IP
    User DEVBOX_USER
    DynamicForward 1080
    ServerAliveInterval 30
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
DirectiveWhy
DynamicForward 1080Opens the SOCKS5 proxy, bound to loopback only.
ServerAliveInterval 30
ServerAliveCountMax 3
Spots a dead link in about 90 seconds instead of hanging forever after sleep or a VPN flap.
ExitOnForwardFailure yesIf it can't bind port 1080, die. Don't sit there proxying nothing.

Check that the entry resolves the way you wrote it:

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

Auth first

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

macOS
brew install autossh

Launch agent

Write ~/Library/LaunchAgents/com.user.devbox-socks.plist:

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>
load and check
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

Debian / Ubuntu
sudo apt update && sudo apt install -y autossh

Install

Arch
sudo pacman -S --needed autossh

systemd user unit

Write ~/.config/systemd/user/devbox-socks.service:

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
enable and check
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 1080
Agent keys under systemd

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

%USERPROFILE%\devbox-socks.ps1
$ErrorActionPreference = 'Continue'
while ($true) {
    ssh -N devbox
    Start-Sleep -Seconds 10
}

Scheduled task, at logon

PowerShell, once
$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 Listen
Simpler alternative

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

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

pacserve.py
#!/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()
Why not python -m http.server

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 entries
<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/>
load and check
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:

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
enable and check
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:

PowerShell, once
$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.pac

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

macOS
open -a "Google Chrome" --args \
  --user-data-dir="$HOME/.chrome-devbox" \
  --proxy-pac-url="http://127.0.0.1:8088/localhost.pac"
Linux
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 flags
Windows
start 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

the important flag is --socks5-hostname
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.

on the dev box, once
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

macOS
brew install fswatch

Sync script

Write ~/.local/bin/drop-sync.sh:

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:

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>
load and check
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.

Auth, same as the tunnel

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

Debian / Ubuntu
sudo apt update && sudo apt install -y inotify-tools rsync

Install

Arch
sudo pacman -S --needed inotify-tools rsync

systemd user unit

Linux has no launchd WatchPaths, so the unit runs a small watcher loop instead:

drop-sync.sh
#!/bin/bash
while true; do
    inotifywait -q -e create -e modify -e close_write -e moved_to -r ~/Drop
    rsync -az ~/Drop/ devbox:~/drop/
done

Mark it executable, then write ~/.config/systemd/user/drop-sync.service:

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
enable and check
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
Auth under systemd

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:

%USERPROFILE%\drop-sync.ps1
$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-Event

Scheduled task, at logon

PowerShell, once
$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 1

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

Why the wrapper matters when you have a fallback

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 · all platforms

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:

Flagshttp://localhost:PORT
none (control)ERR_CONNECTION_REFUSED
--proxy-pac-urlERR_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.

If you truly need literal localhost

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.

Chromium · all platforms

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.

Chromium · all platforms

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.

Chromium · PAC delivery

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.

macOS · VPN

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 active

This is why I configure the browser instead of the system. Firefox's own settings and Chromium's launch flags both sidestep the problem completely.

SSH · service managers

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

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.

DNS

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

Drop folder · service managers

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.

prompt
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/config
systemctl --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/config
Unregister-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\config

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

Reserved TLD per RFC 6761. SOCKS5 remote name resolution per RFC 1928 §5, address type 0x03. One-way file push per rsync(1), with --delete deliberately omitted so the box's copy stays disposable staging, not a mirror.