Every new engineer joining the team used to spend their first day installing Postgres, Redis, and a specific Node version locally, fighting version mismatches the whole time. Replacing that with a single docker compose up took the onboarding time down to minutes, but getting the compose setup itself right, especially around data persistence and hot reload, took several iterations worth documenting.
Compose v2, invoked as docker compose rather than the old standalone docker-compose binary, is what ships with Docker Desktop and Docker Engine now, and the file format dropped the version key entirely in recent Compose spec versions since it's no longer needed. The base file defines three services in our stack: the API, Postgres, and Redis, each with explicit health checks rather than relying on "the container started" as a proxy for "the service is ready."
services:
api:
build: .
ports: ["8000:8000"]
volumes: ["./src:/app/src"]
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: devpassword
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
volumes:
pgdata:
Database data goes in a named volume, pgdata above, so it survives container recreation without living inside the repo directory or getting wiped every time someone runs docker compose down. Application source code goes in a bind mount so changes on the host show up in the container immediately without a rebuild. Mixing these up, putting source in a named volume, is a mistake I made once and spent a confused half hour wondering why my code changes weren't showing up.
Bind-mounting source code isn't enough by itself, file watching tools relying on inotify sometimes don't pick up changes correctly across the host-to-container boundary, especially on macOS where the Docker VM adds another layer of file system translation. Setting CHOKIDAR_USEPOLLING or the framework-equivalent polling flag fixed reload reliability at the cost of slightly higher CPU usage, a tradeoff worth taking for local development where correctness of the dev loop matters more than efficiency.
One compose file trying to serve local development, CI, and a rough approximation of production ended up serving none of them well. Splitting into a base compose.yaml plus a compose.override.yaml for local-only additions, extra debug ports, volume mounts for hot reload, is picked up automatically by Compose without extra flags, while CI uses the base file plus a separate compose.ci.yaml via the -f flag for anything CI-specific like disabling restart policies.
A .env file at the project root, read automatically by Compose for variable substitution in the compose file, holds local defaults and stays out of version control via .gitignore, while an .env.example with placeholder values ships in the repo so a new clone has a template to copy. This is a small thing that every project should do from the first commit and almost none do until someone accidentally commits a real credential once.
depends_on without a condition only waits for a container to start, not for the service inside it to actually be ready to accept connections, which caused intermittent "connection refused" errors on a cold start before I added explicit health checks. The service_healthy condition, paired with a real healthcheck definition on the dependency, is what actually solves this, not just service ordering.
docker compose down -v wipes volumes along with containers, which is exactly what you want when the local database has gotten into a weird state but overkill for a routine restart. Keeping docker compose down (without -v) as the default habit and reserving the -v flag for genuine "start the database over from scratch" situations avoided a lot of unnecessary reseeding during normal development.
A fresh clone with an empty database meant every new engineer manually running a seed script before the app was actually usable, which negated a chunk of the "one command" promise. Mounting a seed SQL file into Postgres's official /docker-entrypoint-initdb.d directory runs it automatically on first container creation, but only on first creation, which caught us off guard the first time a schema change needed the volume wiped and recreated for the new seed to apply at all.
On a laptop running the full stack alongside an IDE and a browser with forty tabs, an unconstrained container doing a heavy build step would occasionally eat enough memory to make everything else sluggish. Setting explicit mem_limit and cpus values on the heavier services, generous enough not to bottleneck normal work but capped enough to prevent one runaway process from taking the whole machine down, made the local dev experience noticeably more predictable across the team's different laptop specs.
Maintaining a separate Dockerfile.dev and Dockerfile for production risked the two drifting apart until a dependency present in dev silently wasn't in prod, and something broke only after deployment. A single multi-stage Dockerfile, with a dev stage layering in hot-reload tooling on top of a shared base and a slimmer production stage skipping it, kept both builds honest about sharing the same underlying base image and dependency set, closing that particular gap between what we tested locally and what actually shipped.
Docker Compose for local development earns its place specifically by removing the "works on my machine" version drift problem, but the payoff depends entirely on getting health checks, volume strategy, and hot reload right, none of which the minimal quickstart examples demonstrate. Once dialed in, onboarding a new engineer really did drop to a single command and a coffee break instead of a full day.