worktree-devproxy
One shared Traefik + one compose stack per git worktree. Each instance is reachable at http://<worktree>.<repo>.localhost:8000; a laptop reaches it through a single SSH tunnel. Nothing else touches the host network.
laptop browser → SSH tunnel (:8000) → Traefik (127.0.0.1 only)
→ Host-header match → per-worktree compose stack (devproxy network)
Invariants (don't trade these away when adapting):
- No host ports except Traefik's own
127.0.0.1:8000 (web) and 127.0.0.1:8080 (dashboard/API). Everything else flows over the shared external docker network devproxy.
- Routing is Host-header only —
*.localhost resolves to loopback by OS convention, so there is no DNS, no TLS, and no port allocation table.
- Worktree isolation is
COMPOSE_PROJECT_NAME (set by .envrc via direnv): independent networks, volumes, and container names per worktree.
One-time machine setup
Prerequisites: docker (with compose plugin), git, direnv (hook it into the shell rc).
- Copy
references/dev-proxy.docker-compose.yml to ~/dev-proxy/docker-compose.yml, mkdir -p ~/dev-proxy/dynamic, and docker compose up -d (run with COMPOSE_PROJECT_NAME=dev-proxy or from a direnv-free shell — a stray project name from another repo's .envrc creates a second Traefik that loses the :8000 bind race). Two flags in it are load-bearing, learned the hard way:
image: traefik:v3 (not an old pin) — Traefik ≤ v3.3 hardcodes
Docker API 1.24, which Docker Engine 29+ rejects outright.
--providers.docker.network=devproxy — app containers sit on
[default, devproxy]; without the pin Traefik dials the default
network's IP it can't reach and every request 504s.
- Install
scripts/wt and scripts/devproxy-route to ~/bin/, chmod +x, make sure ~/bin is on PATH.
- Verify:
curl -s --noproxy '*' localhost:8080/api/version returns the Traefik version; curl localhost:8000 returns 404 (no routes yet — correct).
Onboarding a repo
Four pieces per repo, all committed — worktrees are created from HEAD, so uncommitted setup files silently don't propagate and wt new breaks.
.envrc at the repo root:
export REPO=<repo-dir-name>
export WORKTREE=$(basename "$PWD" | tr '/_.' '-')
export COMPOSE_PROJECT_NAME="${REPO}-${WORKTREE}"
# Main checkout (dir name == repo) sits at the apex; worktrees are
# subdomains: <repo>.localhost vs feat-x.<repo>.localhost.
if [ "$WORKTREE" = "$REPO" ]; then
export DEV_HOST="${REPO}.localhost"
else
export DEV_HOST="${WORKTREE}.${REPO}.localhost"
fi
Then direnv allow. (Don't apex-special-case COMPOSE_PROJECT_NAME the same way on an already-onboarded repo — renaming the project orphans its existing volumes, including the database.)
docker-compose.yml — the HTTP-serving service gets Traefik labels and no ports: section; the file joins the external network:
services:
web:
labels:
- traefik.enable=true
- traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${DEV_HOST}`)
- traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=<container port>
networks: [default, devproxy]
networks:
devproxy:
external: true
Internal services (db, redis) keep only the default network and lose any host-port publishing too. Name volumes with plain aliases (no explicit name:) so compose prefixes them per project — that is the per-worktree data isolation. Give an explicit shared name: only to caches where cross-worktree reuse is the point (cargo registry, sccache, pnpm store).
Server must bind 0.0.0.0 inside the container, and framework Host-checking must admit .localhost:
- Vite:
server.allowedHosts: ['.localhost']; gate host on an
env var (process.env.VITE_HOST ?? 'localhost') so bare host-side
pnpm dev is unchanged; if HMR runs through the proxy, wire
server.hmr.clientPort from an env var and set it to 8000 in
compose.
- Next.js: dev command gets
-H 0.0.0.0.
- Multi-process stacks (API + frontend): either run them in one
container so existing
127.0.0.1 proxy targets keep working, or
keep an internal reverse proxy (Caddy/nginx) as the labeled service.
- Rust/Go binaries: pass
--host 0.0.0.0 or the bind env var.
CLAUDE.md — append a section telling agents the instance is at http://$DEV_HOST:8000 and verifiable with curl -s -H "Host: $DEV_HOST" localhost:8000.
Useful patterns from onboarded repos: single-container pnpm monorepo (arbor), build-then-serve with a one-shot builder service + service_completed_successfully dependency (duhem), full multi-service stack with an internal Caddy edge carrying the Traefik labels (onsager).
Daily usage
wt new feat/xyz # run inside the repo → branch + worktree + stack up
# → http://feat-xyz.<repo>.localhost:8000
wt rm feat/xyz # compose down -v + remove worktree (chown fallback built in)
wt ls # all worktree containers across repos
Worktrees land at <repo-parent>/<repo>-wt/<name>, sibling to the repo.
Laptop side, one tunnel serves every repo and worktree:
Host devbox
HostName <remote>
LocalForward 8000 127.0.0.1:8000
LocalForward 8080 127.0.0.1:8080
Host-run stacks (no containers)
When the containerized per-worktree stack is too slow as the primary loop (first build compiles the whole workspace into container-private volumes), a repo's native dev runner (just dev, pnpm dev, …) can join the same routing via Traefik's file provider — already enabled by the machine setup above. Pattern (reference implementation: onsager just dev, onsager-ai/onsager#579):
Auto-allocate ports, canonical-first: try the service's usual port, fall back to a random free one so parallel worktree stacks never collide. Bash picker:
pick_port() {
local p=$1
while (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; do
p=$(( (RANDOM % 20000) + 20000 ))
done
echo "$p"
}
Register the front door only: devproxy-route up <port> writes ~/dev-proxy/dynamic/$COMPOSE_PROJECT_NAME.yml mapping Host($DEV_HOST) → http://host.docker.internal:<port>. Point it at the one process that fronts the rest (e.g. Vite with a dev proxy for /api); backends only need to be reachable by that process, not by Traefik. devproxy-route down in the runner's EXIT trap.
Same framework rules as containers: the fronting process must bind 0.0.0.0 (host-gateway traffic arrives on a non-loopback interface), allow .localhost hosts, and aim its HMR websocket at the proxy port (clientPort: 8000) when reached through Traefik.
Containerized (wt new) and host-run stacks coexist behind one Traefik — routing is per-hostname, so each worktree independently picks whichever mode fits. Don't run both modes for the same worktree at once: two routers with an identical Host rule is a coin-flip.
Troubleshooting
| Symptom |
Cause / fix |
Traefik logs client version 1.24 is too old |
Old Traefik image vs Docker 29+. Use traefik:v3 (v3.5+). |
| 502 on a host-run stack's hostname |
Stale route file from a hard-killed runner (EXIT trap never ran), or the fronting process bound to 127.0.0.1 so host-gateway can't reach it. rm ~/dev-proxy/dynamic/<project>.yml or restart the runner; bind 0.0.0.0. |
| Every route 504s but containers are healthy |
Traefik dialing the wrong network. Ensure --providers.docker.network=devproxy. |
| 404 from Traefik |
No router matched: label interpolation empty (direnv not allowed / compose run without the env), or container not on devproxy. Check curl -s localhost:8080/api/rawdata. |
curl localhost:8000 exits 000 / hangs |
Corporate proxy env (ALL_PROXY etc.) hijacking loopback. export NO_PROXY=localhost,127.0.0.1,.localhost in the shell rc, or curl --noproxy '*'. |
| Vite answers 403 / "host not allowed" |
Missing server.allowedHosts: ['.localhost']. |
wt rm fails Permission denied |
Root-owned files written by containers into the bind mount. The bundled wt already chowns via a throwaway alpine container and falls back to rm -rf + git worktree prune. |
wt new works but the app 404s/504s briefly |
First boot is still installing/compiling inside the container — docker logs <project>-<service>-1 to watch progress. |
| Stack vanished after a docker daemon restart |
App services carry no restart: policy by design (only Traefik does). Re-run docker compose up -d in the worktree. |
1---2name: worktree-devproxy3description: Set up and operate a git-worktree parallel web-dev environment behind a shared Traefik proxy — every worktree runs its own docker compose stack, reachable at http://<worktree>.<repo>.localhost:8000 by Host-header routing, with zero host-port publishing, zero DNS, zero TLS, zero port allocation. Covers: one-time machine setup (Traefik on the `devproxy` network + the `wt` manager script + direnv), onboarding a repo (.envrc, compose Traefik labels, framework adaptations for Vite/Next/etc.), daily usage (wt new/rm/ls), host-run stacks joining the same routing without containers (Traefik file provider + devproxy-route + auto port allocation), and the known failure modes (Docker API version, wrong-network 504s, corporate proxy hijacking localhost, root-owned bind-mount files, stale host-run routes). Triggers on: "worktree dev environment", "devproxy", "wt new", "parallel dev stacks", "Traefik localhost routing", "onboard this repo to the worktree proxy", "set up worktree dev on this machine", "<name>.<repo>.localho4---5
6# worktree-devproxy
7
8One shared Traefik + one compose stack per git worktree. Each instance is reachable at `http://<worktree>.<repo>.localhost:8000`; a laptop reaches it through a single SSH tunnel. Nothing else touches the host network.
9
10```
11laptop browser → SSH tunnel (:8000) → Traefik (127.0.0.1 only)
12 → Host-header match → per-worktree compose stack (devproxy network)
13```
14
15Invariants (don't trade these away when adapting):
16
171. **No host ports** except Traefik's own `127.0.0.1:8000` (web) and `127.0.0.1:8080` (dashboard/API). Everything else flows over the shared external docker network `devproxy`.
182. **Routing is Host-header only** — `*.localhost` resolves to loopback by OS convention, so there is no DNS, no TLS, and no port allocation table.
193. **Worktree isolation is `COMPOSE_PROJECT_NAME`** (set by `.envrc` via direnv): independent networks, volumes, and container names per worktree.
20
21## One-time machine setup
22
23Prerequisites: `docker` (with compose plugin), `git`, `direnv` (hook it into the shell rc).
24
251. Copy [`references/dev-proxy.docker-compose.yml`](references/dev-proxy.docker-compose.yml) to `~/dev-proxy/docker-compose.yml`, `mkdir -p ~/dev-proxy/dynamic`, and `docker compose up -d` (run with `COMPOSE_PROJECT_NAME=dev-proxy` or from a direnv-free shell — a stray project name from another repo's `.envrc` creates a *second* Traefik that loses the :8000 bind race). Two flags in it are load-bearing, learned the hard way:
26 - `image: traefik:v3` (not an old pin) — Traefik ≤ v3.3 hardcodes
27 Docker API 1.24, which Docker Engine 29+ rejects outright.
28 - `--providers.docker.network=devproxy` — app containers sit on
29 `[default, devproxy]`; without the pin Traefik dials the default
30 network's IP it can't reach and every request 504s.
312. Install [`scripts/wt`](scripts/wt) and [`scripts/devproxy-route`](scripts/devproxy-route) to `~/bin/`, `chmod +x`, make sure `~/bin` is on PATH.
323. Verify: `curl -s --noproxy '*' localhost:8080/api/version` returns the Traefik version; `curl localhost:8000` returns 404 (no routes yet — correct).
33
34## Onboarding a repo
35
36Four pieces per repo, all **committed** — worktrees are created from HEAD, so uncommitted setup files silently don't propagate and `wt new` breaks.
37
381. **`.envrc`** at the repo root:
39
40 ```bash
41 export REPO=<repo-dir-name>
42 export WORKTREE=$(basename "$PWD" | tr '/_.' '-')
43 export COMPOSE_PROJECT_NAME="${REPO}-${WORKTREE}"
44 # Main checkout (dir name == repo) sits at the apex; worktrees are
45 # subdomains: <repo>.localhost vs feat-x.<repo>.localhost.
46 if [ "$WORKTREE" = "$REPO" ]; then
47 export DEV_HOST="${REPO}.localhost"
48 else
49 export DEV_HOST="${WORKTREE}.${REPO}.localhost"
50 fi
51 ```
52
53 Then `direnv allow`. (Don't apex-special-case `COMPOSE_PROJECT_NAME` the same way on an already-onboarded repo — renaming the project orphans its existing volumes, including the database.)
54
552. **`docker-compose.yml`** — the HTTP-serving service gets Traefik labels and **no `ports:` section**; the file joins the external network:
56
57 ```yaml
58 services:
59 web:
60 labels:
61 - traefik.enable=true
62 - traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${DEV_HOST}`)
63 - traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=<container port>
64 networks: [default, devproxy]
65
66 networks:
67 devproxy:
68 external: true
69 ```
70
71 Internal services (db, redis) keep only the default network and lose any host-port publishing too. Name volumes with **plain aliases** (no explicit `name:`) so compose prefixes them per project — that *is* the per-worktree data isolation. Give an explicit shared `name:` only to caches where cross-worktree reuse is the point (cargo registry, sccache, pnpm store).
72
733. **Server must bind `0.0.0.0`** inside the container, and framework Host-checking must admit `.localhost`:
74 - **Vite**: `server.allowedHosts: ['.localhost']`; gate `host` on an
75 env var (`process.env.VITE_HOST ?? 'localhost'`) so bare host-side
76 `pnpm dev` is unchanged; if HMR runs through the proxy, wire
77 `server.hmr.clientPort` from an env var and set it to `8000` in
78 compose.
79 - **Next.js**: dev command gets `-H 0.0.0.0`.
80 - Multi-process stacks (API + frontend): either run them in **one
81 container** so existing `127.0.0.1` proxy targets keep working, or
82 keep an internal reverse proxy (Caddy/nginx) as the labeled service.
83 - Rust/Go binaries: pass `--host 0.0.0.0` or the bind env var.
84
854. **CLAUDE.md** — append a section telling agents the instance is at `http://$DEV_HOST:8000` and verifiable with `curl -s -H "Host: $DEV_HOST" localhost:8000`.
86
87Useful patterns from onboarded repos: single-container pnpm monorepo (arbor), build-then-serve with a one-shot builder service + `service_completed_successfully` dependency (duhem), full multi-service stack with an internal Caddy edge carrying the Traefik labels (onsager).
88
89## Daily usage
90
91```
92wt new feat/xyz # run inside the repo → branch + worktree + stack up
93 # → http://feat-xyz.<repo>.localhost:8000
94wt rm feat/xyz # compose down -v + remove worktree (chown fallback built in)
95wt ls # all worktree containers across repos
96```
97
98Worktrees land at `<repo-parent>/<repo>-wt/<name>`, sibling to the repo.
99
100Laptop side, one tunnel serves every repo and worktree:
101
102```
103Host devbox
104 HostName <remote>
105 LocalForward 8000 127.0.0.1:8000
106 LocalForward 8080 127.0.0.1:8080
107```
108
109## Host-run stacks (no containers)
110
111When the containerized per-worktree stack is too slow as the primary loop (first build compiles the whole workspace into container-private volumes), a repo's native dev runner (`just dev`, `pnpm dev`, …) can join the same routing via Traefik's **file provider** — already enabled by the machine setup above. Pattern (reference implementation: onsager `just dev`, onsager-ai/onsager#579):
112
1131. **Auto-allocate ports, canonical-first**: try the service's usual port, fall back to a random free one so parallel worktree stacks never collide. Bash picker:
114
115 ```bash
116 pick_port() {
117 local p=$1
118 while (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; do
119 p=$(( (RANDOM % 20000) + 20000 ))
120 done
121 echo "$p"
122 }
123 ```
124
1252. **Register the front door only**: `devproxy-route up <port>` writes `~/dev-proxy/dynamic/$COMPOSE_PROJECT_NAME.yml` mapping `Host($DEV_HOST)` → `http://host.docker.internal:<port>`. Point it at the one process that fronts the rest (e.g. Vite with a dev proxy for `/api`); backends only need to be reachable *by that process*, not by Traefik. `devproxy-route down` in the runner's EXIT trap.
126
1273. **Same framework rules as containers**: the fronting process must bind `0.0.0.0` (host-gateway traffic arrives on a non-loopback interface), allow `.localhost` hosts, and aim its HMR websocket at the proxy port (`clientPort: 8000`) when reached through Traefik.
128
129Containerized (`wt new`) and host-run stacks coexist behind one Traefik — routing is per-hostname, so each worktree independently picks whichever mode fits. Don't run both modes for the *same* worktree at once: two routers with an identical Host rule is a coin-flip.
130
131## Troubleshooting
132
133| Symptom | Cause / fix |
134| --- | --- |
135| Traefik logs `client version 1.24 is too old` | Old Traefik image vs Docker 29+. Use `traefik:v3` (v3.5+). |
136| 502 on a host-run stack's hostname | Stale route file from a hard-killed runner (EXIT trap never ran), or the fronting process bound to `127.0.0.1` so host-gateway can't reach it. `rm ~/dev-proxy/dynamic/<project>.yml` or restart the runner; bind `0.0.0.0`. |
137| Every route 504s but containers are healthy | Traefik dialing the wrong network. Ensure `--providers.docker.network=devproxy`. |
138| 404 from Traefik | No router matched: label interpolation empty (direnv not allowed / compose run without the env), or container not on `devproxy`. Check `curl -s localhost:8080/api/rawdata`. |
139| `curl localhost:8000` exits 000 / hangs | Corporate proxy env (`ALL_PROXY` etc.) hijacking loopback. `export NO_PROXY=localhost,127.0.0.1,.localhost` in the shell rc, or `curl --noproxy '*'`. |
140| Vite answers 403 / "host not allowed" | Missing `server.allowedHosts: ['.localhost']`. |
141| `wt rm` fails `Permission denied` | Root-owned files written by containers into the bind mount. The bundled `wt` already chowns via a throwaway alpine container and falls back to `rm -rf` + `git worktree prune`. |
142| `wt new` works but the app 404s/504s briefly | First boot is still installing/compiling inside the container — `docker logs <project>-<service>-1` to watch progress. |
143| Stack vanished after a docker daemon restart | App services carry no `restart:` policy by design (only Traefik does). Re-run `docker compose up -d` in the worktree. |