Local Development with Docker Compose for a Polyglot Stack
TL;DR — Compose v2 + healthchecks +
profilesmakes a polyglot microservices stack tractable on a laptop. Onedocker compose upbrings up everything; profiles let you opt into noisy dependencies only when you need them.
By article seven of January we have a containerized monolith, two Go services, and a clear pattern for splitting more out. The next thing breaking is the developer experience. Three weeks ago “starting the dev environment” meant php artisan serve. Today it means PHP-FPM, Nginx, two Go services, Postgres, Redis, a Kafka broker, and a UI dev server. Without a single command to bring it all up, onboarding a new engineer becomes a half-day adventure.
That single command is docker compose up. Compose v2 (the Go rewrite that ships with current Docker Desktop and docker-compose-plugin on Linux) is what makes this manageable in 2022. Profiles, dependency conditions, healthchecks, and proper networking are all sharp tools — sharper than they were in v1 — and you’ll use all of them.
The compose file, narrated
Here’s the working docker-compose.yml for our stack. Long but straightforward; we’ll narrate the interesting parts after.
name: monolith-stack
services:
postgres:
image: postgres:14-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
monolith:
build:
context: .
dockerfile: Dockerfile
env_file: .env.local
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8080:8080"
volumes:
- ./:/app
- /app/vendor
command: ["supervisord", "-c", "/etc/supervisord.conf"]
notifications:
build:
context: ../notifications
env_file: ../notifications/.env.local
depends_on:
postgres:
condition: service_healthy
ports:
- "8081:8080"
billing:
build:
context: ../billing
env_file: ../billing/.env.local
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "9090:9090"
kafka:
image: bitnami/kafka:3.1
profiles: ["events"]
environment:
KAFKA_CFG_NODE_ID: "0"
KAFKA_CFG_PROCESS_ROLES: "controller,broker"
KAFKA_CFG_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
KAFKA_CFG_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092"
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: "0@kafka:9093"
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
ports:
- "9092:9092"
mailhog:
image: mailhog/mailhog:latest
profiles: ["mail"]
ports:
- "1025:1025"
- "8025:8025"
volumes:
postgres-data:
redis-data:
Several things doing real work in here.
condition: service_healthy on depends_on. Compose v2’s killer feature. Old depends_on only waited for the container to start, which for Postgres meant “the process is running but the database is still initialising.” Half your services would crash on startup because they hit Postgres a second too early. With healthchecks plus service_healthy condition, Postgres has to actually accept connections before its dependents start. No more boot-order race conditions.
profiles: ["events"] on Kafka. Kafka is heavy. Most local dev work doesn’t need it. Putting it behind a profile means docker compose up skips it by default, and you opt in with docker compose --profile events up. Same for MailHog — only spins up when someone actually wants to test transactional email.
name: monolith-stack. Compose v2 added top-level name. Without it, the project name comes from the directory name, which differs between contributors. Setting it explicitly means everyone’s containers are named the same way; useful when you docker exec -it monolith-stack-postgres-1 ....
Build contexts pointing outside the file’s directory. The notifications and billing services live in sibling repos. Compose can build them via relative path. This is the polyglot part: each service has its own Dockerfile, its own dependency manifest, its own image. Compose just orchestrates.
vendor as an anonymous volume. The - /app/vendor line is there to prevent the host bind mount from clobbering the vendor directory baked into the image. Composer install ran in the build, and we don’t want a missing host-side vendor/ to nuke it.
Workflow patterns
Some patterns we use day-to-day.
Boot the stack:
docker compose up -d
docker compose logs -f monolith notifications billing
Boot with optional profiles:
docker compose --profile events --profile mail up -d
Run a one-off command in a service container:
docker compose exec monolith php artisan migrate
docker compose exec billing go test ./...
Tear it all down (preserving data):
docker compose down
Nuke and start fresh (volumes too):
docker compose down -v
Rebuild after a Dockerfile change:
docker compose build --no-cache notifications
docker compose up -d notifications
A Makefile wraps these into one-word targets so nobody has to remember. New engineers run make up on day one, run make seed for sample data, hit localhost:8080, and they’re working.
Common Pitfalls
Sharing the same Postgres for everything. Tempting because it’s “just local dev.” Don’t. Each microservice should connect to its own database (or schema) even locally — that’s the only way to catch coupling early. Use the same Postgres container with multiple databases via the init.sql; use separate connection strings per service.
Bind-mounting node_modules or vendor. Always escape these with anonymous volumes (- /app/node_modules, - /app/vendor). Otherwise the host directory wins and your container has no dependencies.
Hardcoding localhost in service URLs. Inside the compose network, services reach each other by service name (postgres, billing), not localhost. The host-side ports (5432:5432) are for tools running on the host (psql, dbeaver), not for inter-service traffic.
Forgetting the :ro on read-only mounts. If you bind-mount init.sql as read-write and Postgres modifies it (it shouldn’t, but stranger things happen), you’ll wonder why your CI’s identical setup behaves differently. :ro everywhere it’s safe.
Running heavy services everyone doesn’t need. Kafka, Elasticsearch, ClickHouse — none belong in the default docker compose up. Profile them. People who don’t need them shouldn’t be paying the RAM cost.
Wrapping Up
Compose v2 is mature enough in 2022 that it’s the default tool for local microservices dev. Healthchecks plus service_healthy plus profiles are the three knobs that turn a brittle pile of containers into a stack a new hire can boot in ten minutes. Next post: configuration management for Go services — specifically how to wire env vars from your .env.local into your services without losing your sanity.