Skip to content

Docker Compose Recipes For Local Dev

217 companion flashcards · AI-assisted study content · Open the deck →

This deck is a practical collection of Docker Compose recipes aimed at everyday local development. The cards cover the kinds of things you reach for constantly: mounting host directories into containers, defining environment variables, wiring up healthchecks so dependent services wait for things like Postgres to be ready, persisting data across container recreations, and distinguishing between ports exposed to your host versus ports only visible to other services on the Compose network.

It's well suited to developers who already understand the basics of Docker but want a quick reference for the common patterns and gotchas that come up when building a local dev environment. You'll also find cards on workflow tasks like rebuilding after Dockerfile changes, tailing logs for a single service, running one-off commands without editing your compose file, scaling a stateless service, and overriding Compose files for production. A few cards highlight subtle pitfalls, such as why a container can't reach localhost on your host machine.

Because the content is recipe-driven rather than conceptual, the cards stick best when you pair them with hands-on practice. Open a compose.yml, try each directive as you study it, and watch the behavior change. Short, spaced review sessions tend to work better than long cramming for this kind of material, since the goal is to recognize the right snippet or flag quickly when you're setting up a new service.

Compose File Anatomy

A Docker Compose file is a YAML document that describes a multi-container application. In Compose v2, the modern format no longer requires a top-level version: field; the schema is inferred from the features you use. The top-level keys you'll encounter are services, networks, volumes, secrets, configs, and name. Inside each service definition under services:, the most-used keys are image, build, ports, environment, env_file, volumes, depends_on, healthcheck, restart, command, and networks. A service either runs a pre-built image referenced by tag or digest, or is built from a Dockerfile using a build: block.

Environment configuration in Compose has two distinct concepts that often confuse newcomers. The .env file in the same directory as your compose file is auto-loaded by Compose for variable interpolation inside the YAML itself, with values referenced as ${VAR}. The env_file: key on a service, on the other hand, populates environment variables inside the running container at runtime. To set variables inline you use environment: with a list of KEY=value pairs, and to inherit a value from the shell you write just the key with no value, e.g. environment: - HOME. You can also override values on the command line with WEB_PORT=4000 docker compose up, but only if the compose file uses \({WEB_PORT} interpolation.

Compose supports a shell-like interpolation language for variable references. A bare \){VAR} resolves to the empty string if the variable is unset. Use \({VAR:-default} to supply a fallback when the variable is unset or empty, and \){VAR:?error message} to make Compose fail loudly if the variable is missing, which is the strict mode you want for production. To include a literal dollar sign in a value, escape it as $$, because an unescaped $ will be treated as the start of an interpolation. The docker compose config command is invaluable here: it validates the file and prints the fully resolved configuration with all interpolation applied, which is the fastest way to debug why a variable isn't showing up where you expect.

Services, Dependencies, and Health

The depends_on: key controls the order in which Compose starts services, but it has a sharp edge that catches almost everyone the first time. The short form depends_on: [db, redis] and the long form with condition: service_started only wait for the container to be created and started, not for the service inside that container to be ready to accept connections. For a database, that means your app may begin trying to connect before the database has finished initializing. The fix is to add a healthcheck: to the dependency and use condition: service_healthy instead.

A healthcheck is a per-service test that Docker runs inside the container on a schedule. For Postgres, a typical healthcheck uses pg_isready -U postgres; for a web service you might curl a health endpoint. The schema accepts test (the command to run), interval (how often to run it), timeout, retries (consecutive failures before marking unhealthy), and start_period (an initial grace window during which failures don't count). The defaults are 30s interval, 30s timeout, 3 retries, and no start period, which is too aggressive for slow-starting databases, so tune these for your workload. Once a service has a healthcheck, dependents can wait on it via depends_on: db: condition: service_healthy, and even better, the dependent itself should retry its connection on startup rather than assuming a single attempt is enough.

Beyond service_healthy, Compose offers service_completed_successfully for one-shot init or migration containers. You mark a service as a one-off with restart: 'no', give it a command that exits when its work is done, and then other services depend on it with the completed-successfully condition. This is the cleanest pattern for running database migrations or seed scripts before the main app boots. Restart policies are also part of service health: restart: always restarts no matter how the container exited, on-failure[:N] only restarts on a non-zero exit with an optional retry cap, and unless-stopped restarts always except when you explicitly stopped it. For local development, unless-stopped is usually the right default because it survives reboots but lets you take a service down deliberately.

Volumes, Mounts, and Data Persistence

Compose supports three flavors of persistent storage, and choosing the right one matters. A bind mount maps a host path into the container, written as ./local:/app, with optional read-only mode :ro or a consistency mode like consistency: cached for macOS performance. Bind mounts are ideal for source code in development because edits on the host appear immediately in the container, but they make it easy to clobber image-baked files. A named volume is managed by Docker and survives container recreation, declared at the bottom of the compose file under a top-level volumes: key and referenced in the service as pg_data:/var/lib/postgresql/data. An anonymous volume is created implicitly when you write a path with no name, like /var/lib/postgresql/data by itself; it survives container restarts but is removed by docker compose down -v.

The most important data-persistence pattern for stateful services like databases is the named volume. For Postgres, the data directory lives at /var/lib/postgresql/data, and on first start the entrypoint initializes an empty database there. If you bind-mount that path or omit a volume entirely, every container recreation wipes your data. The canonical recipe declares a named volume at the bottom of the file and mounts it on the database service. To list all named volumes in a project, use docker volume ls; Compose prefixes them with the project name, so they appear as <project>_<volume>. To find the host path where a named volume actually lives, run docker volume inspect <name> and look at the Mountpoint field.

There are two more storage options worth knowing. A tmpfs: mount is in-memory scratch space that's lost on container restart, useful for caches and intermediate files you don't want hitting disk. Anonymous volumes also solve a famous bind-mount footgun: when you mount your source over /app, you wipe out any pre-installed dependencies like node_modules that were baked into the image. The standard fix is to declare an anonymous volume on the dependency directory after the bind mount, so the host mount provides source code but the image's installed files are preserved. For init scripts that should run only on the first creation of an empty database, mount SQL or shell files into /docker-entrypoint-initdb.d/; the Postgres and MySQL images execute these files alphabetically, so prefix them with 01_, 02_ to control order. Finally, docker compose down -v removes everything including volumes, so treat that flag as destructive and never use it casually on a database you care about.

Networking in Compose

Every Compose project gets a default network named <project>_default, and on that network services can resolve each other by service name. That name resolution is what makes the line depends_on: db actually useful: when your app says db:5432, it reaches the database container, not the host. This is also the most common stumbling block for newcomers, because inside a container, localhost refers to the container itself, not the host machine. To reach the host from a container, use the service name for other Compose services, or host.docker.internal on Docker Desktop. On Linux, host.docker.internal works too in recent versions, but the service name is always the cleanest answer.

You can declare additional networks under a top-level networks: key, specifying a driver like bridge and optionally IPAM configuration for static addressing. Services attach to networks via the service-level networks: key, which accepts a list, so a single service can be on multiple networks. This is the standard pattern for gateway or reverse-proxy containers that need to talk to both a public-facing frontend network and a private backend network while the app services themselves only sit on the backend. To join a network that was created outside this compose file, declare it with external: true; this is how you share a network across multiple Compose projects. For IPv6, set enable_ipv6: true on the network and provide a subnet under IPAM, and to pin a specific container to a fixed IP, set ipv4_address under the service's network entry.

For advanced topologies, you can use a different driver entirely. A macvlan network gives a container its own IP on the physical LAN, which is useful when a container needs to be reachable from other physical machines without NAT. To add DNS configuration that overrides container defaults, set the dns: key on a service with a list of resolvers, which is handy behind corporate DNS that can't resolve public hostnames. To add hostname aliases that resolve inside the container, use extra_hosts: for one-off mappings, or attach a service to a network with an aliases: list to give it additional hostnames visible to other services on that network. The expose: key differs from ports: in an important way: expose: only makes the port reachable to other services on the Compose network, while ports: publishes it to the host machine, with the syntax HOST:CONTAINER.

Building, Scaling, and Resources

The build: key tells Compose how to construct an image for a service. The simplest form is just a path to a directory containing a Dockerfile, but a richer form lets you specify the context separately from the dockerfile, so you can keep the Dockerfile named differently per environment. To pass build-time variables, use build.args with a map of ARG names to values, and reference them in the Dockerfile as ARG NAME; remember that ARG values are only available during the build, not at runtime, which is exactly what you want for things like a NODE_VERSION baked into a base image. To build for multiple CPU architectures in one go, list platforms under build.platforms, which requires BuildKit and buildx. To use a specific stage of a multi-stage Dockerfile, set build.target to the stage name.

To rebuild images after changing a Dockerfile, run docker compose build or docker compose up --build. To rebuild only one service, pass the service name: docker compose build web. To force a fresh pull of base images before starting, run docker compose pull && docker compose up -d. To skip rebuilding and reuse the existing image, use --no-build, and to skip pulling entirely use --no-pull. To force-recreate containers without rebuilding the image, use docker compose up -d --force-recreate, optionally scoped to a single service. The docker compose watch command in Compose v2 provides live development by watching source paths and either syncing changed files into the running container or triggering a full rebuild, configured via develop: or x-develop: blocks on each service.

Resource controls live under the deploy.resources key in Compose, though they were originally designed for Swarm. Under limits, you can set cpus as a fractional number like '1.5' and memory with a unit like 512M; the kernel will kill the container if it exceeds the memory limit, which is how OOM kills manifest. Under reservations, you can set guaranteed minimums that act as soft limits. Setting realistic memory limits is the single most effective way to avoid mysterious OOM kills, which you can detect by checking the OOMKilled field in docker inspect. To scale a service to multiple instances, the local-development-friendly command is docker compose up -d --scale worker=3, while in Swarm-mode Compose you would declare replicas under deploy.replicas. To request a GPU, list capabilities: [gpu] under deploy.resources.reservations.devices, which requires the NVIDIA runtime to be installed on the host.

Multi-Environment Compose and Configuration

One of Compose's most powerful features is the ability to split a configuration across multiple files and merge them. The conventional layout is a base docker-compose.yml containing the shared services, a docker-compose.override.yml that is automatically merged on top for local development, and a docker-compose.prod.yml you opt into with -f. To start the production stack you run docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d. When multiple files are given, later files override earlier ones, and mappings are merged deeply, so you can add a key to a service in the override without rewriting the whole service block. The same merging can be triggered without CLI flags by setting the COMPOSE_FILE environment variable to a colon-separated list, or a semicolon-separated list on Windows.

Profiles let you mark a service as opt-in, so it only starts when you explicitly include its profile. A common pattern is to add profiles: [dev] to services like Adminer, MailHog, or a debug container, keeping the production up clean. To start with profiles, pass --profile dev or set COMPOSE_PROFILES=dev,debug. A service can be in multiple profiles, and it starts if any matching profile is active. This is much cleaner than commenting and uncommenting service blocks. The profiles key also gives you a way to define a one-shot migration or seed service that runs on demand rather than on every up, by giving it a profile and combining it with the service_completed_successfully condition pattern.

Configuration precedence for environment variables follows a strict order that you need to internalize when debugging. From highest to lowest precedence: a -e flag on docker compose run, the service's environment: block, the service's env_file:, the shell environment, and finally ENV directives baked into the Dockerfile. For secrets, Compose's secrets: top-level key declares file-backed or external secrets that mount inside the container at /run/secrets/<name> as files, never as environment variables, which is the secure default. For local development, a gitignored .env file referenced via env_file is the most pragmatic option, but in production you should pull secrets from a real secrets manager or a CI-injected file. BuildKit also supports build-time secrets via build.secrets, used in the Dockerfile with --mount=type=secret, which never get cached in image layers. The project name, which prefixes container and volume names, can be changed with -p projname or the COMPOSE_PROJECT_NAME environment variable, and the working directory Compose uses can be overridden with --project-directory or COMPOSE_PROJECT_DIR.

Logs, Debugging, and Common Gotchas

Debugging a Compose project starts with the docker compose ps command, which shows the status of every service, and the docker compose logs command, which prints container output. To follow logs in real time, add -f; to scope to one service, pass its name: docker compose logs -f web; to scope to multiple services, list them: docker compose logs -f web db. To limit output to recent lines, use --tail=100, and to add timestamps, use --timestamps. For a quick health check, docker compose top shows running processes inside each container, and docker compose stats is a Compose v2 shortcut for live CPU and memory usage. To watch lifecycle events like start, die, and kill, useful when a container keeps restarting, use docker compose events.

To interact with a running container, use docker compose exec with a command. docker compose exec web sh opens a shell, docker compose exec web env | sort lists all environment variables inside the container (a great way to verify what actually got set), and docker compose exec --user root web sh escalates to root inside a container that normally runs as a non-root user. To copy files in or out, use docker compose cp web:/path ./local and the reverse form. To see which host port maps to a container port, use docker compose port web 80. To run a one-off command without modifying the compose file, use docker compose run --rm web bash, remembering that the service name must match a defined service and the working directory must contain a compose file, or you need to pass -f.

Several pitfalls recur often enough to be worth memorizing. The "service not found" error on docker compose run is almost always a typo or a wrong working directory; Compose only loads docker-compose.yml from the current directory unless you point it elsewhere. Port conflicts happen when another container, another project, or a host process binds the same port; the fix is to change the host-side port in the ports: mapping, e.g. '8081:80', or to find the offending process with sudo lsof -iTCP:8080 -sTCP:LISTEN or ss -ltn. Bind mounts sometimes appear empty inside the container because the host path is wrong or the relative path was resolved from the wrong directory; remember that relative paths in volumes: are resolved from the directory containing the compose file, not the directory you ran the command from. On macOS and WSL, file-watching via inotify often fails over bind mounts, so hot-reload tools may need polling mode. On WSL2 in particular, keeping your project under the Linux filesystem rather than /mnt/c/ dramatically improves performance. To clean up, docker compose down removes containers and networks but keeps volumes, while docker compose down -v --remove-orphans also removes volumes and stray containers from removed service definitions. For nuclear cleanup, docker system prune -a --volumes removes all unreferenced images, networks, and volumes; treat it as a last resort.

Stack-Specific Recipes and Patterns

For Postgres, the canonical Compose service sets POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB environment variables to create a database on first run, mounts a named volume on /var/lib/postgresql/data for persistence, mounts SQL or shell files into /docker-entrypoint-initdb.d/ for one-time initialization, and defines a healthcheck using pg_isready -U postgres. The MySQL equivalents are MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, and MYSQL_PASSWORD. To back up a Postgres database, run docker compose exec db pg_dump -U postgres -d mydb > backup.sql; to restore, run docker compose exec -T db psql -U postgres -d mydb < backup.sql.

For Redis, persistence is opt-in via command-line flags: command: ['redis-server', '--appendonly', 'yes'] plus a volume on /data turns on append-only file persistence, and --requirepass sets a password. For MongoDB, set MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD to enable authentication on first start. RabbitMQ's default guest/guest credentials only work from localhost; for real credentials, mount a definitions file or use the RABBITMQ_DEFAULT_USER and RABBITMQ_DEFAULT_PASS environment variables. Elasticsearch in development is happy with discovery.type=single-node and xpack.security.enabled=false in its environment. Kafka in KRaft mode (no Zookeeper) is easiest with the bitnami image and a few KAFKA_* environment variables. For Nginx serving a static SPA, the typical recipe mounts the build output to /usr/share/nginx/html read-only and configures a try_files fallback to index.html so client-side routes work.

Common developer tooling stacks have well-trodden patterns. A LAMP-style setup links php-fpm, nginx (mounting a site.conf file), and mysql (with a volume and env) over the default Compose network. A Laravel app typically runs php-fpm, nginx, mysql, redis, and a queue worker as its own service running php artisan queue:work. A Rails app combines web (puma), sidekiq, postgres, and redis, with an optional Chrome service for system tests. A Django app runs gunicorn, celery worker, celery beat, postgres, and redis, with flower as an optional monitoring service. A Next.js dev environment runs next dev with postgres and an anonymous volume on node_modules. A FastAPI setup runs uvicorn with --reload plus postgres. For dev tooling, MailHog catches outgoing email on ports 1025 (SMTP) and 8025 (UI), Adminer provides a database GUI on port 8080 reachable via the compose service name as host, and reverse proxies like Traefik or Caddy attach via labels or mounted config files to route traffic. For Go and Python hot-reload, the cosmtrek/air image or uvicorn --reload handle the rebuild loop inside the container. As a general rule, keep the base docker-compose.yml committed to git for reproducibility while gitignoring docker-compose.override.yml so each developer can customize without conflicting.

Frequently asked questions

Compose file directive to mount a host directory into a container?

volumes: - ./local:/app

Mount a single config file as read-only?

volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro

Define a tmpfs mount (in-memory)?

tmpfs: /tmp — fast scratch space, lost on restart.

Set the default platform for all services?

services:<br> web:<br> platform: linux/amd64 (or env DOCKER_DEFAULT_PLATFORM).

Pause/unpause a service?

docker compose pause web and docker compose unpause web — process is suspended via SIGSTOP.

MySQL equivalents?

MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD.

Read-only root filesystem?

read_only: true — combine with tmpfs: for writable scratch dirs.

Color the log output?

Compose colors by default; --no-color to disable for piping.

Share IPC with host (legacy apps)?

ipc: 'host' — security risk; avoid if possible.

Healthy compose for Rails app?

web (puma) + sidekiq + postgres + redis + (optionally) chrome for system tests.

Drill this topic

217 flashcards on Docker Compose Recipes For Local Dev — free, no signup needed to start.

Study Docker Compose Recipes For Local Dev flashcards

LearnWiki pages are generated with AI assistance from LearnCoachAssist's reviewed study catalog and may contain errors — verify anything critical against your course materials.