technical

Taming Localhost Ports with Caddy: Never Remember a Port Number Again

How I stopped juggling port numbers in my monorepo by using Caddy's wildcard routing. Now pregni-web.dev.localhost opens Pregni, sashka-marketing.dev.localhost opens Sashka. Simple, memorable, instant.

AuthorAlex Raihelgaus
Date
Taming Localhost Ports with Caddy: Never Remember a Port Number Again

Dev Dashboard showing all local services The end result: a simple dashboard with everything bundled up - I can see what's running and what's not at a glance.

If you work on multiple projects—especially in a monorepo—you know the pain. "Was Pregni on 5400 or 5450? Is the marketing site 4321 or 4322? Wait, which one is the API?"

I got tired of checking package.json files and running mental port arithmetic. So I fixed it with Caddy and wildcard localhost domains.

Now I just type pregni-web.dev.localhost and I'm there. No port numbers. No guessing.

The Problem: Port Number Hell

Here's a typical day in my monorepo:

  • localhost:5400 - Pregni web app
  • localhost:5450 - Pregni business portal
  • localhost:4321 - Sashka marketing site
  • localhost:4323 - Sashka API
  • localhost:6006 - Storybook... which one?

Five projects, five ports, and zero chance I'll remember which is which. Every time I switch contexts, I'm hunting through configs or trying random ports.

The Solution: Caddy + *.dev.localhost

Caddy is a modern web server with automatic HTTPS and dead-simple configuration. Its killer feature for local development: you can get HTTPS with trusted certificates using a wildcard subdomain.

Here's the entire setup:

*.dev.localhost {
    tls internal

    @pregni-web host pregni-web.dev.localhost
    handle @pregni-web {
        reverse_proxy localhost:5400
    }

    @pregni-biz host pregni-biz.dev.localhost
    handle @pregni-biz {
        reverse_proxy localhost:5450
    }

    @sashka-marketing host sashka-marketing.dev.localhost
    handle @sashka-marketing {
        reverse_proxy localhost:4321
    }

    @sashka-api host sashka-api.dev.localhost
    handle @sashka-api {
        reverse_proxy localhost:4323
    }

    handle {
        respond "App not configured in Caddyfile" 404
    }
}

That's it. Now:

  • https://pregni-web.dev.localhost → Pregni web app
  • https://pregni-biz.dev.localhost → Pregni business portal
  • https://sashka-marketing.dev.localhost → Sashka marketing site
  • https://sashka-api.dev.localhost → Sashka API

No ports to remember. Real HTTPS. Green padlock. Just names that make sense.

How It Works

1. The .localhost TLD

Browsers automatically resolve .localhost (and its subdomains) to 127.0.0.1. No /etc/hosts editing required. This is baked into the browser spec.

2. tls internal

This tells Caddy to generate its own certificates instead of trying Let's Encrypt (which can't reach your local machine). After running caddy trust, your browser accepts these certs with a green padlock.

3. The Wildcard Trick: *.dev.localhost

Here's the key insight that took me several failed attempts to discover:

  • *.localhostDoesn't work. Browsers reject wildcard TLS certs for the .localhost TLD itself.
  • *.dev.localhostWorks perfectly. Adding a subdomain level (dev) makes browsers accept the wildcard cert.

This means you only need to update the Caddyfile when adding new apps—no need to update a domain list or regenerate certificates.

4. Named Matchers

Each @name host subdomain.dev.localhost creates a named matcher. Caddy checks incoming requests against these matchers in order.

5. Reverse Proxy

When a matcher hits, reverse_proxy localhost:PORT forwards the request to your actual dev server. Your app doesn't know or care—it just sees normal traffic.

6. Fallback Handler

The final handle block catches anything that doesn't match. Instead of a confusing connection refused, you get a clear "App not configured" message.

Setting It Up

Step 1: Install Caddy

# macOS
brew install caddy

# Linux
sudo apt install caddy

# Or download from https://caddyserver.com/download

Step 2: Trust Caddy's CA

This is the key step most tutorials skip. Run once:

caddy trust

This installs Caddy's root CA into your system trust store. Now browsers will trust the internal certificates Caddy generates.

Step 3: Create Your Caddyfile

Create a Caddyfile in your project root:

*.dev.localhost {
    tls internal

    @your-app host your-app.dev.localhost
    handle @your-app {
        reverse_proxy localhost:YOUR_PORT
    }

    @another-app host another-app.dev.localhost
    handle @another-app {
        reverse_proxy localhost:ANOTHER_PORT
    }

    handle {
        respond "App not configured in Caddyfile" 404
    }
}

Step 4: Run Caddy

Caddy needs sudo for port 443 (HTTPS):

sudo caddy start --config Caddyfile

To reload after config changes:

sudo caddy reload --config Caddyfile

Step 5: Start Your Dev Servers

Start your apps on their usual ports. Caddy handles the routing.

# Terminal 1
moon pregni-web:dev  # Runs on :5400

# Terminal 2
moon sashka-marketing:dev  # Runs on :4321

Now open https://pregni-web.dev.localhost or https://sashka-marketing.dev.localhost in your browser. Green padlock included.

Why This Beats Alternatives

vs. Editing /etc/hosts

  • No sudo required for adding apps
  • No manual IP mappings
  • True wildcard support
  • Easy to add new projects (just add a handler block)

vs. Browser Bookmarks with Ports

  • URLs are shareable and memorable
  • Works in terminal tools (curl, httpie)
  • Matches production URL patterns
  • No mental translation needed

vs. Docker/Traefik

  • Zero container overhead
  • Works with any dev server
  • Simpler configuration
  • Faster startup

Pro Tips

Naming Convention

Use consistent, predictable names:

# Pattern: {project}-{app}.dev.localhost
@pregni-web host pregni-web.dev.localhost
@pregni-biz host pregni-biz.dev.localhost
@pregni-api host pregni-api.dev.localhost

Adding New Apps

When you add a new app, just add a handler block and reload:

sudo caddy reload --config Caddyfile

No need to update a domain list or restart—the wildcard cert already covers it.

If Certs Stop Working

Sometimes after system updates, the CA trust breaks:

caddy untrust && caddy trust

Then reload Caddy.

API Routes

Works great for APIs too. Your frontend can call https://sashka-api.dev.localhost instead of http://localhost:4323—which also solves mixed-content issues if your frontend is on HTTPS.

What Didn't Work (So You Don't Have To Try)

Before landing on this solution, I tried several approaches that failed:

Wildcard *.localhost: Browsers reject wildcard TLS certs for the .localhost TLD directly. You get ERR_CERT_COMMON_NAME_INVALID.

Explicit domain list: Works (pregni-web.localhost, pregni-biz.localhost {...}), but you have to update the list and reload for every new app. More friction.

Let's Encrypt for local domains: ACME challenges can't reach 127.0.0.1 from the internet. Even pointing a real domain to 127.0.0.1 doesn't help.

HTTP only: Works, but no green padlock, and you get mixed-content issues when your frontend is HTTPS in production.

The key insight: add a subdomain level. *.dev.localhost works where *.localhost doesn't.

Bonus: A Dev Dashboard

Once I had all these apps behind nice URLs, I realized I still had one problem: which apps are actually running right now?

So I built a simple dashboard at https://start.dev.localhost:

  • Green dot = app is running
  • Red dot = app is offline
  • Auto-refreshes every 5 seconds
  • Click any app to open it

The dashboard is just a static HTML file that Caddy serves. It checks each port directly via fetch('http://localhost:PORT') to detect if something is listening—this avoids CORS issues that would occur if checking via the Caddy URLs.

Now start.dev.localhost is my browser homepage for development. One glance tells me what's running.

Solving the OAuth Whitelist Problem

Here's another pain point this setup solves: OAuth providers and third-party services that need callback URLs whitelisted.

The problem:

  • Google OAuth requires HTTPS for redirect URIs (except localhost, but inconsistently)
  • Supabase Auth needs whitelisted redirect URLs per environment
  • Some services reject localhost:PORT entirely
  • You end up with different callback URLs for dev vs staging vs prod
  • Or you reach for ngrok, which adds latency and is yet another tool to manage

With Caddy, your callback URLs look like production:

https://myapp.dev.localhost/auth/callback

Instead of:

http://localhost:3000/auth/callback

One URL pattern across environments. Whitelist *.dev.localhost once in your OAuth provider, and every new app just works. No more per-app, per-port configuration.

This also means your auth code doesn't need environment-specific URL logic. The same redirect URI works locally and in production—just swap dev.localhost for your real domain.

My Current Setup

Here's what I'm running in my monorepo:

App URL Port
Dashboard https://start.dev.localhost -
Pregni
pregni-web https://pregni-web.dev.localhost 5400
pregni-biz https://pregni-biz.dev.localhost 5450
storybook-pregni https://storybook-pregni.dev.localhost 6007
Sashka
sashka-marketing https://sashka-marketing.dev.localhost 4321
sashka-api https://sashka-api.dev.localhost 4323
sashka-clients https://sashka-clients.dev.localhost 4324
storybook-design-system https://storybook-design-system.dev.localhost 6006

Eight apps, one Caddyfile, one dashboard, zero ports to remember.

Bonus: Git Worktrees and AI Agents

If you're using git worktrees—especially with AI coding agents that need isolated environments—this setup becomes even more valuable.

The problem: you have your main repo at sashka-content/, and worktrees like sashka-moon/, sashka-feature-x/. Each might run the same app on a different port. Now you're back to "which port is which worktree?"

The solution: a naming convention + Caddy's map directive + a systematic port allocation scheme.

Port Allocation Scheme

To avoid conflicts with common services (databases, dev servers, etc.), use 5-digit ports starting at 10000. Each monorepo gets a 1000-port range, and each app within it gets a 10-port range for worktrees:

Monorepo Range Capacity
Sashka 10000-10999 100 apps × 10 worktrees
Pregni 11000-11999 100 apps × 10 worktrees
Future 12000-12999 ...

Within each project:

App 0:  X0000-X0009  (main: X0000, worktree-1: X0001, ...)
App 1:  X0010-X0019
App 2:  X0020-X0029
...

Example for Sashka:

sashka-api:        10000-10009  (main: 10000, moon: 10001, ...)
sashka-marketing:  10010-10019  (main: 10010, moon: 10011, ...)
sashka-clients:    10020-10029

The Caddyfile

*.dev.localhost {
    tls internal

    map {labels.2} {backend_port} {
        # Sashka apps (10000-10999)
        sashka-api                10000
        sashka-api-moon           10001
        sashka-marketing          10010
        sashka-marketing-moon     10011

        # Pregni apps (11000-11999)
        pregni-web                11000
        pregni-web-moon           11001

        default                   10000
    }

    reverse_proxy localhost:{backend_port}
}

Why This Matters for AI Agents

When you're running multiple AI agents (Claude, Cursor, etc.) on different worktrees simultaneously, each agent needs its own isolated environment. Without this setup, you'd have:

  • Agent 1 on sashka-content/ → port 10010
  • Agent 2 on sashka-moon/ → port... wait, also 10010? Or did I change it?

With the Caddy setup:

  • sashka-marketing.dev.localhost → main branch, port 10010
  • sashka-marketing-moon.dev.localhost → moon worktree, port 10011

Each agent gets its own URL. No port conflicts. No confusion.

Auto-Generating the Caddyfile

Since Caddy doesn't support arithmetic (you can't say "base port + worktree number"), you either maintain the map manually or generate it.

Here's a script that scans your worktree directories and generates the port mappings:

#!/usr/bin/env bash
# generate-worktree-caddy.sh
# Place this in the parent directory of your worktrees

BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_FILE="$BASE_DIR/Caddyfile.worktrees"

# Port allocation: each app gets 10 ports (base + 9 worktrees)
# Sashka: 10000-10999, Pregni: 11000-11999
APP_NAMES=("sashka-api" "sashka-marketing" "sashka-clients" "pregni-web" "pregni-api")
APP_PORTS=(10000 10010 10020 11000 11010)

# Which directory is "main" (not a worktree)
MAIN_DIR="sashka-content"

cat > "$OUTPUT_FILE" << 'HEADER'
# Auto-generated Caddyfile for worktree routing
*.dev.localhost {
    tls internal

    map {labels.2} {backend_port} {
HEADER

# Add base apps (main directory)
for i in "${!APP_NAMES[@]}"; do
    printf "        %-30s %s\n" "${APP_NAMES[$i]}" "${APP_PORTS[$i]}" >> "$OUTPUT_FILE"
done

# Scan for worktree directories
PORT_OFFSET=1
for dir in "$BASE_DIR"/sashka-*/; do
    dirname=$(basename "$dir")
    [[ "$dirname" == "$MAIN_DIR" ]] && continue

    suffix="${dirname#sashka-}"
    for i in "${!APP_NAMES[@]}"; do
        worktree_port=$((${APP_PORTS[$i]} + PORT_OFFSET))
        printf "        %-30s %s\n" "${APP_NAMES[$i]}-$suffix" "$worktree_port" >> "$OUTPUT_FILE"
    done
    ((PORT_OFFSET++))
done

cat >> "$OUTPUT_FILE" << 'FOOTER'

        default    10000
    }

    reverse_proxy localhost:{backend_port}
}
FOOTER

echo "Generated: $OUTPUT_FILE"

Run it whenever you add a new worktree, then reload Caddy:

./generate-worktree-caddy.sh
sudo caddy reload --config Caddyfile.worktrees

The Workflow

  1. Create a worktree: git worktree add ../sashka-feature-x feature-x
  2. Run the generator script
  3. Start your app on the assigned port (check the generated Caddyfile)
  4. Access it at sashka-marketing-feature-x.dev.localhost

No more port juggling. No more "which port is which worktree?" Just predictable URLs for every environment.

The Result

Before:

  • "What port is Pregni again?"
  • opens 5 browser tabs with different ports
  • checks package.json for the 10th time
  • Mixed-content warnings when API is HTTP and frontend is HTTPS

After:

  • Type pregni-web.dev.localhost
  • Green padlock
  • Done

It's a small thing, but it removes friction from context switching. And in a monorepo with many apps, that friction adds up.


The best developer experience improvements are the ones you stop thinking about. Caddy with local HTTPS is one of those—set it up once, never think about ports again.


References

Tags

#technical#devex#caddy#monorepo#developer-tools