Platform engineering teams need deterministic, reproducible local environments that mirror production execution paths. Ad-hoc setups introduce configuration drift, prolong onboarding, and obscure CI/CD failures. The cost is rarely a single dramatic outage; it is the steady tax of an afternoon lost to a colleague's broken Postgres version, a bug that reproduces only on one laptop, a new hire idle on day three because the README skipped a step. Each of those is a symptom of the same disease — environment state that lives in someone's shell history instead of in version control. By standardizing on declarative Docker Compose configurations, teams move that state into the repository, where it can be reviewed, diffed, and rebuilt on demand. The payoff is concrete: environment baselines that everyone shares, dependency resolution that runs without manual steps, and parity between developer workstations and pipeline runners that turns "works on my machine" from an excuse into a testable claim.

The discipline rests on one principle — the environment is code. The Compose file, the Dockerfile, the .env.example, and the bootstrap script are reviewed like any other change and produce identical results from any clean checkout. This work spans five problem areas — devcontainer configuration standards, local network and port mapping, multi-service orchestration with Compose, volume mounting and hot-reload optimization, and Compose profiles for targeted environments — and pairs closely with environment sync, secrets, and CI parity and the broader work of developer onboarding and friction mapping.

Containerized local environment topology A developer workstation runs a Docker Compose stack — devcontainer, gateway, application, database, and cache — connected over an explicit bridge network, with a verification loop feeding back from CI parity checks. Local Compose Stack Topology Devcontainer • Pinned base image • Toolchain features • Bind-mounted source • postCreate hooks Compose Services • gateway / proxy • app + worker • db (healthcheck) • cache + queue Bridge Network • Stable DNS aliases • Scoped port binds • Internal segments • Named volumes Verification loop — fail fast when local diverges from CI

Declarative Workspace Baselines

Zero-friction provisioning begins with a single command that initializes a fully configured workspace. Define the baseline using .devcontainer/devcontainer.json paired with a base docker-compose.yml. This decouples IDE configuration from runtime orchestration while keeping shell environments consistent across VS Code, JetBrains, and terminal-only workflows. The full ruleset for image pinning, mount strategy, and lifecycle hooks lives in devcontainer configuration standards.

// .devcontainer/devcontainer.json
{
  "name": "Platform Baseline",
  "dockerComposeFile": ["../docker-compose.yml"],
  "service": "app",
  "workspaceFolder": "/workspace",
  "features": {
    "ghcr.io/devcontainers/features/git:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "postCreateCommand": "bash scripts/bootstrap.sh"
}

The scripts/bootstrap.sh script validates host prerequisites, copies .env.example to .env, and brings the stack up. Never hardcode credentials; route them through a .env file that is explicitly .gitignored. The single most important property of this baseline is that it is idempotent: running it twice on a clean checkout, on a half-built workstation, or after a crashed container all converge to the same running state. That guarantee is what lets you delete an environment and rebuild it without fear, which in turn is what makes onboarding a one-command operation rather than a half-day of tribal knowledge. If you are still deciding between a devcontainer and a plain Compose stack, weigh the trade-offs in devcontainers vs bare Docker Compose for team onboarding.

Two decisions made here ripple through everything downstream. First, pin the base image and every feature to an explicit version or digest — a floating latest tag silently reintroduces drift the moment upstream republishes. Second, keep IDE concerns (extensions, settings, port labels) in customizations and runtime concerns (services, networks, volumes) in the Compose file, so a terminal-only contributor and a VS Code user run byte-identical containers.

Lifecycle hooks deserve careful placement because they run at different times and for different reasons. postCreateCommand runs once when the container is first built — the right home for npm ci or pip install, which only need to run when dependencies change. postStartCommand runs on every start, so it suits bringing up background services or applying pending migrations. postAttachCommand runs when the editor attaches and is where a long-running npm run dev belongs. Putting a dependency install in postStartCommand by mistake means paying its cost on every restart; putting a watch process in postCreateCommand means it never restarts after the first build. Getting this mapping right is the difference between a workspace that feels instant and one that stalls for a minute every morning.

#!/usr/bin/env bash
# scripts/bootstrap.sh
set -euo pipefail

cp -n .env.example .env || true
docker compose config --quiet && echo "Compose schema valid"
docker compose up -d --wait
docker compose run --rm app bash -c 'printenv | grep -c "^APP_"' \
  && echo "App env vars injected"

Multi-Service Orchestration and Startup Order

Implicit depends_on declarations only guarantee container start order, not application readiness. A Postgres container reports "started" the instant its process spawns, but it cannot accept connections until it has run initialization, replayed WAL, and opened its socket — often several seconds later. An API that connects during that window crashes or, worse, silently retries against a half-initialized database and corrupts its first migration. Replace bare ordering with explicit healthcheck conditions so dependent services block until their target is actually serving traffic. This eliminates the race conditions that cause flaky integration tests and the "it works on the second up" class of bug. A well-formed healthcheck distinguishes liveness (the process is up) from readiness (it can do useful work); for orchestration ordering you want readiness, which is why pg_isready against the application database — not merely a TCP probe on port 5432 — is the correct test. Tune start_period to cover the slowest cold start you expect, so a slow first migration does not trip the retry budget and mark a healthy service as failed. The detailed patterns — seed data, resource quotas, teardown — live in multi-service orchestration with Compose, and the specific failure of services attaching before their dependencies are ready is covered in resolving service startup order and healthcheck races.

# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app_db
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app_db"]
      interval: 3s
      timeout: 5s
      retries: 5
      start_period: 10s
  api:
    image: app/api:latest
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:${API_PORT:-3000}:3000"

Deterministic Networking and Service Discovery

Default bridge networks lack stable DNS resolution and scope ports too broadly. Two failure modes dominate. The first is accidental exposure: ports: ["5432:5432"] binds to every host interface, so a database meant for local development is reachable from the office Wi-Fi. Binding to 127.0.0.1 closes that hole. The second is brittle service discovery: code that hardcodes 172.18.0.4 breaks the moment Docker reassigns addresses on the next up. Declare explicit networks to segment traffic, bind host ports to 127.0.0.1, and rely on service names — which Docker's embedded resolver maps to current container IPs — for every inter-container call. Use an internal: true network for back-end services that should never be reachable from the host at all. Segmenting front-end and back-end networks also lets you model production trust boundaries locally: a worker on the backend network can reach the database, but the gateway on frontend cannot, which surfaces an accidental cross-tier dependency at development time instead of in a staging incident. When you need stable virtual domains across many services, local network and port mapping covers reverse proxies and wildcard DNS, while configuring local DNS for microservice routing handles custom TLD resolution.

# docker-compose.yml
services:
  gateway:
    image: traefik:v3.1
    ports:
      - "127.0.0.1:80:80"
      - "127.0.0.1:443:443"
    networks:
      - frontend
  api:
    image: app/api:latest
    networks:
      - frontend
      - backend
networks:
  frontend:
    driver: bridge
  backend:
    internal: true

When a port refuses to bind because another process or a stale container already holds it, work through fixing "port is already allocated" errors in Compose.

Volume Mounts and Hot-Reload

Iteration velocity depends on rapid feedback loops and predictable state persistence. There are two distinct mount jobs and they have opposite requirements. Source code needs to flow host-to-container instantly so a save triggers a reload; bind mounts (./src:/app/src) do that but suffer filesystem-event latency on macOS and Windows because every event crosses the VM boundary. Stateful data — Postgres files, search indexes — needs raw I/O throughput and must survive container recreation; named volumes (db_data:/var/lib/postgresql/data) live inside the VM and deliver near-native speed. Mixing the two is where performance dies: bind-mounting node_modules forces the host to synchronize tens of thousands of tiny files on every reload. Mask those high-churn directories with anonymous or named volumes so they never traverse the host filesystem at all. The complete tuning guide is in volume mounting and hot-reload optimization.

# docker-compose.yml
services:
  app:
    image: node:20-alpine
    volumes:
      - ./src:/app/src:cached
      - /app/node_modules
    develop:
      watch:
        - path: ./src
          target: /app/src
          action: sync
  db:
    image: postgres:16-alpine
    volumes:
      - db_data:/var/lib/postgresql/data
volumes:
  db_data:

Run the stack with docker compose watch for sub-second sync. The develop.watch directive is preferable to in-container polling (CHOKIDAR_USEPOLLING=true) because polling pegs a CPU core scanning the filesystem and still lags real events by a second or more; native sync pushes only the changed files and lets the in-container watcher fire on a real inotify event. When edits land on the host but the process inside the container never restarts, see fixing hot-reload not triggering on file changes, and for ownership errors on bind mounts, fixing volume permission issues on macOS and Windows.

Targeted Environments with Compose Profiles

A single docker-compose.yml rarely fits every task. Frontend work does not need the data-pipeline workers; a quick API check does not need the full observability stack. The naive workaround — maintaining docker-compose.frontend.yml, docker-compose.full.yml, and friends — multiplies the surface that can drift out of sync. Compose profiles solve it inside one file: tag services with profiles:, and docker compose up starts only the always-on core plus whichever profiles you name. A service with no profile always runs; a profiled service runs only when its profile is activated. This keeps cold-start time and RAM pressure proportional to the task at hand, which matters on a laptop juggling a dozen services. The full pattern lives in Compose profiles and targeted environments, with a worked example in running a subset of services with Compose profiles.

# docker-compose.yml
services:
  api:
    image: app/api:latest
  worker:
    image: app/worker:latest
    profiles: ["pipeline"]
  grafana:
    image: grafana/grafana:11.1.0
    profiles: ["observability"]
#!/usr/bin/env bash
set -euo pipefail
# Start only the core API and its dependencies
docker compose up -d
# Add the pipeline workers when you need them
docker compose --profile pipeline up -d

Cross-Cutting Concerns

The same OS-specific failure modes recur across every section above, and they account for the majority of "it works for me but not for them" support tickets. The root cause is almost always that Docker Desktop on macOS and Windows is not running Linux containers natively — it runs them inside a managed Linux VM, and everything that crosses the host-to-VM boundary (bind mounts, port binds, file-change events, file ownership) is translated rather than passed through. Line-ending differences add a second axis: a .env or shell script committed with CRLF on Windows fails to parse on a teammate's Linux container. Set core.autocrlf input and add a .gitattributes that forces LF on scripts and env files. Treat the notes below as a shared checklist for any Compose change.

macOS (Docker Desktop): Bind mounts traverse a Linux VM via VirtioFS; prefer :cached consistency for read-heavy source trees and never use :consistent for development. Host port binds add ~10–50ms under connection churn. Windows / WSL2: Keep the repository on the Linux filesystem (~/code, not /mnt/c) so inotify/FSEvents events fire and 9p translation does not throttle I/O. Run port scans and scripts inside the WSL2 distro, not PowerShell. Apple Silicon (ARM64): Pin platform: linux/amd64 only for images that lack an arm64 manifest, since emulation negates the native performance win. Verify with docker manifest inspect before pinning.

Verification Suite

Exercise the whole baseline with one target so any developer — or CI job — can confirm the stack is reproducible in seconds. The target validates the Compose schema, brings every service up to a healthy state, asserts the API answers a health probe, and tears the stack down cleanly — proving both that the environment builds and that it leaves no residue behind. Wire this same target into a pre-push hook and a CI smoke job so a configuration change that breaks the stack is caught before it reaches another developer's machine.

# Makefile — full-stack verification
.PHONY: verify-stack
verify-stack:
	@set -euo pipefail; \
	docker compose config --quiet; \
	docker compose up -d --wait; \
	docker compose ps --format '{{.Name}} {{.Status}}'; \
	docker compose exec -T api curl -fsS http://localhost:3000/health; \
	docker compose down -v --remove-orphans; \
	echo "Containerized environment baseline OK"