What problem are you actually solving in microservices local development?
When engineers say "local development is slow" in a microservices codebase, they usually mean one of two different problems, and the right tool depends on which one hurts more.
The first is running the dependencies you are not changing: Postgres, Redis, a message broker, an auth stub, and the three services another team owns that your code calls on every request. These need to start reliably, in the right order, with compatible versions and known configuration. They rarely change during a working session, so how quickly they start matters much less than whether they start the same way on every laptop.
The second is iterating on the services you are editing. This is the inner loop: save a file, see the change running, hit an endpoint or run a test, repeat. Here speed is everything. A loop that takes two seconds keeps you focused. One that rebuilds an image and restarts a container on every save sends you to another tab.
Teams invest in a local dev environment for three reasons. Fast feedback catches mistakes before CI does. A debugger attached to a real process, talking to real dependencies, finds bugs that mocks hide. And a new hire who can bring up a working stack with one command is productive in hours instead of days.
This article's position is that Docker Compose handles the first problem well, and that Compose and Tilt mainly differ on the second.
How does Docker Compose model a dev environment?
A Compose file describes the stack as a set of services, each built from a Dockerfile or pulled as an image. Compose puts them on a shared default network, so the app can reach Postgres at the hostname postgres instead of a hard-coded IP. Named volumes keep database files across docker compose down, so you don't re-seed after every restart. Adding -v removes the volumes, which gives you a clean reset when you want one.
In the example that follows, Postgres has a healthcheck that runs pg_isready. The app service declares depends_on with condition: service_healthy, so Compose waits until the database reports healthy before it starts the app. A queue such as RabbitMQ is gated the same way. Optional services, like a tracing UI or a mail catcher, are assigned to a profile. They stay off unless someone runs docker compose --profile debug up, which keeps the default stack small.
depends_on controls only the startup order. If Postgres restarts while the stack is running, or the queue drops connections, Compose does nothing about it. The application still needs connection retries with backoff. Without them, a stack that works on the first up will fail in confusing ways an hour later.
For local-only settings, Compose automatically reads a .env file for variable substitution and merges a compose.override.yaml if one is present. Put the shared, credential-free definition in the main file. Commit a .env.example that contains placeholder values. Add .env and any personal override file to .gitignore so real credentials never reach the repository.
# compose.yaml: shared and committed. No real credentials live here.
# Values come from .env, which is gitignored. Commit a .env.example with placeholders.
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app_dev
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data # survives `down`; `down -v` resets it
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
queue:
image: rabbitmq:3
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
retries: 10
app:
build: .
environment:
# Hostnames are service names on the default network.
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@postgres:5432/app_dev
AMQP_URL: amqp://guest:guest@queue:5672/
depends_on:
# Gates the first start only. The app still needs retries with backoff.
postgres: { condition: service_healthy }
queue: { condition: service_healthy }
db-shell:
image: postgres:16
profiles: ["debug"] # off by default: docker compose --profile debug run --rm db-shell
entrypoint: ["psql", "-h", "postgres", "-U", "app", "app_dev"]
volumes:
pgdata:
This Compose file health-checks Postgres and RabbitMQ, starts the app only after both report healthy, keeps a psql debug service behind a profile, and reads the database password from a gitignored .env file.
How does Tilt work, and how is its inner loop faster?
A Tiltfile is a Starlark program, a Python dialect, that tells Tilt what to build and where to run it. A typical file calls docker_build to define an image, k8s_yaml to load manifests, and k8s_resource to group a workload with its port forwards and dependencies. Tilt then builds the images, deploys them to a local Kubernetes cluster, and watches the source tree. Each resource gets its own row in a browser UI with build status, logs, and a manual trigger. That makes it easier to see which of a dozen services failed than scanning one interleaved docker compose logs stream.
The inner loop is where the difference shows. With Compose you have two choices. You can rebuild the image on every change, which is correct but slow. Or you can bind-mount source and rely on a reloader inside the container, which is fast but only works for interpreted code or a watcher you configure yourself. Tilt's live_update argument to docker_build takes steps such as sync() to copy changed files into the running container and run() to execute a command, such as reinstalling dependencies, when specific files change. No image rebuild or pod restart is needed.
You don't have to pick one tool. Calling docker_compose() in a Tiltfile runs your existing Compose file under Tilt's UI and file watching.
There are trade-offs. A live-updated container drifts from what a clean build would produce, so do periodic full rebuilds to catch missing dependencies and Dockerfile errors. File sync and bind mounts can also be slow or unreliable on macOS and Windows, where files cross a VM boundary.
# Tiltfile (Starlark): one API service built and deployed to local Kubernetes,
# with backing services pulled in from an existing Compose file.
# Reuse the current Compose file for Postgres/Redis instead of porting them to k8s.
# Each Compose service appears as its own resource in the Tilt UI.
docker_compose('./docker-compose.deps.yml')
# Build the API image. live_update patches the running container instead of
# rebuilding the image and restarting the pod on every save.
docker_build(
'example/api',
'./api',
live_update=[
# A Dockerfile change can't be patched in place, so force a full rebuild.
fall_back_on(['./api/Dockerfile']),
# Copy changed source and manifest files into the container.
sync('./api/src', '/app/src'),
sync('./api/package.json', '/app/package.json'),
sync('./api/package-lock.json', '/app/package-lock.json'),
# Reinstall dependencies only when the lockfile or manifest changes.
run('npm install', trigger=['./api/package.json', './api/package-lock.json']),
],
)
# Load the Kubernetes manifests that reference the example/api image.
k8s_yaml('./k8s/api.yaml')
# Group the Deployment into one UI row and forward localhost:8080 to the pod.
k8s_resource('api', port_forwards='8080:8080')
# Caveat: live-updated containers drift from a clean build. Rebuild from
# scratch now and then (e.g. trigger the resource in the UI or restart Tilt)
# to catch missing dependencies or Dockerfile errors.
A minimal Tiltfile that live-updates a Node API on a local Kubernetes cluster with sync() and run(), falls back to a full rebuild on Dockerfile changes, and runs supporting services from an existing Compose file through docker_compose().
How do you run the service you are editing on the host with dependencies in containers?
The hybrid setup avoids that cost for the code you change most often. The service you are editing runs as a normal host process, started from your IDE with a debugger attached and hot reload working. Only its dependencies, such as the database, broker and other services, run in containers.
Host OS
┌─────────────────────────────────────────────────────────┐
│ orders-svc (IDE + debugger) │
│ │ localhost:5432 │ localhost:8081 │
│ ────┼───────────────────────┼──── published ports ───── │
│ ┌──▼──────────┐ ┌─────▼───────┐ compose network │
│ │ postgres │ │ billing-svc │──┐ (DNS by │
│ │ :5432 │◄─────│ :8080 │ │ service name) │
│ └─────────────┘ └─────────────┘ │ │
│ host.docker.internal ◄──────────┘ callbacks │
└─────────────────────────────────────────────────────────┘
Host to container. Publish each dependency's port, and bind it to 127.0.0.1 so you do not expose it on your LAN. The host process then reaches it at localhost:<published port>. If the published port differs from the container port, use the published one.
Container to host. If a containerized service calls back into your process, localhost will not work, because inside a container it refers to the container itself. Docker Desktop resolves host.docker.internal automatically. Plain Docker Engine on Linux does not, so add an extra host mapping of host.docker.internal to the special host-gateway value. That gives you one hostname that works on every developer's machine. Your host process must also listen on an interface the containers can reach, not only on loopback.
Consistent config. Read every address from environment variables with the same names in both modes. Only the values differ: DB_HOST=localhost on the host, DB_HOST=postgres in Compose. Keep the host values in a checked-in env file. When you move a service back into a container, you then change values, not code.
# compose.yaml: dependencies only; orders-svc runs on the host from the IDE
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: orders
POSTGRES_PASSWORD: dev-only-password # placeholder, local use only
POSTGRES_DB: orders
ports:
- "127.0.0.1:5432:5432" # host reaches it at localhost:5432, not exposed on LAN
billing-svc:
image: example/billing-svc:dev
ports:
- "127.0.0.1:8081:8080" # published 8081 -> container 8080; host uses 8081
environment:
# Container-to-container: compose DNS by service name and container port
DATABASE_URL: postgres://orders:dev-only-password@postgres:5432/orders
# Container-to-host: callbacks into orders-svc running natively
ORDERS_CALLBACK_URL:
extra_hosts:
# Docker Desktop resolves this name already; on Linux Engine this mapping
# makes the same hostname point at the host's gateway address
- "host.docker.internal:host-gateway"
# .env.hybrid (loaded by orders-svc on the host): same variable names as
# the all-in-containers mode, only the values change
# DATABASE_URL=postgres://orders:dev-only-password@localhost:5432/orders
# BILLING_URL=
# HTTP_LISTEN_ADDR=0.0.0.0:3000 # not 127.0.0.1, so containers can call back
A compose file for the hybrid setup: localhost-bound published ports for the host process, a host-gateway mapping so host.docker.internal works on Linux, and one set of env var names reused across both modes.
How do you seed data and reset local state in one command?
Treat local state as disposable, because it drifts. A teammate's database has a column from an abandoned branch, yours is missing last week's migration, and both of you spend an afternoon debugging a difference that exists only on your laptops. The fix is to make state cheap to rebuild and give everyone the same single command to rebuild it.
In Compose, model migrations and seeds as one-off services rather than steps someone remembers to run. A migrate service runs your migration tool against postgres and exits. A seed service then loads fixture data. Use depends_on with condition: service_completed_successfully so your app starts only after both finish. For trivial schemas, the official Postgres image runs scripts in /docker-entrypoint-initdb.d, but only when the data directory is empty. That is easy to forget, so prefer an explicit migration service.
In Tilt, express the same ordering with resource_deps. Put seeding in a local_resource set to manual trigger mode, so tilt trigger seed reloads fixtures without restarting anything else.
Seed only generated fake data that is checked into the repo. Production dumps leak personal data onto laptops, and real credentials in fixtures leak into git history.
Wrap the reset in a documented target such as make reset, which runs docker compose down -v to delete named volumes and then docker compose up -d. When a bug report says "works on my machine," running make reset first rules out state drift in about a minute.
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: localdev-only # placeholder, local use only
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data # named volume; `docker compose down -v` deletes it
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 2s
retries: 15
migrate: # one-off job: apply schema, then exit 0
image: postgres:16
environment: { PGPASSWORD: localdev-only }
volumes: ["./db/migrations:/migrations:ro"]
# Stand-in for your real migration tool; these SQL files must be idempotent
command: sh -c 'for f in /migrations/*.sql; do psql -h postgres -U app -d app -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done'
depends_on:
postgres: { condition: service_healthy }
seed: # one-off job: load generated fake fixtures checked into the repo
image: postgres:16
environment: { PGPASSWORD: localdev-only }
volumes: ["./db/seed:/seed:ro"]
command: psql -h postgres -U app -d app -v ON_ERROR_STOP=1 -f /seed/fake_data.sql
depends_on:
migrate: { condition: service_completed_successfully }
app:
build: .
environment:
DATABASE_URL: postgres://app:localdev-only@postgres:5432/app
depends_on:
seed: { condition: service_completed_successfully }
volumes:
pgdata: # reset: docker compose down -v && docker compose up -d
A minimal compose.yaml in which one-off migrate and seed services run in order before the app starts, so docker compose down -v && docker compose up -d rebuilds the database from scratch with fake data.
Docker Compose vs Tilt: which should you choose, and when should you avoid Tilt?
| Dimension | Docker Compose | Tilt |
|---|---|---|
| Setup cost | One YAML file most engineers already read | Tiltfile plus a working local cluster |
| Local cluster required | No | Yes, for Kubernetes workflows |
| Inner-loop speed | Rebuild and restart the container, or run on host | Live update syncs files into running pods |
| Kubernetes manifest parity | None; a separate model of the system | High, if it deploys your real manifests |
| Config and secrets | .env files and environment keys |
ConfigMaps and Secrets, same as production |
| Team debuggability | docker compose logs and ps |
Tilt UI, plus kubectl knowledge |
| Laptop resource use | Containers only | Containers plus cluster overhead |
Decision checklist
- Services edited at once: If you edit one service at a time, Compose with that service on the host is usually enough. If you edit three services together, Tilt's live updates save more time.
- Production on Kubernetes: Do readiness probes, resource limits or DNS-based service discovery cause real incidents? Compose cannot reproduce those failures, so they surface in staging. Tilt only closes that gap if it deploys the same manifests, or the same templated Helm or Kustomize output.
- Cluster tolerance: Will the team debug a stuck local cluster?
- Laptop limits: Measure memory with the full stack running.
Avoid Tilt when only one person understands the Tiltfile. When that person leaves, the Tiltfile breaks and nobody fixes it.
Standardizing on one tool across the organization gives teams shared tooling and lets engineers move between teams easily. Letting each team choose fits differences in stack size. A middle path works for many organizations: standardize on Compose for dependencies, and let teams that run on Kubernetes add Tilt.
What are the alternatives when running everything locally stops scaling?
Both tools hit the same wall eventually. Every container you add needs its own memory, CPU time and disk for images and volumes. A laptop that runs eight services comfortably may swap at thirty, and image pulls and rebuilds start eating the time Tilt saved you. When that happens, you need to stop running parts of the stack locally rather than switch tools.
| Approach | What you gain | What it costs |
|---|---|---|
| Stubs or mocks | Fast startup, tiny footprint, deterministic responses | They encode the API as it was when written, so contract changes go unnoticed until integration |
| Other teams' published images | Real behaviour, pinned to a known build | Heavy, and only works if each image can run standalone without its own chain of dependencies |
| Remote dependencies (a shared staging API) | No local cost at all | Needs network access, shares data with others, and is slow to debug across the boundary |
| Shared dev cluster | Full stack, centrally maintained | Contention for shared databases and queues, and one person's broken deploy blocks everyone unless you add per-developer namespaces |
Most teams mix these. Real images for the two or three services you call most, stubs for the long tail, and a remote environment for the rare end-to-end check.
None of these choices make a service safe to depend on. A stub that never times out will not test your retry logic, and a shared cluster will not catch a field rename. Retries with backoff, timeouts, and consumer-driven contract tests belong in the services and their CI pipelines, whatever your local setup looks like.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to microservices local development, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.