For a long time my local development was a wall of ports. This site on localhost:3000, its production container on :8080, a Postgres on :5432, some other project's API on :4000, a mail inbox on whatever port was free that day. Every project was a different number I had to remember, and none of it looked anything like production. Local was the lesser environment, the place where things half-worked until I pushed them somewhere real. I stopped accepting that. Here is how local now rehearses production instead of approximating it, across every project I run.
The problem with localhost:PORT
A bare port is not just ugly, it lies to you. Production runs on HTTPS at a real hostname. Local runs on plain HTTP at localhost:3000. Those are different enough that whole categories of behavior only show up in one of them. Secure cookies get dropped. Redirects between www and the apex do not fire. Anything that cares about the scheme or the host, which is most auth and a lot of caching, behaves one way on your laptop and another way in the cloud. You find out in production, which is the worst place to find out.
And the ports themselves are just cognitive tax. Three projects open means three numbers to keep straight, plus their databases, plus their side services. I was spending real attention on remembering which tab was which.
One Caddy, one network per project
The fix came from Patrick Taylor, who built this pattern out for his projects and handed it to me. It is simple and it is right: run a reverse proxy as its own long-lived container, join it to a Docker network per project, and let it hand each app a real name behind real TLS.
Concretely, one Caddy container (caddy:2, mine started life as leagueos-caddy) stays running in the background. Each project declares an external Docker network, and the app container joins that network under a known alias. Caddy joins the same network, resolves the app by its alias, and reverse-proxies to it. Add a project and you connect Caddy to that project's network and reload it. One physical Caddy ends up wired into every project's network at once.
The detail that matters: it is a separate edge network per project, not one shared bus. This site has shirepath-edge. League-OS has leagueos-edge. Each stack owns its own front door and joins Caddy to it, so projects stay isolated from each other even though one proxy serves them all. Nothing leaks across the boundary, and I can tear one project's network down without touching the rest.
What this site got
For this site, the wiring is small. The web container joins the shirepath-edge network under the alias shirepath-dev-web, and a small Caddy drop-in does the rest:
www.local.shirepathsolutions.com {
tls internal
reverse_proxy shirepath-dev-web:3000
}
Now the site answers at https://www.local.shirepathsolutions.com, real hostname, real HTTPS, no port to remember.
The tls internal line is the part worth understanding. The hostname ends in .com, so Caddy assumes it is a public domain and tries to get a real certificate from Let's Encrypt. It never can, because the name only resolves to 127.0.0.1 on my machine, so it would loop forever chasing a certificate for a subdomain of my actual production domain. tls internal tells Caddy to sign the certificate with its own local certificate authority instead. I trust that authority once, and every local hostname it issues is trusted after that.
Two honest notes. The container runs the exact production image I ship to AWS, so there is no hot reload here. This path is for production parity, not for fast iteration. The fast loop is still pnpm dev on localhost:3000, and http://localhost:8080 still serves the container directly if I want it without the proxy. The Caddy layer is local only. In production the front door is CloudFront, not Caddy. There is no reverse proxy of mine running in AWS.
A real inbox, not a real send
The same trick solves a second problem for the projects that send email. This marketing site is not one of them. It collects no input and sends no mail, contact is a phone number and a mailto link, so there is nothing here to catch. League-OS is a different story, and it shows the pattern at full strength.
League-OS runs its own Mailpit container (axllent/mailpit:v1.30.3) on its own edge network. Mailpit is a fake SMTP server with a web inbox: the app sends real email to it, and instead of going out to a real person, every message lands in a local inbox you can open in the browser. The app points its mailer at the container with two environment variables:
SMTP_HOST=mailpit
SMTP_PORT=1025
and the same Caddy that fronts the app also fronts the inbox at https://mail.local.leagueos.app. So the developer experience is: trigger a password reset, an invite, a receipt, whatever, then open the inbox hostname and read exactly what the user would have received, rendered, with the real template and the real data. Nothing leaves the laptop. No test send ever reaches a real address. The SMTP port stays internal to the network, so the fake mail server is not even reachable from outside the stack.
That is the whole point of doing it this way. The app is genuinely sending email in development, over SMTP, the same code path as production. It is just sending it into a trap.
Why it all runs in containers
Caddy is a container. Mailpit is a container. So are the databases and the object storage and everything else these stacks lean on. None of it is a brew install or a background service running on my host. That is a deliberate rule, and it earns its keep.
Nothing pollutes the machine, so there is nothing to uninstall and no version drift between what I run and what a teammate runs. The whole edge is disposable: docker compose down and it is gone, up and it is back, identical every time. The certificate authority and the config live in named volumes, so the one thing I do want to persist, the local CA I trusted, survives restarts while everything else stays throwaway. A new machine is a clone and a few commands away from the exact same setup.
What it buys me
Local now looks like production because it is closer to production in the ways that bite. Real HTTPS, so secure cookies and scheme-sensitive code behave. Real hostnames, so host-based routing and any host-sensitive code actually run. A real inbox, so email is exercised end to end without a single message escaping. And no more port roulette, because every project answers at a name I can remember instead of a number I cannot.
Best of all, it is the same move everywhere. Declare an edge network, join the app to it, add a Caddy stanza, and the project has a real front door. It dropped into this site in one small change, and it drops into the next one just as easily. That is the theme I keep coming back to on everything I build: own the stack, understand the primitives, and make the environment you work in every day tell you the truth.
Relentlessly curious. Unreasonably willing to build.
