Where your dependencies run decides the tool

The first question in microservices local development is where the service's dependencies run: databases, queues, and the other services it calls. Each of these three tools gives a different answer.

Docker Compose runs every dependency on your laptop as containers defined in one file. Teams choose it because it needs no cluster and starts with one command. The limit is your machine's RAM and CPU. It also doesn't reproduce Kubernetes behavior such as probes, service discovery, or network policies.

Tilt watches your source, rebuilds images, and redeploys them into a Kubernetes cluster, usually a local one. It shows logs and status for every resource in one dashboard. Teams use it when their manifests or Helm charts are the source of truth and they want to test against them during development. Tilt can also drive Compose, so it isn't tied to Kubernetes.

Telepresence runs only the service you are editing on your machine. It connects that local process to a remote cluster's network, so your service can call cluster DNS names directly. It can also intercept requests sent to the cluster's copy of the service and route them to your laptop. Teams use it when the full system is too large to run locally.

Compose        Tilt                     Telepresence
[ Laptop ]     [ Laptop ]               [ Laptop ]
 svc A          src -> build             svc A (local)
 svc B           |                         |
 db, queue       v                         |  network bridge
                [ Local/dev cluster ]      v
                 svc A, B, db           [ Shared remote cluster ]
                                         svc B..Z, db, queue
                                         traffic to A -> laptop

These tools are not interchangeable because each one assumes a different place for your dependencies.

COMPOSE                   TILT                      TELEPRESENCE
+----------------------+  +----------------------+  +----------------------+
| Laptop               |  | Laptop               |  | Laptop               |
| [svc A]  [svc B]     |  | src --> image build  |  | svc A (local process)|
| [db]     [queue]     |  | (Tilt watch loop)    |  | edit, run, debug     |
|                      |  +----------+-----------+  +----^----------|------+
| all deps run here as |             | deploy            |          |
| containers, one file |             v                   | traffic  | calls
| no cluster needed    |  +----------------------+  +----+----------v------+
+----------------------+  | Local or dev cluster |  | Shared remote cluster|
                          | [svc A]  [svc B]     |  | traffic to A rerouted|
                          | [db]     [queue]     |  | [svc B..Z] [db] [q]  |
                          +----------------------+  +----------------------+

Compose runs every dependency on the laptop, Tilt rebuilds code on the laptop and deploys it into a local or dev cluster, and Telepresence runs one service locally while it calls into a shared remote cluster and receives that cluster's intercepted traffic.

How Docker Compose microservices setups work

Compose puts the whole local stack in one compose.yaml: images, build contexts, environment variables, ports, and volumes for every service. Because the file is declarative and lives in the repository, the environment is versioned with the code. A change to the database version or a new environment variable goes through review like any other change.

When you run docker compose up, Compose creates a project network and attaches every service to it. Each container can reach the others by service name, so the app connects to db:5432 instead of localhost or an IP address. The app uses the container port, not whatever port you publish to the host. This is why the same connection string works on every laptop.

For live code, bind-mount the source directory into the app container, for example ./src:/app. Edits on the host appear inside the container immediately, and a file-watching dev server or interpreter picks them up without a rebuild.

Plain depends_on only controls the order in which containers start. It does not wait for Postgres to accept connections. To wait for readiness, give db a healthcheck that runs pg_isready, and set the app's depends_on to condition: service_healthy. The app then starts only after the database reports healthy.

Profiles keep large stacks manageable. If you tag a service such as a background worker with profiles: [worker], a plain docker compose up skips it. To include it, run docker compose --profile worker up.

Compose works best for stacks that fit comfortably on one machine. It also suits offline work, since nothing depends on a remote cluster, and onboarding, where a new engineer can clone the repository and start the stack with one command.

# compose.yaml: the whole local stack in one versioned file
services:
  app:
    build: .
    command: npm run dev           # file-watching dev server reloads on edits
    ports:
      - "3000:3000"
    environment:
      # Use the service name and the container port, not localhost
      DATABASE_URL: postgres://app:devpassword@db:5432/appdb
    volumes:
      - ./src:/app/src             # bind mount so host edits appear immediately
    depends_on:
      db:
        condition: service_healthy # wait for readiness, not just container start

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpassword # local placeholder only
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10

  worker:
    build: .
    command: npm run worker
    profiles: [worker]             # skipped unless the profile is enabled
    depends_on:
      db:
        condition: service_healthy

# Start app + db only:        docker compose up
# Also start the worker:      docker compose --profile worker up

A compose.yaml where the app reaches Postgres by service name, mounts source for live reload, waits for the database healthcheck via depends_on, and leaves an optional worker behind a profile, plus the commands to start it with and without that profile.

How Tilt dev workflows work

Tilt targets the opposite case: services that already run on Kubernetes and need to stay there during development. You describe the dev environment in a Tiltfile, written in Starlark, a Python-like configuration language. Because it is real code with functions and loops, one file can configure a dozen services without copy-paste.

A minimal Tiltfile does three things. k8s_yaml loads your existing manifests, or the rendered output of helm() for a chart, so you deploy what production deploys rather than a parallel description. docker_build tells Tilt how to build each image and matches it to the image references in those manifests. A live_update block inside docker_build lists sync steps that copy changed files into the running container and run steps, such as a dependency install, that fire only when specific files change. This lets Tilt patch a running container instead of rebuilding the image and redeploying the pod. Tilt watches the build contexts and reacts on save. Its web UI shows each resource separately, with its own build status, logs and errors, so a failing service is easy to find among healthy ones.

The failure modes are concrete. A live update with too narrow a sync path, or a missing run trigger, leaves the container with partial or stale state that matches neither your source nor a clean build. When behavior looks wrong, force a full rebuild first. The Tiltfile drifts as services change and needs an owner. Starlark is a learning curve for engineers who don't otherwise touch it. And Tilt is only as reliable as the cluster beneath it.

Tilt suits Kubernetes-native teams that already maintain production manifests or charts.

# Tiltfile (Starlark; highlighted as Python because the syntax is close)

# Deploy the same manifests production uses, not a parallel dev description.
k8s_yaml(['k8s/api-deployment.yaml', 'k8s/api-service.yaml'])

# Alternative: render an existing Helm chart with dev-specific values.
# k8s_yaml(helm('charts/api', values=['charts/api/values-dev.yaml']))

# The image name must match the image reference in the Deployment,
# so Tilt can substitute its own build when it applies the manifest.
docker_build(
    'registry.example.com/shop/api',
    '.',
    dockerfile='Dockerfile',
    live_update=[
        # Dockerfile changes can't be patched in place: do a full rebuild.
        fall_back_on(['Dockerfile']),

        # Copy source edits into the running container.
        # A path that is too narrow here leaves stale code in the container.
        sync('./src', '/app/src'),
        sync('./package.json', '/app/package.json'),
        sync('./package-lock.json', '/app/package-lock.json'),

        # Reinstall dependencies only when the lockfiles change.
        # Without this trigger, new deps never land and the container drifts.
        run('npm ci', trigger=['./package.json', './package-lock.json']),
    ],
)

# Name the resource in the UI, group it, and forward a local port.
# 'api' is the Deployment name from the manifest above.
k8s_resource('api', port_forwards='8080:3000', labels=['backend'])

A minimal Tiltfile that reuses existing Kubernetes manifests, builds the matching image, and uses live_update to sync source files and rerun npm ci only when the lockfiles change, falling back to a full rebuild when the Dockerfile changes.

How Telepresence works and when to avoid it

Telepresence takes the opposite approach to Compose and Tilt. It doesn't recreate dependencies on your laptop. It brings the laptop into the cluster. Running telepresence connect sets up a network tunnel and DNS configuration so that local processes can resolve and call cluster services by name, as if they were running in a pod. telepresence list shows which workloads in the namespace can be intercepted. telepresence intercept then reroutes traffic bound for one of those workloads to a port on your machine, either all of it or only requests that match certain criteria. The service you're changing runs locally under your usual debugger, hot reloader, and IDE, while its upstream callers and downstream databases and queues are the real ones. This matters when the hard bugs come from real data shapes, real service behavior, or configuration that no local mock reproduces.

When to avoid it

  • Privileges and network conflicts. The local daemon needs elevated rights to change DNS and routing. These changes can collide with corporate VPNs, endpoint security agents, and split-tunnel setups.
  • Cluster-side components. A traffic manager and per-workload agents must run in the cluster. The platform team has to install, upgrade, and govern them.
  • Shared blast radius. An unscoped intercept on a shared namespace sends your teammates' requests to your laptop.
  • Network dependence. High latency makes every call slow, and if the cluster is down, local development stops too.
  • Data exposure. Real traffic can reach your machine. Use per-developer namespaces or seeded datasets, never production data.
# Point kubectl at your own namespace so an intercept can't capture
# teammates' traffic in a shared namespace (placeholder name).
kubectl config set-context --current --namespace=dev-alice

# Start the local daemon and tunnel. This step needs elevated
# privileges because it changes DNS and routing on your machine.
telepresence connect

# Cluster DNS now resolves from the laptop. Call a real downstream
# service by its in-cluster name to confirm.
curl -s

# Show which workloads in the namespace can be intercepted.
telepresence list

# Reroute traffic for the 'orders' workload to port 8080 locally.
# Requests now reach the process on your laptop instead of the pod.
telepresence intercept orders --port 8080

# Run the service under your normal toolchain. It receives real
# callers' requests and talks to the real databases and queues.
npm run dev   # or your debugger / IDE run configuration

# When finished, remove the intercept so traffic goes back to the
# pod, then shut down the daemon and restore local networking.
telepresence leave orders
telepresence quit

A minimal Telepresence session scoped to a per-developer namespace: connect, verify cluster DNS, list workloads, intercept one service to a local port, then remove the intercept and disconnect.

Parity, speed, and resource ceilings compared

Docker Compose Tilt Telepresence
Where dependencies run Laptop containers Local or remote Kubernetes Shared remote cluster
Production parity Low: separate config format High: real manifests Highest: real cluster, mesh, ingress
Startup speed Fast after first pull Slower first run, fast live updates Fast locally, bound by network
Setup effort Low Medium: Tiltfile plus cluster High: cluster access, RBAC, in-cluster agent
Offline capability Full Full with a local cluster None
Privileges required Docker daemon access Docker plus cluster credentials Local admin for networking, cluster RBAC
Failure blast radius One laptop One laptop or one namespace Can affect shared traffic if intercepts are misused
Laptop resource load Entire stack Entire stack plus cluster overhead One service

Compose files drift because they duplicate what Helm charts or manifests already define. Teams update one source and forget the other. Probes, resource limits, ConfigMap-mounted files, ingress path rewrites, and mesh-enforced mTLS or retries have no Compose equivalent. Bugs in those layers only appear after deployment. Tilt and Telepresence both run the real manifests, so this class of bug shows up during local development.

Illustrative estimate. Assume these inputs:

  • 15 services at 400 MiB and 0.1 vCPU idle each: 6 GiB, 1.5 vCPU
  • Postgres, a message broker, and Redis: about 3 GiB
  • Local Kubernetes overhead: about 1.5 GiB
  • IDE and browser: about 4 GiB

That totals roughly 14.5 GiB at idle, before any rebuild spikes CPU. On a 16 GiB laptop, the machine starts swapping. Each new service adds about 0.5 GiB with its sidecars. Around this size, teams typically stop running everything locally. They move dependencies to a shared remote cluster and run only the service under change on the laptop.

Combining tools and choosing by team situation

The tools are not mutually exclusive, and the most practical setups often combine two of them.

Compose for state, Tilt for app services

Databases, queues, and caches rarely change during a workday. They need persistent volumes and stable ports, not rebuilds. Running them under Compose keeps them out of Tilt's rebuild loop and lets them survive restarts of the app layer. Tilt can load a Compose file directly, so you still get one dashboard and one command. Tilt then handles the services you edit, where live updates and dependency ordering pay off.

Tilt locally, Telepresence for the rest

When the dependency graph is too large for a laptop, run the service you are changing under Tilt for fast rebuilds. Use Telepresence to connect that process to a shared remote environment. Your code resolves cluster DNS and reaches real upstreams, while the edit-run loop stays local. The cost is coordination. Intercepts on a shared namespace can affect teammates, so agree on personal intercepts or per-developer namespaces first.

Decision checklist

Team situation Recommended setup Why
Small stack, few services Docker Compose Lowest setup cost; whole stack fits locally
Kubernetes-native, production manifests exist Tilt (plus Compose for stateful deps) Reuses real manifests; keeps state out of the rebuild loop
Large or heavyweight dependency graph Tilt + Telepresence to a shared cluster Avoids local resource ceilings
Reproducing bugs tied to real cluster data or traffic Telepresence intercept Only option that sees real requests and data

Impact on cost and engineering time

Generic claims like "hot reload is instant" or "Compose is lighter" don't tell you what your team actually loses. Pick one representative service and time four things with a stopwatch or shell timestamps:

  • Edit to running code: save a file and wait until the change answers a request.
  • Cold start: bring up the full stack from a clean machine or a pruned cache.
  • Dependency rebuild: change a lockfile or base image and wait until the service is running again.
  • Breakage: count how often the environment stops working each week and how long each recovery takes.

Illustrative estimate (assumed numbers, not a benchmark)

Weekly hours per engineer = (loops per day × loop seconds × 5) / 3600 + (breakages per week × hours per breakage)

Assume 60 loops per day.

Loop time Breakages Weekly hours
Before 90 s 1 per week at 2 h 7.5 + 2 = 9.5 h
After 15 s 1 per month at 2 h 1.25 + 0.5 = 1.75 h

That is about 7.75 hours per engineer per week, or roughly 78 hours across ten engineers. Not every waiting second is lost time, because people do review code during long builds. Context switching makes short waits costlier than the raw seconds suggest, though.

Cost of ownership

The other side of the ledger includes:

  • shared cluster nodes for Telepresence or remote Tilt targets
  • platform-team hours for access, upgrades, and support
  • ongoing edits to Tiltfiles and Compose files whenever services change

A single maintained golden path concentrates that cost in one place. Ten half-working local setups spread it invisibly across every engineer, and each person debugs the same drift separately.

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 .env by 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.