Containerized Dev Environments and Docker Compose
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. This topic sits inside the wider practice of developer onboarding and friction mapping: a containerized workstation is the concrete artifact that turns an onboarding checklist into a single reproducible command. The cost of skipping that discipline 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.
Strategic overview
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.
The five problem areas above are not an arbitrary list; each maps to a distinct class of drift that a real team hits in a predictable order. A workstation first fails to build the same way twice — that is the devcontainer and image-pinning concern. Once it builds, services fail to start in a usable order — that is orchestration and healthchecks. Once they start, they fail to find each other reliably — that is networking and DNS. Once they talk, the edit-save-reload loop is too slow to be usable — that is volume strategy. And finally, the whole stack is too heavy to run for a narrow task — that is profiles. Reading the sections in that sequence mirrors the order a new environment matures, so a team can adopt the practice incrementally rather than rewriting everything at once.
Everything below assumes a single source of truth: a docker-compose.yml checked into the repository root, an optional .devcontainer/ directory beside it, and a .env.example that documents every variable the stack reads. The Compose file is authored to the v2 specification (no top-level version: key), so docker compose config is the canonical validator — if it parses and renders, every contributor and every CI runner sees the identical merged configuration. That single guarantee is what makes the rest of this page enforceable rather than aspirational.
Treating the environment as code also changes how it is reviewed. A change to docker-compose.yml gets the same scrutiny as a change to application source: a reviewer can read the diff, reason about its blast radius, and reproduce the result on their own machine before approving. That review surface is where drift is actually caught — a floating tag introduced in a pull request, a port quietly widened from loopback to all interfaces, a healthcheck deleted "to make CI green" — each is visible as a line in a diff instead of a surprise discovered three sprints later. The companion practices of environment sync, secrets, and CI parity extend the same principle to the values that must not live in the repository, so the split is clean: structure and defaults are versioned in Compose, and secrets are injected at run time through a mechanism every contributor shares.
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.
Image identity is the other half of a stable baseline. Prefer a digest pin (postgres@sha256:…) over a mutable tag when byte-for-byte reproducibility matters, because a tag such as postgres:16-alpine can point at a rebuilt image tomorrow. Digest pins make the trade-off explicit: you get perfect reproducibility at the cost of updating the digest deliberately, which is exactly the review gate you want around a base-image bump. Where a floating minor tag is acceptable, record the resolved digest in a lockfile-style comment so a drift diff is still visible in review. Confirm what a fresh checkout will actually pull with docker compose config --images, which prints the fully resolved image reference for every service without starting anything.
The .env.example file is the third leg of the baseline and the one most often neglected. It is a committed, secret-free template that names every variable the stack reads and gives a safe default or an inline comment for each, and the bootstrap script copies it to .env on first run with cp -n so it never clobbers a developer's local edits. Keeping the two files in lockstep is what makes onboarding self-documenting: a contributor who runs the stack and hits a missing-variable error can diff .env against .env.example and see exactly what they failed to set. Enforce the relationship with a one-line drift check — comm -23 <(grep -oP '^[A-Z_]+' .env.example | sort) <(grep -oP '^[A-Z_]+' .env | sort) prints any variable documented in the template but absent from the local file — and run it in the same verification target that validates the schema, so a newly introduced variable that a teammate forgot to document fails fast instead of surfacing as a runtime crash.
#!/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"
Compose exposes three depends_on conditions and choosing the wrong one is the usual root cause of a flaky start. service_started waits only for the container process to spawn — correct for a fire-and-forget sidecar that tolerates a not-yet-ready peer. service_healthy waits for the target's healthcheck to report healthy — the right choice for a database or message broker whose client connects and immediately issues a query. service_completed_successfully waits for a one-shot container to exit zero — the pattern for a migration or seed job that must finish before the API boots. Model those as an explicit dependency graph: a migrate service that runs to completion, an api that depends on both db being healthy and migrate completing, and workers that depend on the API. Add a deploy.resources.limits block to memory-hungry services (memory: 512m) so one runaway container cannot starve the rest of the stack on a constrained laptop, and Compose will surface an out-of-memory kill instead of a mysterious hang.
When a start does hang, the diagnostic is the healthcheck log rather than the application log. Run docker compose ps to see which service is stuck in starting versus healthy, then docker inspect --format '{{json .State.Health}}' <container> to read the last probe's exit code and output. A probe that returns non-zero on every attempt points at a wrong test command or a database name mismatch; a probe that never runs before the retry budget expires means start_period is too short for the observed cold start. Tuning those two numbers against the slowest machine on the team — not the fastest CI runner — is what keeps the same Compose file green everywhere, because a healthcheck calibrated only to a warm cache will intermittently fail the first cold boot of the day on a colleague's laptop.
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
Inside the stack, never publish a port just so one container can reach another — that is what the shared network is for. A service on the same network resolves its peers by service name through Docker's embedded DNS resolver at 127.0.0.11, and you can add extra stable hostnames with networks.<net>.aliases so a rename does not ripple through every connection string. Reserve published ports strictly for traffic that must originate on the host: your browser hitting the gateway, a database GUI attaching to Postgres. Segmenting the stack into a frontend bridge and an internal: true backend then encodes a production trust boundary locally, so an accidental call from the gateway straight to the database fails at development time with a resolution error rather than passing silently and surfacing as a staging incident weeks later.
A reverse proxy on the frontend network turns a wall of localhost:3001, localhost:3002 ports into memorable hostnames like api.app.localhost and web.app.localhost, which matters once a stack grows past a handful of services and port collisions become routine. Traefik reading Docker labels, or a small nginx config, routes by hostname to the right container without any published port per service — only the proxy publishes 80 and 443. This also makes cookies, CORS, and OAuth redirect URIs behave the way they do in production, because the browser sees real hostnames on a single origin family rather than a scatter of ports. The *.localhost TLD resolves to loopback automatically on most systems, so no /etc/hosts editing is required; where a custom TLD is needed, the resolver setup lives in configuring local DNS for microservice routing.
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.
The consistency flag on a bind mount is a deliberate lever, not a default to ignore. :cached tells the runtime the host is authoritative and brief container-side staleness is acceptable — correct for a read-heavy source tree. :delegated grants the container temporary authority for write-heavy scratch paths, and bare consistency forces synchronous coherency that tanks throughput; never use it for development. For state, choose named volumes over anonymous ones when you want the data to survive docker compose down and be addressable by docker volume inspect; reach for an anonymous volume (- /app/node_modules) only when the goal is purely to mask a host directory so its churn never crosses the VM boundary. The develop.watch block supports three actions that matter here: sync copies changed files into the container for a hot reload; sync+restart restarts the service after syncing, which suits a config file the process reads only at boot; and rebuild triggers a full image rebuild, correct for a dependency manifest like package.json where a changed lockfile must reinstall. Choosing the coarsest action a change actually requires — sync for source, rebuild for manifests — is what keeps the loop fast without ever serving stale dependencies. 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
Profile activation follows a few rules worth memorizing. A service with no profiles: key always starts. A profiled service starts only when its profile is named, either on the command line or through the COMPOSE_PROFILES environment variable — set COMPOSE_PROFILES=pipeline,observability in a shell profile or .env and every docker compose invocation activates them without a flag. Naming a service explicitly (docker compose up worker) also pulls in its profile implicitly, which is why a targeted command still works even when the profile is otherwise dormant. Because profiles live in the one docker-compose.yml, docker compose config --profiles lists every profile the file defines, giving reviewers a single command to audit what execution modes the repository supports instead of grepping for scattered override files.
Profiles compose cleanly with the one override file Compose already merges automatically. A docker-compose.override.yml sitting beside the base file is layered on top of every up without a -f flag, which is the sanctioned place for machine-specific tweaks — a developer's extra debug port, a mounted local certificate — that must never reach CI. Keep the base file authoritative and the override thin, because anything that lives only in an untracked override is drift by definition. The distinction to hold onto is that profiles decide which services run while an override decides how the running services are configured; conflating them by spawning docker-compose.frontend.yml, docker-compose.pipeline.yml, and their combinatorial explosion is exactly the fragmentation profiles were designed to retire. When you do need a second file for a genuinely different target — a CI-only variant that swaps bind mounts for baked images — name it explicitly and pass it with -f, so the deviation from the default is visible in the command rather than hidden in file-discovery magic.
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
:cachedconsistency for read-heavy source trees and never use:consistentfor development. Host port binds add ~10–50ms under connection churn. Windows / WSL2: Keep the repository on the Linux filesystem (~/code, not/mnt/c) soinotify/FSEventsevents fire and 9p translation does not throttle I/O. Run port scans and scripts inside the WSL2 distro, not PowerShell. Apple Silicon (ARM64): Pinplatform: linux/amd64only for images that lack anarm64manifest, since emulation negates the native performance win. Verify withdocker manifest inspectbefore pinning.
File ownership is the subtler recurring failure. A process running as UID 1000 inside a container writes files that appear owned by a different user on the host, and a bind-mounted directory created by root inside the container becomes un-deletable from the host without sudo. Set a user: matching the host UID/GID (user: "${UID:-1000}:${GID:-1000}") for services that write to bind mounts, and keep build artifacts in named volumes so ownership never leaks onto the host tree at all. Two more low-frequency but high-confusion issues round out the checklist: clock skew, where a Docker Desktop VM that has been suspended reports a stale time and breaks TLS or token validation until you restart the VM; and resource ceilings, where the whole stack is silently capped by the CPU and memory allotted to Docker Desktop, so a stack that runs on Linux CI may OOM on a laptop until the VM's limits are raised. Encode the expected UID/GID and minimum resource assumptions in the same .env.example that documents every other variable, so a mismatch is a diff rather than a mystery.
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"
The value of a single target is that it is identical everywhere it runs. A developer executes make verify-stack before pushing; a CI job runs the same target on a clean runner; a reviewer runs it against a branch to reproduce a bug. Because it validates the schema first (config --quiet), it fails fast on a malformed Compose file without waiting for images to pull. Because it uses --wait, it blocks until every healthcheck passes rather than reporting success the instant containers spawn — so a broken healthcheck fails the target instead of leaking into the next step. And because it ends in down -v --remove-orphans, it proves the environment leaves no residue: no dangling volumes, no orphaned containers from a renamed service. Wire the target into a pre-push hook so a change that breaks the stack never reaches another machine, and into CI as a required check so the pull request that introduces drift cannot merge. That closes the loop the opening diagram drew — local and CI exercise byte-identical configuration, and divergence surfaces as a failed check rather than a lost afternoon.
Frequently Asked Questions
Does docker compose down delete my database volume?
No. docker compose down removes the containers and the default network but preserves named volumes, so your Postgres data survives a normal teardown. The data is deleted only when you add the -v flag (docker compose down -v), which removes named volumes declared in the Compose file. The verification target above uses -v deliberately because its whole purpose is to prove the stack rebuilds from nothing; a routine down in daily work should omit it.
Why does my API still crash on startup even with depends_on?
Bare depends_on guarantees only that the dependency's container has started, not that it can accept connections. A Postgres container reports started the instant its process spawns, seconds before it finishes initialization and opens its socket. Add a healthcheck to the database and set depends_on: { db: { condition: service_healthy } } so the API blocks until a pg_isready probe passes. For a migration job that must finish first, use condition: service_completed_successfully instead.
Should I pin image tags or digests in the Compose file?
Pin a specific tag (postgres:16-alpine) at minimum; never rely on latest, which silently reintroduces drift when upstream republishes. For byte-for-byte reproducibility across machines and time, pin a digest (postgres@sha256:…), which resolves to exactly one image forever. The trade-off is that digests must be bumped deliberately — which is the review gate you want around a base-image change. Run docker compose config --images to see exactly what a fresh checkout will pull.
Why is hot-reload slow or missing on macOS and Windows?
Docker Desktop runs containers inside a Linux VM, so every file-change event crosses the host-to-VM boundary and arrives late. Bind-mounting high-churn directories like node_modules makes it worse by synchronizing tens of thousands of files. Mask those directories with a named or anonymous volume, mount source with :cached consistency, and drive reloads with docker compose watch (the develop.watch directive) rather than in-container polling, which pegs a CPU core and still lags real events.