What a tiered local development strategy is and who it is for
A tiered local development strategy splits a microservice system into three groups and gives each a different runtime. The services you are actively changing run in Docker Compose on your machine, built from source with your edits. Services owned by other teams run as stubs: lightweight fakes that return canned responses and are checked against real API contracts so they don't drift. Full-system behavior, meaning real dependencies, real data flows and cross-team integration, is tested in ephemeral preview environments that a pipeline creates per branch or pull request and destroys afterward.
The tiers exist because running the whole platform on a laptop eventually stops working. Each service brings its own process, database, cache or queue, and memory runs out long before the service count looks large. Cold startup gets slow because dozens of containers must pull images, run migrations and pass health checks before anything is usable. On macOS and Windows, Docker runs inside a virtual machine, so bind-mounted source directories cross a VM file-sharing boundary. File watching and hot reload can lag noticeably, and the delay grows with every mounted service.
The approach suits backend and platform engineers working on systems with more services than fit comfortably on one machine, especially where ownership is spread across teams. It is overkill for a small system of a few services that starts quickly and fits easily in memory. In that case, running everything locally is cheaper than maintaining stubs, contracts and preview infrastructure.
+------------------------------ Developer laptop --------------------------+
| Tier 1: Docker Compose (services you are changing, built from source) |
| +--------------+ +--------------+ +--------------------+ |
| | Service A |---->| Service B |---->| DB / cache / queue | |
| | (your edits) | | (your edits) | | (local, per svc) | |
| +--------------+ +--------------+ +--------------------+ |
| | | |
| v v |
| Tier 2: Stubs for other teams' services (canned responses) |
| +--------------+ +--------------+ |
| | Stub: Orders | | Stub: Search | |
| +--------------+ +--------------+ |
| ^ ^ |
+---------|--------------------|-------------------------------------------+
| contract checks |
+-----+--------------------+-----+
| API contracts (provider-owned) |
+--------------------------------+
git push / open PR
|
v
+------------- Tier 3: ephemeral preview environment (per branch/PR) ------+
| Created by the pipeline on push, destroyed after merge or close |
| +-----+ +-----+ +--------+ +--------+ +-----+ |
| | A | | B | | Orders | | Search | | ... | |
| +-----+ +-----+ +--------+ +--------+ +-----+ |
| Real dependencies, real data flows, cross-team integration |
+--------------------------------------------------------------------------+
The three tiers: services under change run in Docker Compose on the laptop, other teams' services run as stubs checked against provider-owned API contracts, and full-system integration runs in a per-branch preview environment that the pipeline creates and destroys.
Deciding what runs real, containerized or stubbed
For larger systems, sort every dependency of the service you are changing into one of three groups and apply a single rule to each group. This keeps each decision consistent and stops a sprawl of special cases.
| Group | Examples | Rule | Why |
|---|---|---|---|
| Service under change | The API or worker you are editing | Run real, natively or with source mounted | You need fast edit-run cycles and an attached debugger |
| Owned infrastructure | PostgreSQL, Redis, a message broker | Run containerized | SQL dialects, locking, TTLs and transaction semantics are hard to fake faithfully, and official images start quickly |
| Other teams' services | Payments, identity, search | Stub at the API boundary | You do not control their release cadence, data or resource footprint |
Asynchronous dependencies need care. When another team produces events you consume, do not stub the broker protocol. Run the real broker in a container and feed it with a small test producer or fixture messages. A fake broker will not reproduce redelivery, acknowledgement timing, per-partition ordering, consumer-group rebalancing or dead-letter routing, and those are where most async bugs live. The broker is cheap to run. The other team's producer is what you replace.
Compose is not your production orchestrator. A green local run says nothing about how real service discovery resolves names, how network policies restrict traffic, how secrets and config are injected, or how sidecars such as proxies and log shippers behave. Validate those in a shared or preview environment that uses the production platform.
Structuring a fast docker compose dev environment
Start with one base file that defines every service your team might need, then decide how much of it each engineer actually starts. Profiles let you tag optional services, such as a search cluster or the billing worker, so a plain docker compose up brings up only the core path. Anyone who needs more enables that profile explicitly. Override files hold developer-only settings, such as bind mounts, debug ports and hot-reload commands, so the base file stays close to what CI runs.
Ordering without sleeps
Sleeping for a fixed number of seconds before connecting is either too short on a cold laptop or wasted time on a warm one. Give each dependency a healthcheck that proves it is usable, for example a query against the database rather than just an open port. Then make dependent services wait on depends_on with the service_healthy condition. Run one-shot jobs such as migrations as their own services and gate the application on service_completed_successfully.
The inner loop
Bind-mount source into the container and run the framework's file-watching dev server, so an edit reloads the process without a rebuild. In the Dockerfile, copy dependency manifests and install them before copying application code. That keeps the expensive install layer cached across ordinary edits.
Seed and reset
Expose one command that drops volumes, reruns migrations and loads fixture data. A broken local state should cost a minute to fix, not an afternoon.
Keep the footprint small. Laptops share limited CPU, memory and disk across every container. On macOS and Windows, bind mounts go through a VM file-sharing layer that can make watchers and installs noticeably slower.
# compose.yaml: base file shared with CI. Dev-only bind mounts and watch commands live in compose.override.yaml.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpassword # local placeholder, not a real secret
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
# Healthy only when a real query succeeds, not just when the port is open
test: ["CMD-SHELL", "psql -U app -d app -c 'SELECT 1'"]
interval: 2s
timeout: 3s
retries: 30
migrate:
build: .
command: ["npm", "run", "migrate"] # one-shot job that exits when done
environment:
DATABASE_URL: postgres://app:devpassword@db:5432/app
depends_on:
db:
condition: service_healthy
api:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app:devpassword@db:5432/app
depends_on:
migrate:
condition: service_completed_successfully # start only after migrations exit 0
billing-worker:
build: .
command: ["node", "worker/billing.js"]
profiles: ["billing"] # skipped by plain `docker compose up`; enable with --profile billing
depends_on:
migrate:
condition: service_completed_successfully
volumes:
db-data:
A base Compose file that uses a query-based healthcheck and depends_on conditions to order the database, migrations and API, and puts an optional worker behind a profile.
Building service stubs with WireMock
WireMock is an HTTP server that answers from a set of mappings. Each mapping pairs a request pattern with a canned response. When your service calls another team's API, it calls WireMock instead. You get predictable answers without running that team's code, database or upstream dependencies.
Request matching and templating
Mappings match on method, URL or URL pattern, query parameters, headers and body. Body matchers include exact JSON equality and JSONPath expressions. Match as loosely as the contract allows. If a stub requires an exact header that the real service ignores, it fails for reasons that have nothing to do with your code.
Response templating lets a response reuse parts of the request, such as an ID from the path or a field from the body. A single mapping can then serve many inputs, which keeps the mapping set small and easy to review.
Latency, errors and faults
WireMock can add fixed or randomly distributed delays to any response. Use this to check that your timeouts and retries actually trigger. Map specific inputs to 4xx and 5xx responses so error-handling paths get exercised on purpose.
Fault injection goes further: dropped connections, empty responses and malformed data. These expose bugs that a clean HTTP 500 never reaches.
Where the stubs live
Store mappings in the consuming service's repository, so stub changes are reviewed alongside the code that depends on them. Run WireMock as a Compose service with that directory mounted.
Limits
Stubs cover happy paths and documented errors well. They rarely reproduce real performance, pagination edge cases, eventual consistency or rate limiting. You need real integrations to test those.
{
"mappings": [
{
"name": "Drop the connection to exercise client fault handling",
"priority": 1,
"request": { "method": "GET", "urlPath": "/inventory/items/sku-reset" },
"response": { "fault": "CONNECTION_RESET_BY_PEER" }
},
{
"name": "Respond slower than the client timeout so the timeout and retry paths run",
"priority": 1,
"request": { "method": "GET", "urlPath": "/inventory/items/sku-slow" },
"response": { "status": 200, "jsonBody": { "sku": "sku-slow", "available": 1 }, "fixedDelayMilliseconds": 30000 }
},
{
"name": "Documented error: unknown SKU returns 404",
"priority": 1,
"request": { "method": "GET", "urlPath": "/inventory/items/sku-missing" },
"response": { "status": 404, "jsonBody": { "error": "item_not_found" } }
},
{
"name": "Happy path for any other SKU: echo the ID from the path, add jittered latency",
"priority": 5,
"request": { "method": "GET", "urlPathPattern": "/inventory/items/[a-z0-9-]+" },
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "sku": "{{request.path.[2]}}", "available": 42 },
"transformers": ["response-template"],
"delayDistribution": { "type": "uniform", "lower": 100, "upper": 600 }
}
}
]
}
A WireMock mapping file for another team's inventory API. A loose URL pattern with response templating covers the happy path, and higher-priority mappings for specific SKUs return a documented 404, a response slower than the client timeout, and a dropped connection.
Keeping stubs honest with contracts in CI
Stub drift is the quieter failure. A WireMock mapping written in March still returns customer_id as a string in July, after the provider switched it to an integer. Every local test passes, and the break shows up in staging or production. Stubs drift because nothing ties them to the provider. Two practices create that link.
Derive or validate stubs from the API specification
If the provider publishes an OpenAPI document, treat it as the source of truth. Either generate stub mappings from the spec, or keep hand-written mappings and validate each one's request and response against the spec in CI. Validation is often the better choice. Hand-written stubs can carry realistic data and error cases, and the check still fails when a field is renamed or a required parameter is added. This method only catches drift between the stub and the spec, so it depends on the provider keeping the spec accurate.
Consumer-driven contract testing
With frameworks such as Pact, the consumer's tests record the interactions it relies on as a contract file. The provider's CI pipeline replays those contracts against the real service on every build. A provider change that breaks a consumer fails the provider's build before it merges. The same contract can also generate the consumer's stubs, so the stub and the verified behavior come from one artifact.
Wiring and ownership
Publish contracts and specs as versioned artifacts. Run verification on provider pull requests, and block deploys when verification fails. Consumers own the contracts because they define what they depend on. Providers own the spec and must keep verification green. A platform team should own the shared pipeline and artifact store.
When to use ephemeral preview environments and how to run them
Local stacks stop being enough when a change depends on things you cannot fake cheaply: real authentication flows, ingress and TLS behavior, service mesh policies, or configuration that only exists in a deployed cluster. Preview environments also help when someone else needs to see the change. A reviewer, product owner or QA engineer can click through a running URL instead of pulling the branch. End-to-end suites belong here too, because they need the whole system wired the way production wires it.
Avoid them for minute-by-minute iteration. Building images, scheduling workloads and waiting for readiness checks takes minutes, not seconds. That is fine for a pull request and painful for a debugging loop.
Running them
Create one isolated unit per pull request. In Kubernetes that is usually a namespace named after the PR number, which makes ownership and cleanup obvious. Automate the lifecycle. Pipeline hooks create the environment when the PR opens and delete it on merge or close. A time-to-live reaper catches abandoned branches. Set resource quotas per environment so one runaway preview cannot starve the cluster.
Share expensive backing services, such as a database server with a schema per environment, when isolation requirements allow it. Give each environment its own instance when tests mutate global state. Inject secrets from your secret store at deploy time with scoped, non-production credentials. Never bake them into images.
Data is the hard part. Seeded fixtures are safe but miss the malformed records and odd states that real traffic produces. Copying production data captures those cases but puts personal data into short-lived, loosely controlled environments, which creates privacy and compliance risk. A middle path is to anonymize or synthesize data from production shapes, and to keep a curated fixture set that grows every time a production bug reveals a new edge case.
Alternatives, test placement and impact on cost and engineering time
Each option fails in a different way, so match the choice to the failure you can tolerate.
| Approach | Strength | Typical failure mode |
|---|---|---|
| Everything local | Full fidelity, no network dependency | Laptops run out of memory; startup takes minutes |
| Shared staging | One realistic environment | Contention and broken deploys block every team |
| Remote dev against a cluster | Real dependencies, light laptop | Needs connectivity; one engineer's state leaks into another's |
| Tiered (Compose, stubs, previews) | Fast feedback, isolation per change | Many artifacts to maintain |
Where each test runs
- Unit: run locally without Compose, and in CI on every push.
- Component with WireMock stubs: run in Compose, both locally and in CI.
- Contract: run in CI, in the consumer and provider pipelines.
- End-to-end: run a small smoke suite in the per-PR preview environment.
Cost and time
Idle previews are the main cluster cost. Previews that stay up after a PR goes quiet keep paying for nodes. Tear them down on merge and apply a time-to-live to cover the rest. The other cost is maintenance of Compose files, stubs, contracts and pipeline scripts. Give each artifact a named owner. When nobody owns stubs, they drift from the real services, engineers stop trusting them, and the team returns to shared staging.
Illustrative estimate (assumed numbers, not a benchmark)
- Preview spend = previews alive × hours alive × hourly cost. With assumed inputs of 10 previews × 200 hours/month × $0.50/hour, that is $1,000/month.
- Time saved = engineers × rebuilds/day × minutes saved × workdays. With assumed inputs of 20 × 15 × 2 min × 20 days, that is 200 hours/month.
The tiered approach also catches integration breaks at PR time, before they reach staging.
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 local development microservices, 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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.