--- title: 'One Caddy gateway for all your local Docker stacks' description: 'How I run a bunch of isolated Docker Compose apps locally at once without fighting over port 3000 - one shared Caddy Docker Proxy gateway and project.localhost hostnames.' pubDate: 'July 16, 2026' tags: ['Infra', 'Docker'] --- This one is a spin-off from [the box and the loop](/blog/how-i-build-small-products-autonomously-hetzner-swarm-and-agent-loops). 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://.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: ```yaml 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: ``` ```bash 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. ```yaml 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: ```bash 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 - ```txt Project: offerlink-home URL: http://offerlink-home.localhost Stop: docker compose -p offerlink-home down ``` - and every downstream tool (Playwright, curl, the [screenshot gallery](/blog/let-the-agent-show-me-the-ui-screenshot-galleries)) 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.