Docs / Networking
Networking stack
Every worktree gets its own port. The networking layer gives those ports human-readable names, stable URLs for third-party integrations, and optional public access. This page explains the architecture, the design decisions behind it, and how the pieces fit together.
The core problem
When you run multiple worktrees, each server needs its own port. Typing localhost:3014 into your browser works, but nobody remembers which port belongs to which branch. Worse, OAuth providers, Stripe webhooks, and CORS policies all expect a fixed origin. Changing that config every time you switch branches is not sustainable.
GTL solves this with two layers of abstraction over raw port numbers: branch URLs for your browser and a proxy for anything external that expects a fixed origin.
Port allocation
GTL's port allocator is built around one principle: common development ports stay free so the developer controls what listens on them.
Worktree servers never bind to ports like 3000, 4000, 5000, 8080, or 8888. Those are reserved for tools the developer explicitly chooses: gtl proxy, a debugging proxy, a test runner, or anything else that expects a well-known port.
| Port | Reserved for | Why |
|---|---|---|
| 3000 | Developer / gtl proxy | The port most OAuth, webhook, and CORS configs point to. Always available for the proxy facade. |
| 3001 | HTTPS router (gtl serve) | Maps branch-named subdomains to worktree ports. Port 443 is forwarded here. |
| 3002+ | Worktree allocations | Starting base for dynamic allocation. You rarely type these directly. |
How allocation works
The allocator walks from the port base (default 3002) in increments (default 2), looking for a contiguous block of ports that are:
- Not already allocated to another worktree
- Not in the reserved common ports list
- Not the router port
- Not a browser-blocked port (WHATWG spec)
- Actually free on the loopback interface (tested with
net.Listen)
The increment value (default 2) determines the reservation block size. With an increment of 2 and port_count: 1, each worktree gets a 2-port block even though it only uses one. This leaves room for future multi-port support without reallocating.
Projects needing multiple ports set port_count in .treeline.yml. The allocator then finds a contiguous block of that size. The ports are exposed as {port}, {port_2}, {port_3}, etc. in env interpolation.
Port reservations
You can pin a project or specific branch to a fixed port via user config:
$ gtl config set port.reservations.myapp 3010
$ gtl config set port.reservations.myapp/main 3020
A project-level reservation (myapp) applies to the main worktree. A project/branch reservation (myapp/main) targets a specific branch. Reservations reserve the full increment block size (not just one port), preventing other worktrees from being allocated nearby.
Two ways to reach a worktree
Both are always available simultaneously. You don't have to choose one or the other.
| Branch URL | Direct loopback | |
|---|---|---|
| URL | https://myapp-feature.prt.dev | http://localhost:3010 |
| Protocol | HTTPS (locally trusted CA) | HTTP |
| Requires router | Yes (gtl serve) | No |
| Host header | The subdomain (preserved through proxy) | localhost |
| Best for | Browser, cross-service links, CORS, session cookies | Direct API calls, scripts, CI |
The HTTPS router (gtl serve)
The router is a pure Go reverse proxy that runs as a background service. It maps {project}-{branch}.{domain} subdomains to local ports, adding HTTPS with a locally-trusted CA.
How it works
gtl serve installgenerates a local ECDSA certificate authority (10-year lifetime) and installs it in your system trust store.- Port forwarding (macOS
pf, Linuxiptables/nft) redirects port 443 to the router port (3001), so URLs don't need a port number. On macOS, a small LaunchDaemon at/Library/LaunchDaemons/dev.treeline.pfreload.plistre-enables pf at boot — without it, Apple's signed pf service would load the rules but leave pf disabled, silently breaking the redirect after every reboot. - A
launchd(macOS) orsystemd(Linux) service keeps the router running across reboots.gtl serve restartbounces it vialaunchctl kickstartwith no sudo or plist rewrite. - Every 5 seconds, the router reads the GTL registry and rebuilds its route table. New worktrees appear automatically.
- Per-hostname TLS certificates are issued on demand (SAN includes the hostname + localhost + loopback IPs). No wildcard certs are used due to Safari/Chromium issues with
*.localhost. - Incoming requests match by subdomain, and the router reverse-proxies to the target port on loopback. The original
Hostheader is preserved.X-Forwarded-ProtoandX-Forwarded-Forare set. - Loop detection: an
X-Gtl-Hopsheader is incremented on each pass. At 5 hops, the router returns HTTP 508 with debugging hints (common with Vite, webpack, or Next.js dev servers that proxy back to themselves).
The prt.dev domain
Fresh installs default to prt.dev as the router domain. This is a real domain with wildcard DNS pointing *.prt.dev to 127.0.0.1. No local DNS server or /etc/hosts editing needed for most setups.
On macOS, GTL also writes /etc/hosts entries as a fallback (Safari resolves .localhost domains inconsistently). On Linux, hosts entries are written when using a non-localhost domain.
If you're offline or behind a corporate DNS that blocks external resolution, you can switch to router.domain localhost via gtl config. The .localhost TLD resolves locally by spec (RFC 6761), but some browsers handle it inconsistently.
Aliases
Aliases give a stable hostname to a port that isn't derived from a branch name. Two sources:
- User aliases:
gtl serve alias redis-ui 8081createshttps://redis-ui.prt.devrouting to port 8081. Stored in user config. - Project aliases: Defined in
.treeline.ymlunderaliases:. Each entry maps a name to a port offset. Resolved per-worktree from the registry.
Aliases are picked up by the router on its 5-second refresh cycle. No restart needed. If a registry route and an alias share the same name, the registry route wins.
The most common use case for aliases is OAuth: register a stable alias like myapp-oauth.prt.dev in your provider dashboard once, then use a start hook to point it at whichever branch you're testing. See OAuth callbacks across worktrees for the full walkthrough.
The port proxy (gtl proxy)
The proxy binds a well-known port (like 3000) and forwards traffic to whichever worktree you run it from. It solves a different problem than the router: third-party services that are hardcoded to localhost:3000 and can't use branch-named subdomains.
~/feature-auth $ gtl proxy 3000
Proxying :3000 → :3010
# With local HTTPS
~/feature-auth $ gtl proxy 443 --tls
Proxying https://localhost:443 → :3010
When you omit the target port, the proxy reads the registry for the current directory and forwards to the first allocated port. This means you configure your OAuth provider to localhost:3000 once, and rotate which worktree sits behind it by running gtl proxy 3000 from different worktree directories.
The proxy handles HTTP, WebSocket upgrades, and SSE streams. The Host header is preserved. The --tls flag terminates HTTPS on the listen side using a separate cert resolution: cached cert, then mkcert if installed, then self-signed fallback.
See Why port 3000 stays free for the full rationale.
Router vs proxy: when to use which
| Scenario | Use |
|---|---|
| Opening a branch in the browser | Router (myapp-feature.prt.dev) |
Cross-service {resolve:...} links between repos | Router (automatic via env tokens) |
OAuth callback registered to localhost:3000 | Proxy (gtl proxy 3000) |
| OAuth with a branch-stable alias | Router + alias (gtl serve alias) |
Stripe webhook to localhost:3000 | Proxy (gtl proxy 3000) |
| Showing work to a remote teammate | gtl tunnel or gtl share |
| Script or CI hitting a local server | Direct loopback (localhost:{port}) |
Public access: tunnels and sharing
Both features expose a local worktree to the internet via Cloudflare tunnels, but they serve different trust boundaries.
gtl tunnel
Quick mode (no account): generates a disposable *.trycloudflare.com URL. No setup, no config, but the URL changes every run.
Named mode (your domain): after running gtl tunnel setup, every gtl tunnel maps to {routeKey}.yourdomain.dev using the same route key convention as the local router. Stable, predictable, and branch-specific. Multiple Cloudflare domains are supported.
Running gtl tunnel from two worktrees at once on the same named tunnel is safe: the first invocation forks a lazy per-tunnel-name daemon under ~/.cloudflared/ (socket mode 0600), subsequent invocations register their hostname+port with it, and the daemon owns the single cloudflared with a merged multi-host ingress. The daemon idle-exits about 5s after the last client disconnects; an unexpected cloudflared exit propagates a real error to every client instead of leaving you staring at a dead URL.
gtl share
Creates a token-gated URL for quick demos. The recipient authenticates with a one-time token, then gets session cookies. Hostnames and tokens rotate on every run. Ctrl+C tears everything down.
gtl share --tailscale uses Tailscale Serve instead for tailnet-only access with identity-based auth. Mutually exclusive with --tunnel.
Route key convention
The router, tunnel, and share commands all derive URLs from the same route key. The algorithm:
- Project and branch names are lowercased
- Common branch prefixes stripped:
feature/, chore/, bugfix/, hotfix/ - Slashes and underscores become dashes
- Non-alphanumeric characters (except dashes) are removed
- Consecutive dashes collapsed
- DNS label truncated to 63 characters (with a short hash suffix if truncation was needed)
- Result:
{project}-{branch}
Branch Route key URL main myapp-main myapp-main.prt.dev feature/auth-flow myapp-auth-flow myapp-auth-flow.prt.dev bugfix/fix_login_crash myapp-fix-login-crash myapp-fix-login-crash.prt.dev
Networking env tokens
GTL interpolates networking-related tokens into your .treeline.yml env section and commands.start:
Token Expands to Context {port} Primary allocated port (e.g. 3010) env, commands.start, hooks {port_N} Nth allocated port ({port_2} = port+1) env, commands.start, hooks {router_domain} The configured router domain (e.g. prt.dev) env only {router_url} https://{routeKey}.{domain} env only {resolve:project} http://127.0.0.1:{port} of another project's allocation env only {resolve:project/branch} Same, targeting a specific branch env only
Hooks have limited interpolation. Hook commands only support {port} and {port_N}. Tokens like {router_domain} and {resolve:...} are not available in hook commands. If a hook needs those values, reference the environment variables that GTL writes to the env file.
Edge cases and limitations
- Only the first port is routed. If your project uses
port_count: 3, only the primary port gets a subdomain on the router. Secondary ports are accessible via localhost:{port_N} directly. - Windows.
gtl serve install does not support Windows (WSL2 planned). Windows users fall back to localhost:{port} without branch-named HTTPS URLs. - Offline DNS. If
prt.dev DNS can't resolve (corporate firewall, no internet), branch URLs won't work unless /etc/hosts entries cover them. Switching to router.domain localhost avoids this. - gtl resolve vs {resolve:...}. The
gtl resolve CLI command rewrites the output to the router URL if the router is running. The {resolve:...} env token always expands to http://127.0.0.1:{port}. They're different by design: env tokens point directly at the backend, the CLI gives you the browsable URL. - Alias precedence. If a registry route and an alias share the same name, the registry route wins. Aliases are intended for ports that don't have an automatic registry entry.
Configuration reference
Networking settings live in user config (gtl config), not in .treeline.yml. They're per-machine, not per-project.
Key Default Description port.base 3002 Starting port for worktree allocation. port.increment 2 Step size between allocations. Also the reservation block size. port.reservations Map of project or project/branch to a fixed base port. router.port 3001 Port the HTTPS router listens on. Must not equal port.base. router.domain prt.dev Base domain for branch-named URLs. Fresh installs default to prt.dev. router.aliases User-defined alias name → target port. tunnel.name — Cloudflare tunnel name. Set by gtl tunnel setup. tunnel.domain — Domain for named tunnel subdomains. Set by gtl tunnel setup.
See it in action
- Why port 3000 stays free: the proxy facade for OAuth and webhooks.
- OAuth callbacks across worktrees: using aliases and start hooks for stable callback URLs.
- Local services: cross-worktree resolution with
{resolve:...}. - Networking feature page: the marketing overview.