Named Localhost Proxies for Development

Cloud & Infrastructure intermediate Building with AI ~30 min read

Overview

Local development environments often start with raw ports: the frontend runs on localhost:3000, the API on localhost:3001, documentation on localhost:3002, and each developer or worktree eventually collides with a different process. That works for a single server, but it becomes brittle when a stack needs realistic origins, cookies, service workers, OAuth callbacks, WebSocket endpoints, or multiple app copies running side by side.

A named localhost proxy solves this by putting a stable routing layer in front of development servers. Each app binds an ephemeral private port, registers a hostname such as web.localhost or api.myapp.localhost, and the proxy listens on a standard HTTP or HTTPS port. Browsers and clients use the named URL; the proxy forwards each request to the current process behind that route.

Vercel Labs Portless demonstrates this pattern for JavaScript development servers. It assigns a free app port, injects the PORT environment variable or framework-specific port flags, starts a local proxy, generates a trusted local certificate authority for HTTPS, and maps hostnames under .localhost or another reserved development TLD. The transferable idea is not the specific CLI; it is the architecture: separate human-facing origins from process ports and make local routing explicit, repeatable, and safe.

This topic covers how named local proxies work, when to use them, how to design route registration, TLS, host resolution, subdomains, and LAN exposure, and what failure modes to avoid when development traffic starts looking more like production traffic.

Core Concepts

Stable Origin, Ephemeral Process Port

The core split is between the origin developers type into the browser and the port where the framework process listens. The origin should be stable because OAuth callbacks, cookies, browser storage, service workers, CORS policies, and screenshots all key off hostnames and schemes. The app process port can be disposable as long as the proxy updates its route table.

Route Registration

A named proxy needs a source of truth mapping hostnames to backend ports. For development tools, this is usually a local state file or daemon API. When a command such as run web npm run dev starts, the wrapper chooses a free port, launches the child process with that port in its environment, and registers web.localhost -> 127.0.0.1:4173 with the proxy.

Special-Use Development Names

The .localhost name is reserved for loopback use, and many browsers treat names ending in .localhost as local. The .test name is also reserved for testing and documentation scenarios, but it usually requires host-file or resolver setup. Avoid casually using real public TLDs or .local for ordinary browser development: public TLDs can collide with real DNS policy, and .local is conventionally associated with multicast DNS on local networks.

Key Concept: Origin Stability

A stable named origin turns local development into an origin-management problem instead of a port-management problem. This makes browser behavior predictable and lets every service in a local stack have a production-like URL without requiring production-like infrastructure.

How It Works

Process Wrapper and Proxy Route Table

The wrapper process is responsible for turning an ordinary dev command into a routable service. It chooses a port, configures the child, registers the route, and tears the route down when the child exits. The proxy remains long-lived so browser tabs can keep a stable target even as the backend process restarts.

# Example developer workflow
devproxy run web npm run dev
# Browser URL: https://web.localhost
# Internal backend: http://127.0.0.1:4317

devproxy run api go run ./cmd/api
# Browser URL: https://api.localhost
# Internal backend: http://127.0.0.1:4820
// Simplified route registration model.
type Route struct {
  Host       string
  TargetHost string
  TargetPort int
  TLS        bool
  StartedAt  time.Time
}

func runWithProxy(name string, command []string) error {
  port := pickFreePort(4000, 4999)
  host := name + ".localhost"

  child := exec.Command(command[0], command[1:]...)
  child.Env = append(os.Environ(),
    fmt.Sprintf("PORT=%d", port),
    "HOST=127.0.0.1",
    "PUBLIC_URL=https://"+host,
  )

  if err := proxy.Register(Route{
    Host: host,
    TargetHost: "127.0.0.1",
    TargetPort: port,
    TLS: true,
    StartedAt: time.Now(),
  }); err != nil {
    return err
  }

  defer proxy.Unregister(host)
  return child.Run()
}

Request Routing

At runtime, the proxy inspects the HTTP Host header or TLS Server Name Indication value, looks up the route, and forwards the request to the backend. If the app process restarts on a new port, only the route table changes; the browser URL stays the same.

func proxyRequest(w http.ResponseWriter, r *http.Request) {
  route, ok := routes.Lookup(r.Host)
  if !ok {
    http.Error(w, "unknown local route", http.StatusNotFound)
    return
  }

  target := &url.URL{
    Scheme: "http",
    Host: net.JoinHostPort(route.TargetHost, strconv.Itoa(route.TargetPort)),
  }

  reverseProxy := httputil.NewSingleHostReverseProxy(target)
  reverseProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
    http.Error(w, "local backend unavailable", http.StatusBadGateway)
  }
  reverseProxy.ServeHTTP(w, r)
}

Local HTTPS

Modern browser features increasingly assume secure contexts. A named proxy can provide HTTPS locally by creating a private local certificate authority, installing its certificate into the system trust store, and serving certificates for the development hostnames. Tools such as mkcert popularized this model: the root CA stays on the developer machine, and generated certificates are trusted only because that machine trusts its own CA.

Practical Applications

Multi-Service Application Development

A web application with a frontend, backend API, documentation server, webhook receiver, and Storybook instance becomes easier to reason about when each service has a named origin. Instead of remembering which app is on which port, developers use web.localhost, api.localhost, docs.localhost, and storybook.localhost. The names also make logs, screenshots, and bug reports more durable because the URLs do not depend on each developer’s current port allocation.

OAuth, Cookies, and Browser Storage

Authentication flows are sensitive to exact callback URLs. Stable local hostnames let developers register one callback such as https://auth.myapp.localhost/callback instead of constantly updating provider configuration for changing ports. Cookies and local storage also become cleaner because the origin represents the app role, not the incidental process port from one run.

Parallel Worktrees and Branch Testing

When developers use git worktrees, two branches of the same app may run simultaneously. A proxy can namespace routes by worktree or branch, such as main.web.localhost and fix-login.web.localhost. That avoids forcing developers to stop one branch before testing another and makes side-by-side comparison practical.

LAN Testing on Real Devices

Sometimes the browser is not on the same machine as the dev server: mobile testing, tablet review, embedded devices, and QA stations need to reach a developer’s laptop. A proxy can switch from loopback-only hostnames to LAN-advertised names using multicast DNS and the machine’s current LAN IP. This is useful, but it changes the threat model because the service is no longer private to the local machine.

Implementation Patterns

Strict Host Registration

Default to strict mode: only explicitly registered hostnames should route. Wildcard routing is convenient for tenant subdomains and preview URLs, but it can also hide typos or route unexpected hostnames to the wrong backend. A safe design lets developers opt into wildcard fallback per app.

routes:
  web.localhost:
    target: 127.0.0.1:4317
    wildcard: false
  myapp.localhost:
    target: 127.0.0.1:4480
    wildcard: true

# tenant1.myapp.localhost may fall back to myapp.localhost
# typo.web.localhost should fail unless web explicitly enables wildcard routing

Framework-Aware Port Injection

Many frameworks respect the PORT environment variable, but not all of them do. Robust wrappers maintain a small adapter table: some commands need --port, some need --host, and some mobile toolchains should keep their default LAN behavior. The proxy should fail with a clear message when it cannot safely control the backend port.

function frameworkArgs(command, port, lanMode) {
  switch (detectFramework(command)) {
    case "vite":
      return [...command, "--port", String(port), "--host", "127.0.0.1"];
    case "next":
      return command; // Next.js reads PORT in common setups.
    case "expo":
      return lanMode ? command : [...command, "--host", "localhost"];
    default:
      return command;
  }
}

Host-File Synchronization Fallback

Some browsers and operating-system resolver paths handle .localhost subdomains differently. A practical proxy includes a host-file sync command that writes only the managed route block and can cleanly remove it. This fallback should be auditable, idempotent, and explicit because it may require elevated privileges.

Loop Detection for App-to-App Proxying

Frontend dev servers often proxy /api to a backend. If the frontend forwards requests to another named local app without rewriting the Host header, the outer proxy may route the request back to the frontend and create a loop. Good tooling detects repeated proxy headers or mismatched hostnames and returns a diagnostic error instead of hanging.

Performance Considerations

Common Pitfalls

Further Reading