One Caddy gateway for all your local Docker stacks
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:
- 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. - In the browser, stay same-origin. The frontend calls
/api, nothttp://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.