Skip to content

How to run a microservice environment locally

Running one microservice locally is easy: its README says how. The trouble starts when the flow you want to test goes through four of them, and each one calls two more that are not yours.

This page is not a tutorial for one particular tool: it is which pieces you need, why the hard part is not the one it looks like, and how each one gets solved — with Aseptic at the end, because that is what this site documents, but the four problems are the same whatever you use.

A local environment worth having needs to solve four things. In this order of real difficulty, which is almost never the order they get tackled in:

  1. Shared infrastructure. Kafka, Redis, PostgreSQL, whatever your system uses. It is the most visible part and the most solved: containers.
  2. Starting each service. Its command, its profile, its variables, its port. Repetitive work, but mechanical.
  3. Communication between services. Where each one calls. This is where the morning goes, and it is what almost nobody talks about.
  4. Data. A schema created and something inside it to test with.

A docker-compose.yml with the shared pieces and docker compose up. It is what everybody does and it is right: containers are exactly the correct tool for this.

Two details that bite later:

  • Ports collide. Every team declares 5432 and 6379. As soon as you have two projects open, or you bring things up and down, the conflicts start.
  • Infrastructure is shared between projects; data is not. Reusing the same Postgres for two different systems ends in a schema nobody knows the owner of.

Every service has its own way: ./mvnw quarkus:dev, ./gradlew bootRun -Dspring.profiles.active=local, npm start, a binary. With different profiles, variables and flags.

This is what usually ends up written in a forty-step LOCAL_SETUP.md: it works, but it ages badly. Nobody updates the document when a flag changes, and the next person to arrive discovers the gap through errors.

One detail that decides far more than it looks: not every service wants to run the same way. The one you are working on you want native, with hot reload and the debugger attached. The other three only have to be up: containerised is fine, isolated and without cluttering your machine.

Here is the real work, and it is the one no container tool solves for you.

Your bff service calls orders. In production that URL comes from service discovery or the ingress. Locally, someone has to decide where it points, and the options are not equivalent:

  • At the service running on your machine. That is what you want when you are changing both sides. But then you have to start it too, with its whole tail of dependencies behind it.
  • At the shared development environment. It saves you starting it, in exchange for VPN, for data that changes under your feet, and for the fact that if that environment is down, so are you.
  • At a mock. Perfect when that service’s response is irrelevant to you, or when you cannot even reach it. In exchange for maintaining the stub.

The expensive part is not choosing: it is applying the choice. Each option means changing a URL in the configuration of a repository that is not yours, remembering not to commit it, and undoing it when you want to try something else. Multiply by services and by times a day.

And there is a trap: the correct URL depends on where the caller runs. A native service reaches a container through localhost:port; a container calling another container, through the service name; and a container calling something native on your machine needs the special host name. The same dependency, three different URLs depending on how each end is running.

Schema created, migrations run and some minimal data. It is usually solved with the application’s own migrations plus a seed script.

The rule worth not breaking: the local environment does not touch shared databases. A migration accidentally fired against the common development environment is a lost afternoon for the whole team.

ApproachGood forWhere it falls short
docker-compose for everythingInfrastructure; a small, stable systemBuilding your service’s image on every change; no comfortable hot reload or debugger; communication is still hand-written configuration
Scripts + LOCAL_SETUP.mdGetting started fastIt ages: nobody updates it and it fails differently on every machine
TestcontainersReal integration testsThe environment is born and dies with the test; it is not a place to work
Local Kubernetes (Kind, minikube)Resembling productionA lot of machine and a lot of build cycle for the day to day; the change loop gets longer
Local orchestratorThe daily loop with several servicesEach service has to be described once

None of them is silly: they solve different things and often coexist. What none of the first four does is decide, per dependency, where each service calls, and apply it without touching the repositories.

Aseptic is a desktop orchestrator that takes care of the four pieces, and treats the third as the main problem it is.

  1. You describe each service once. You point at the repository folder and its technology, its ports, the infrastructure it needs and who it calls get autodetected. You review it and that is that.
  2. You group into a scenario the services of a flow —“Checkout”, “Customer onboarding”— and they start together, in order and waiting for each one to be healthy.
  3. For each dependency you choose local, cloud or mock, and the engine rewrites the URL on start by injecting it as configuration —-D flags or environment variables—, with the right perspective depending on where each end runs. Your repositories are not touched.
  4. The shared infrastructure (Kafka, Redis, PostgreSQL) is managed by the app in Docker, with automatic port remapping when they collide.
  5. Each service, native or containerised, as you choose: native the one you are working on, containerised the background ones.

And what you build gets shared: a scenario exports to a self-contained file, so whoever joins clones, imports and starts instead of reading the LOCAL_SETUP.md.