Lab note

JUL 16 2026

One Caddy gateway for all your local Docker stacks

Raw text for LLM All posts Edit on GitHub

This one is a spin-off from the box and the loop. I mentioned a “little local gateway” in one sentence there and then thought - no, that deserves its own tiny post. Here it is.

The problem

The moment you have more than one project, local dev turns into a turf war.

Every app wants port 3000. Or 5173. Or 5432. You start running two compose stacks at once and Docker slaps you with port is already allocated. So you start hand-editing docker-compose.override.yml to bump one to 3001, another to 3002, and now your .env is a spreadsheet of magic numbers you have to remember. Add an agent into the mix - which spins stacks up and down on its own - and “which port is this one again?” becomes a genuine, recurring waste of everyone’s time.

I got tired of it, so now every project on my machine goes through one shared gateway.

The idea

Run a single Caddy Docker Proxy for the whole machine. Every app stack:

  • gets a unique, DNS-safe Compose project name using lowercase letters, digits and hyphens (offerlink-home-b21c),
  • keeps its DB / cache / queue on its own private network,
  • exposes only browser-facing services to a shared external network (dev-ingress),
  • and shows up at http://<project>.localhost.

No published host ports. No localhost:5173. Just names.

Caddy watches the Docker socket, sees labels on your containers, and wires up routing automatically. You never touch a Caddyfile - the containers describe themselves.

The gateway

Lives once, outside any app repo, and just stays running:

services:
  caddy:
    image: lucaslorentz/caddy-docker-proxy:2.12.1-alpine
    ports:
      - "80:80"
      - "443:443/tcp"
      - "443:443/udp"
    environment:
      CADDY_INGRESS_NETWORKS: dev-ingress
    networks:
      - dev-ingress
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

networks:
  dev-ingress:
    external: true

volumes:
  caddy_data:
  caddy_config:
docker network inspect dev-ingress >/dev/null 2>&1 || docker network create dev-ingress
docker compose -p dev-gateway -f ~/DEV/infra/local-docker-gateway/compose.yaml up -d

That’s the whole machine-level piece. Set it once, forget it.

The app

An app opts in with two things: join dev-ingress, and add labels telling Caddy where to send traffic.

This version assumes the frontend dev server proxies /api to http://api:3000 inside Docker. If it can’t, Caddy can route both paths instead.

services:
  frontend:
    build: ./frontend
    expose:
      - "5173"
    environment:
      VITE_API_BASE_URL: /api
    networks:
      - default
      - dev-ingress
    labels:
      caddy: "http://${COMPOSE_PROJECT_NAME}.localhost"
      caddy.reverse_proxy: "{{upstreams 5173}}"

  api:
    build: ./api
    expose:
      - "3000"
    environment:
      DATABASE_URL: postgres://postgres:postgres@db:5432/app
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data

networks:
  dev-ingress:
    external: true

volumes:
  pgdata:

The browser-facing process must listen on 0.0.0.0, not only 127.0.0.1, so Caddy can reach it from the gateway network.

Start it with an explicit project name and you’re done:

docker compose -p offerlink-home up -d --build
# → http://offerlink-home.localhost

The two rules that make it click

Everything above collapses into two habits:

  1. Inside Docker, talk by service name. db:5432, redis:6379, api:3000. Compose gives every service internal DNS for free - lean on it and you never publish a host port.
  2. In the browser, stay same-origin. The frontend calls /api, not http://api:3000 (that hostname only exists inside Docker anyway). Let Caddy route /api/* to the backend on the same hostname when the dev server can’t proxy it itself.

Break rule 1 and stacks collide again. Break rule 2 and the browser can’t reach your API. Follow both and you can run four apps side by side, all at friendly .localhost names, zero port bookkeeping.

Why I actually care

Beyond my own sanity: this is fantastic for agents. When I ask an agent to spin up a stack and test it, it reports back three lines -

Project: offerlink-home
URL:     http://offerlink-home.localhost
Stop:    docker compose -p offerlink-home down
  • and every downstream tool (Playwright, curl, the screenshot gallery) just uses a stable name. No “wait, what port did it pick this time?” No collisions when two tasks run in parallel. The agent reasons about the app, not a lottery of numbers.

I wrapped the whole convention into a skill so any agent gets it right by default. Small piece of plumbing, disproportionately good return.