The cost of a hard-to-start stack
Ask a new engineer how long it took to get the application running locally. If the answer is days, you are paying for it on every hire, every laptop replacement and every context switch between projects. The usual symptoms are a wiki page that has been wrong for a year, a database installed natively at a different version from production, and a Slack message that begins with “did anyone else get this error?”
Docker Compose fixes this when it is designed, not just written. It is a core part of our Docker containerisation service, and it pays for itself quickly.
What good looks like
git clonefollowed by one command starts everything, including seed data.- The versions of PostgreSQL, Redis and other dependencies match production.
- Code changes appear without rebuilding the image by hand.
- A second command resets the environment to a clean state.
- Failure messages tell the developer what to do next.
- The same Compose file (or a close relative) is used in CI for integration tests.
A practical layout
We split configuration so that each file has one job. A shared compose.yaml defines the stack, and an optional override handles local-only behaviour.
# compose.yaml
services:
api:
build:
context: .
target: dev
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://cache:6379
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
cache:
image: redis:7
mailpit:
image: axllent/mailpit
profiles: ["tools"]
ports:
- "8025:8025"
volumes:
dbdata:
The credentials above are throwaway local values that never leave the developer machine. Anything real stays out of the repository and is loaded from an untracked .env file or a secrets manager.
Start the default stack with docker compose up --watch, and add optional tools with docker compose --profile tools up.
The details that make it pleasant
Startup order and readiness
depends_on on its own only waits for a container to start, not for the service inside to accept connections. A healthcheck plus condition: service_healthy solves the common database race. We also make the application retry on startup, because production will have the same race during a restart.
Hot reload
Two approaches work. Bind mounts share your source directory with the container, and a watcher such as nodemon, air or Spring DevTools reloads the process. Compose Watch (available in recent Compose releases) syncs files or triggers rebuilds on change, which can be faster on macOS and Windows. We choose per project after trying both on your actual machines.
Seed data and resets
A make dev-reset that runs docker compose down -v and seeds known data turns a broken local database from a half-day problem into a thirty-second one. Seed scripts live in the repository and are tested in CI.
Local stand-ins for cloud services
LocalStack for AWS APIs, MinIO for S3-compatible storage and Mailpit for outbound email let developers work offline. We are cautious here: emulators drift from real services, so we use them for development speed and still run an integration test against real infrastructure in a pre-production environment.
Running tests and debuggers against the stack
A local stack earns more when it also runs the tests. We add a test profile or a separate service that runs the integration suite against the same database and cache, so a developer can reproduce a CI failure with one command rather than guessing at the difference. For debugging, the Dockerfile dev target exposes the language’s debug port, and the README shows the editor attach configuration for the two or three editors your team uses. Without that, people fall back to print statements and rebuilds.
Handling secrets and environment files
Local convenience is where secrets leak. We keep a committed .env.example with safe defaults and a .gitignore entry for the real .env. Where a service needs a real credential, such as a sandbox key for a payment provider, developers pull it from your secrets manager with a documented command rather than pasting it into chat. A pre-commit hook that scans for key patterns is a cheap safety net. This matters more than it looks: Compose files are routinely copied into other projects, and anything real inside them travels too.
Performance on different machines
Teams with mixed macOS, Windows (WSL 2) and Linux laptops hit different problems. Bind mounts across the virtualisation boundary can be slow for large dependency trees, so we keep node_modules or the language’s package cache in a named volume instead of the host directory. Apple silicon laptops pulling x86-only images run under emulation, which is slow, so we check that dependencies publish ARM variants or set the platform explicitly. These are small fixes, but they decide whether developers love the setup or quietly work around it.
Decisions and trade-offs
Full stack or only dependencies? Running only databases and queues in Compose while the app runs natively on the host is faster to iterate on. Running the app in a container is more consistent. We often offer both modes in the same file.
One Compose file for all services or one per repo? For a few services in a monorepo, one file works. For many independent repositories, an include: of per-service files or a shared dev environment repository scales better.
When not to do this. If your application depends on a large number of managed cloud services that cannot be emulated, a shared dev environment in the cloud may serve better than a heavy local stack. And Compose is a development tool: for running workloads on a cluster, see migrating from Docker Compose to Kubernetes or ECS.
Common failure modes
- Port collisions with services already running on a laptop. We use documented, less common host ports.
- Stale volumes carrying old schemas. A documented reset command fixes it.
- File permission problems from bind mounts written by root. We run containers as a matching non-root user.
- A dev image that differs wildly from production. We use a shared Dockerfile with a
devtarget, so the production stages remain the source of truth. See our Dockerfile standards.
Delivery and handover
We deliver the Compose files, a task wrapper, a README section, and a CI job that starts the stack from scratch and runs a smoke test. We then run a short onboarding session where a developer who has never seen the project follows the instructions, and we fix whatever slows them down.
How to tell it is working
A new joiner is running the system within the first hour, “works on my machine” reports fall, and CI uses the same definition developers use. To talk through your current setup, contact us.