Docker Compose skill for Claude Code
One command to a working local stack. Claude builds compose.yaml files where the app waits for a healthy database instead of sleeping, migrations run as a one-shot service, ports bind to localhost, and optional tools sit behind profiles. It separates .env interpolation from container env, sets up Compose Watch for hot reload, and validates everything with docker compose config and up --wait.
- name
docker-compose - size226 lines · ≈2,100 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds local development stacks with Docker Compose v2 — services, healthchecks with depends_on conditions, named volumes vs. bind mounts, profiles, env files and interpolation, Compose Watch for hot reload, and override files. Use when writing or fixing a compose.yaml, when services start before the database is ready, when hot reload does not work in a container, or when onboarding needs a one-command local environment. Try asking
- Set up a compose file with Postgres, Redis and our API that works with one command
- The app container crashes because Postgres is not ready yet. Fix the startup order
- Hot reload stopped working inside the container on my Mac
Use it when
- A project needs a reproducible local environment for new developers
- Services race each other on startup
- Environment variables are not reaching containers as expected
- Code changes are not picked up inside a dev container
Skip it when
- You are deploying to a cluster — use kubernetes-deploy instead
- The problem is image size or build caching — use dockerfile-optimization instead
How to check the result
docker compose config passes, docker compose up -d --wait exits zero, and docker compose ps shows every service with a healthcheck as healthy.
The common mistake it prevents
Expecting variables in .env to appear inside containers; that file only feeds interpolation in compose.yaml unless it is also listed under env_file.
A good Compose stack comes up with one command on a clean machine, starts services in dependency order based on real readiness, keeps data across restarts, reloads code without rebuilds, and never requires editing the file to switch personal settings.
Ground rules
- Use
docker compose(v2 plugin), file namecompose.yaml. Drop the top-levelversion:key; it is obsolete and only produces a warning. - Readiness comes from
healthcheck+depends_on: condition: service_healthy, never fromsleepin an entrypoint. - Services talk to each other by service name (
postgres:5432), notlocalhost. - Bind published ports to
127.0.0.1unless another device must reach them. - Secrets for local dev live in a git-ignored
.env; commit.env.example. docker compose down -vdeletes named volumes (database data). Ask before running it.
Workflow
- [ ] 1. Inventory services, ports, data that must persist, and dev-only tools
- [ ] 2. Write compose.yaml: app + backing services with healthchecks
- [ ] 3. Wire env: .env for interpolation, env_file/environment for containers
- [ ] 4. Add hot reload (Compose Watch or bind mount)
- [ ] 5. Put optional services behind profiles
- [ ] 6. Validate with `docker compose config`, then `up --wait`
- [ ] 7. Document the commands in the README
2. Baseline file
name: shop
services:
api:
build:
context: .
target: dev # dev stage of a multi-stage Dockerfile
ports:
- "127.0.0.1:3000:3000"
env_file:
- path: .env
required: false
environment:
DATABASE_URL: postgres://app:app@postgres:5432/app
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
init: true
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package-lock.json
migrate:
build: { context: ., target: dev }
command: ["npm", "run", "db:migrate"]
environment:
DATABASE_URL: postgres://app:app@postgres:5432/app
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
mailpit:
image: axllent/mailpit
profiles: ["tools"]
ports:
- "127.0.0.1:8025:8025"
volumes:
pgdata:
Note for Postgres 18+ images: the data directory layout changed; mount the volume at
/var/lib/postgresql per the image docs instead of /var/lib/postgresql/data.
Healthcheck commands that work
| Service | test |
|---|---|
| Postgres | ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] |
| MySQL/MariaDB | ["CMD", "mysqladmin", "ping", "-h", "localhost"] (MariaDB 11: healthcheck.sh --connect) |
| Redis/Valkey | ["CMD", "redis-cli", "ping"] / ["CMD", "valkey-cli", "ping"] |
| HTTP app with curl | ["CMD", "curl", "-fsS", "http://localhost:3000/health"] |
| HTTP app without curl | ["CMD", "wget", "-qO-", "http://localhost:3000/health"] or a tiny script in the app’s runtime |
| Kafka (bitnami/apache) | kafka-topics.sh --bootstrap-server localhost:9092 --list |
$$ escapes $ so the variable is expanded inside the container, not by Compose.
3. Environment: two different mechanisms
| Mechanism | Who reads it | Use for |
|---|---|---|
.env in project dir | Compose itself, for ${VAR} interpolation in compose.yaml | Ports, image tags, personal overrides |
env_file: | Injected into the container | App config and local secrets |
environment: | Injected into the container, overrides env_file | Values that must be fixed (service URLs) |
| Shell env when running compose | Interpolation, highest precedence | One-off overrides: API_PORT=4000 docker compose up |
Interpolation with defaults and required values:
ports:
- "127.0.0.1:${API_PORT:-3000}:3000"
image: "myorg/api:${TAG:?TAG must be set}"
Check the resolved result before debugging anything else: docker compose config.
4. Hot reload
Prefer Compose Watch (docker compose up --watch, or docker compose watch):
| Action | When |
|---|---|
sync | Source files the dev server already watches (copied into the container) |
sync+restart | Config files that need a process restart but not a rebuild |
rebuild | Lockfiles, Dockerfile, anything that changes the image |
Bind-mount alternative (needed when tools inside the container write back to the host):
volumes:
- ./:/app
- /app/node_modules # anonymous volume: keep the image's Linux node_modules
If file changes are not detected through a bind mount on macOS/Windows, enable polling
in the dev server (CHOKIDAR_USEPOLLING=1, WATCHPACK_POLLING=true, Vite
server.watch.usePolling).
5. Profiles and overrides
- Services with
profiles:start only when asked:docker compose --profile tools up. Never put a service that othersdepends_onbehind a profile. compose.override.yamlis merged automatically; use it for personal tweaks and git-ignore it, or commit acompose.override.example.yaml.- Split large stacks with top-level
include:(other compose files with their own relative paths) rather than long-fchains.
6. Validate and run
docker compose config --quiet && echo "compose file valid"
docker compose up -d --wait # returns when all healthchecks pass, non-zero if not
docker compose ps # STATUS column must show (healthy)
docker compose logs -f --tail=100 api
docker compose exec postgres psql -U app -d app -c 'select 1'
Debugging table
| Symptom | Likely cause | Check |
|---|---|---|
ECONNREFUSED 127.0.0.1:5432 from app | Using localhost inside a container | Use service name postgres |
| App starts then crashes on DB connect | No healthcheck condition | depends_on.condition: service_healthy |
port is already allocated | Host service or another stack on that port | lsof -i :5432; change host side via ${PORT:-...} |
| Env var empty inside container | Defined only in .env (interpolation), not passed | Add to env_file/environment |
| Data gone after restart | No named volume, or someone ran down -v | docker volume ls |
| Code changes ignored | Watch not running or path mismatch | docker compose up --watch; compare target to WORKDIR |
exec format error | Image built for other arch | platform: linux/amd64 or multi-arch image |
Healthcheck stuck starting | Tool missing in image (curl) | docker compose exec svc which curl |
Gotchas
depends_onwithout a condition only orders container start, not readiness.restart: alwayshides crash loops in dev; preferunless-stoppedor none, and read logs.- Changing
POSTGRES_PASSWORDafter the volume exists does nothing; the database was initialized on first boot. Recreating requires deleting the volume (confirm first). - Scripts in
/docker-entrypoint-initdb.drun only on an empty data directory. - Bind-mounting the project over
/appalso hidesnode_modules/.venvbuilt in the image. container_namebreaks--scaleand collides across checkouts; let Compose name containers.- Compose project name defaults to the directory name; set top-level
name:so two worktrees do not share volumes unexpectedly, or deliberately vary it per worktree.
Output
Deliver compose.yaml, .env.example, any Dockerfile dev stage, and a README block:
## Local development
cp .env.example .env
docker compose up -d --wait # api on http://localhost:3000
docker compose --profile tools up -d mailpit # optional: mail UI on :8025
docker compose logs -f api
docker compose down # stop (keeps data); `down -v` also wipes the DB About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.