AI has made it trivial to scaffold a new microservice, complete with its own Dockerfile and its own compose.yaml for local dependencies. Ask for a service and you get one in minutes, fully containerized, ready to run. Do that fifteen times across a real platform and you have not built a local development environment. You have built fifteen of them, each one convinced it is the only service that exists.
A local environment that only works for one service at a time is not a local environment. It is a demo.
I have watched teams onboard a tenth, then a fifteenth microservice and quietly stop being able to run the whole system on a laptop. Not because the machine ran out of resources, though eventually it does, but because nobody had designed for what happens when fifteen independently-scaffolded services have to agree on one Postgres, one message broker, one set of hostnames, and one story about where a token came from. Each service's own compose file works beautifully when it is the only thing running. The moment you need five of them running together, correctly, while you actively edit one, the assumptions stop holding.
Every Service Owning Its Own Compose File
The obvious approach is to let each service repo carry its own compose.yaml, then glob every one of those files into a shared stack. It looks correct. It is also how you end up with three services each declaring their own postgres and redis containers, and Docker Compose silently merging same-named services first-wins, so the "postgres" that starts is whichever repo happened to load first. Everything else quietly loses its expected credentials and its schema.
This is worse than a crash. A crash tells you something is wrong. A silent merge tells you nothing, right up until a service can't find a table that its own migrations definitely created, and you spend an afternoon convinced the migration is broken before you notice three different services think they own the database.
Most Spring Boot services already ship a compose file for exactly one purpose: local dependencies for , meaning the databases and queues the app expects when you launch it from an IDE, with zero opinion about how the gets containerized. That file is doing its job correctly. It was never meant to describe how fifteen services run together.
Related Articles
Shared topics and tags
Newsletter
Expert notes in your inbox
Subscribe for new articles.
spring-boot:run
app itself
So the container definition for each service moves one layer up, into a shared infrastructure repository, as a small overlay: just the app container, its image, its environment, nothing about the shared Postgres or broker it depends on.
# shared-infra/overlays/notification-service.yaml
services:
notification-service:
image: platform/notification-service:${IMAGE_TAG:-local-latest}
environment:
SPRING_PROFILES_ACTIVE: docker
DATABASE_URL: jdbc:postgresql://postgres:5432/notifications
# no postgres, no redis, no broker: those are declared once,
# in shared-infra, not repeated per service
The shared stack is assembled from exactly two sources: one shared infrastructure file declaring Postgres, the broker, and object storage once, plus every service's overlay glob'd in alongside it. The service's own compose.yaml never enters that build at all, so it stays untouched and still works standalone for anyone who clones just that one repo.
A service repo should describe its own dependencies. It should not be trusted to describe how it fits into everyone else's stack.
Containers as the Inner Loop for the Service You're Editing
Once the stack runs cleanly, the next assumption falls apart immediately: that running the service you are actively changing inside a container is a reasonable inner loop.
It is not, and every engineer who has tried it already knows why, even if nobody wrote it down. Rebuilding a multi-gigabyte image on every code change, with no debugger attached and no hot reload, is a worse editing experience than just running the app from an IDE. On Apple Silicon it gets worse again: an image built for the platform's native architecture on a CI runner often needs emulation locally, which turns a two-second recompile into a genuine wait.
I have started nearly every debugging session on a service I was actively changing by first checking whether it was still running in a container from yesterday, because the symptom of "my container is stale" looks identical to "my fix didn't work."
The pattern that actually holds up: container-run everything you are not editing, host-run the one thing you are. In practice that means a single command decides which side of that line a service sits on, and it stops any container that would otherwise conflict with the IDE run:
make dev SVC=notification-service # pull it onto your host, IDE runs it
make dev SVC=none # hand it back, everything containerized again
Running make dev SVC=notification-service does two things atomically: it stops that service's container so you never end up with two copies competing for the same requests, and it updates the shared config so every other service, and the proxy, now expects to find notification-service on your machine instead of in the network. Anyone else on the team can run the identical command against a completely different service, on the same afternoon, with zero coordination.
The container definition still matters, but its audience changes. It stops being for you and starts being for the teammate who needs your service running correctly without needing to know anything about how you built it.
Hand-Maintained Env Files That Quietly Drift
Once one service is running on the host and fourteen are containerized, someone has to write down what environment variables the host-run service needs: database URLs, broker credentials, whatever internal endpoint it calls. The obvious move is a hand-maintained .env.local file, edited whenever something changes.
This works for about two weeks. Then someone adds a new environment variable to the container definition, forgets to mirror it into the hand-maintained file, and a host-run service silently falls back to a default that happens to work in one environment and not another. Nobody notices until it doesn't, and by then the file has drifted far enough that nobody trusts it as documentation either.
The fix is not "be more disciplined about updating the file." The fix is removing the second copy entirely. Ask the container tooling what the container would actually receive, resolve it exactly the way it resolves it for a real run, and rewrite the handful of hostnames that only mean something inside the container network back to localhost:
Point the IDE's environment-file setting at that generated file and re-run the command after any shared config change. The host-run config becomes a derived artifact of the container config, not an independent hand-maintained one. Two configs that must agree and are edited separately will eventually disagree. One config, generated into two shapes, cannot.
Pretending It Doesn't Matter Where a Service Actually Runs
This is the one that costs the most time to debug, because the failure shows up nowhere near its cause.
In a system using OAuth2 or any token-based auth, the issuer claim baked into every token is a hostname. If the service that issues those tokens is reachable at one address when it's containerized and a different address when a developer pulls it onto their host to debug it, every token minted before the switch becomes invalid the moment the switch happens, and every service that validates tokens against that issuer needs to somehow find out. Multiply that by a team where three different engineers might have three different services pulled onto their own hosts on any given day, and "where things run" becomes state that the whole system has to agree on constantly.
The pattern that removes this entirely: put a reverse proxy in front of the stack that owns the actual public hostnames, and route by hostname to wherever a service currently lives, container or host machine. The proxy's job is to make the switch invisible to everyone except the one person doing it. The public URL for the token issuer does not change whether it's containerized or running from an IDE fifteen feet away, so the issuer claim never changes, so no other service needs to be told anything happened.
https://identity.dev.internal → routes to → container, OR host.docker.internal:8080
Same address, either backend. The URL is the contract. Everything behind it is an implementation detail nobody else should have to track.
This also solves a smaller but constant annoyance: services that all want the same native port. Two Spring Boot apps both defaulting to 8080 is only a problem if they publish ports to the host at all. Once the proxy owns the actual entry points, nothing else needs to.
Treating Three Ways to Run a Service as One Interchangeable Toggle
Once you have three ways to run a service, host, from a locally-built image, or from an image CI already published, it's tempting to let any command touch any setting. That's how you end up with one command flipping a service to host-run while another, run five minutes earlier, still thinks it should be pulling a CI image, and the two disagree about what state the system is in.
The fix is narrow ownership, with each fact in the system written by exactly one command:
# owns: which containers start, where the proxy looks
make dev SVC=notification-service
# owns only: which image tag the container uses (your build)
make local SVC=notification-service
# owns only: which image tag the container uses (CI's build)
make upstream SVC=notification-service
dev owns two facts together on purpose: which containers start, and where every other service should look for the one that didn't. Those two must always agree, so exactly one command is allowed to write both. local and upstream each own a single, narrower fact: which image tag a container should run. Neither ever touches the boundary decision. Ask make local to switch the image for a service that's currently running on your host, and it says so plainly instead of silently dragging that service back into a container you didn't ask for.
Two facts that must always agree should have exactly one place they get written. The moment a second command can write the same fact, drift is not a risk, it is a certainty waiting for a busy afternoon.
Trusting the Config File Instead of Checking What's Actually Running
Even with narrow ownership rules, drift still happens, because a team of real people will always occasionally leave a container running after its slot moved to an IDE, or forget a proxy is still pointed at an image nobody is serving anymore. No amount of tooling discipline removes the human who ran one command yesterday and a different one this morning and never quite reconciled the two.
What removes the cost of that drift is refusing to let the written config be the last word on system state. A single command should be able to answer, on demand: for every service, how is it supposed to be running, how is it actually running right now, and do those two facts agree?
A quick scan across three services might read like this:
notification-service: running on the host at localhost:8080. Nothing to report.
billing-service: running as a local container, but its image is three days old, older than the last commit. Flagged.
reporting-service: running from the current upstream image. Nothing to report.
Only the service that disagrees with reality gets flagged. Everything else stays quiet, which is the point: a noisy audit trains people to stop reading it, a quiet one that only speaks up when something is actually wrong stays trustworthy for years. In practice, every confusing "the stack is broken" report in an environment like this has turned out to be drift rather than an outage: a container nobody remembered to hand back, an image three rebuilds behind the code someone is staring at. An audit command that always tells the truth about drift is worth more than a process that promises never to drift. The second one is aspirational. The first one is checkable in five seconds, which is what actually gets used at nine in the morning.
Treating "Starting" and "Stopping" a Task as Mirror Images
Most teams build real tooling for pulling a service onto a laptop and treat putting it back as an afterthought: close the IDE, move on to the next ticket. That asymmetry is where the leftovers that bite someone else actually accumulate.
A session that ends without a deliberate wind-down step tends to leave behind, quietly:
A host process that still holds the port the next task's run configuration needs, so tomorrow's "it won't bind" bug has nothing to do with tomorrow's code
Generated environment files pointing at configuration that has since changed underneath them
A container handed back to the shared stack while still running the image from before today's fix, so the containerized version quietly serves last week's behavior to anyone who didn't know to check
Uncommitted work sitting in a service checkout that the parent repository's own status can never see, because service clones are intentionally excluded from it
None of these show up as an error. They show up as the next person's confusing afternoon, which is exactly why they are worth naming explicitly rather than trusting people to remember. The fix is symmetric tooling: if there is a first-class command for pulling a service out of the shared stack, there should be an equally first-class one for handing it back cleanly, one that checks for each of these categories, prompts before anything destructive, and reports what it found rather than assuming the answer is "nothing."
A workflow that makes starting easy and stopping an afterthought will always look clean on day one and confusing by day thirty.
Quietly Routing Around a Dependency's Bug Instead of Naming It
In any real microservices platform, your local environment tooling is going to run headfirst into bugs that live in services your team doesn't own: a hostname hardcoded in three unrelated places, a background job that keeps polling a shared queue unless someone remembers to disable it, a build script referencing a filename no build has produced in months.
The tempting fix lives entirely inside your own infrastructure layer: rewrite the URL, inject the missing flag, silently skip the broken step, so the stack "just works" for the person trying to get a demo running before lunch. It is also the wrong fix, because it hides the bug from the team that could actually close it, and it makes the eventual failure unreadable. If the workaround ever stops matching the dependency's actual shape, the symptom looks like your tooling broke, not like a known bug in someone else's service finally changed its blast radius.
The better discipline: keep an explicit, living list of the bugs your team found in its dependencies while getting the stack running, each with the exact confusing symptom and the actual root cause, and resist the urge to make any of them invisible. When the next engineer hits that same symptom six months later, they spend thirty seconds confirming a known issue instead of re-discovering it from scratch and, worse, blaming the wrong layer.
What This Actually Buys an Enterprise Team
None of this matters for three services. It matters enormously past ten, which is exactly where most platform teams are once the business has grown past a single deployable. This is a common shape in fintech and other regulated, service-heavy domains, where a payments flow alone might touch half a dozen independently-owned services before a transaction settles.
Nobody needs to know what their teammates are running. An engineer debugging one service pulls only that service onto their host. Everyone else's services stay exactly where they were, unaffected, because the proxy and the generated env files never assumed a fixed topology in the first place.
The inner loop stays fast for the one thing you're changing, without sacrificing a realistic environment for everything that thing talks to.
Auth and any hostname-sensitive config survive every switch, because the public contract (the URL) is decoupled from the private implementation (container or host).
Config drift becomes structurally impossible for the pieces that are generated rather than hand-maintained, which is the only kind of "impossible" that survives a team growing past the size where one person remembers every convention.
The drift that still happens is cheap to find, because one audit command compares intended state against actual state instead of trusting either in isolation.
The mess from yesterday's session doesn't become today's debugging story, because ending a task has the same first-class tooling as starting one.
A confusing failure gets attributed to its real cause, because known bugs in dependencies are documented where the whole team can find them instead of patched around in silence.
The Broader Pattern
This is not really about Docker Compose or reverse proxies. It's about a question that keeps showing up as systems grow: when multiple independently-built things have to cooperate, where does the one fact that must stay consistent actually live, and who is allowed to change it?
AI makes it fast to generate the fifteenth service, complete with its own Dockerfile and its own reasonable-looking compose file. What it does not do is notice that the fifteenth service's compose file, combined naively with the other fourteen, silently breaks the ninth one's database credentials. That kind of judgment, the sense of which piece of shared state needs exactly one owner and which contract needs to stay stable across every possible implementation detail, is systems thinking. It does not show up in a single file's diff. It shows up in whether the whole thing still works after the fifteenth service joins it.
If your local environment currently works great for one service and gets worse with every service you add, share this with whoever owns your platform tooling.