Everything for Docker Compose in one place — pick a section below. 20 reviewed items across 4 content types.
Quick Answer
Compose creates a dedicated user-defined bridge network per project (named after the project directory by default) and runs an embedded DNS resolver on that network so service names resolve to container IPs automatically — that's why checkout-worker can just connect to hostname postgres-db without hardcoding an IP. Two separate Compose projects get two separate networks and DNS namespaces, so name collisions across projects are harmless, but published host ports are a shared, global resource and will collide if two projects try to bind the same host port.
Detailed Answer
Picture an apartment building where every unit gets its own internal intercom system. Inside unit 4B, you can page 'kitchen' or 'front desk' and it just works, because that intercom network only exists within 4B's walls — it has no idea unit 6A also has a room called 'kitchen.' But the building's single street address and its shared elevator are different: only one entity can claim '123 Main St, Unit 1' at a time. Docker Compose networking works exactly like this. Each Compose project is its own private apartment (network), with its own internal name resolution, but published ports are the shared street address — a single, host-wide resource that any project can only claim once.
Compose was designed around per-project networks specifically so that stacks don't need central coordination to avoid name clashes. If every project shared one flat Docker network, then any two teams both naming a service 'redis' or 'api' would break each other's DNS resolution the moment both stacks ran on the same machine — a serious problem for shared CI runners or dev laptops running multiple projects at once. By scoping each project to its own bridge network (named <project>_default unless you override it), Compose lets 'db' mean something different in every project without any conflict.
Internally, when you run docker compose up, Compose first creates (or reuses) a user-defined bridge network for the project. Docker's embedded DNS server, which listens inside that bridge network at 127.0.0.11, registers each container's name and any network aliases as A records the moment the container starts. When checkout-worker's code does a DNS lookup for postgres-db, the container's resolv.conf points it at that embedded DNS server, which resolves the name to postgres-db's current container IP on that same bridge network — and re-resolves it if the container restarts and gets a new IP, which is why you should never hardcode IPs and always connect by service name. This whole mechanism is invisible to anything outside that specific bridge network; a container in a different Compose project's network cannot resolve or route to it at all unless you explicitly join both to an external shared network.
At scale, the failure mode teams actually hit isn't DNS collision (that's already solved by project isolation) — it's port collision. If project A's docker-compose.yml publishes 5432:5432 for its database and project B does the same, whichever starts second gets a hard 'port is already allocated' error, because host port bindings are a single shared namespace across the whole Docker daemon, not scoped per project the way the internal network is. Teams running multiple stacks on one CI host typically fix this by binding to ephemeral or offset host ports (binding host port to 0 lets Docker pick a free one) and only exposing internally.
The gotcha: containers in two different Compose projects cannot talk to each other by service name even if you know both project names, because they're on entirely separate bridge networks by default. Engineers debugging 'why can't service A reach service B' often don't realize the two are actually defined in separate docker-compose.yml files (maybe checked out from two different repos) and were never meant to share a network — the fix isn't a DNS problem to debug, it's adding both services to a common external: true network, and forgetting this is one of the most common multi-repo Compose setup mistakes.
Code Example
# List the isolated network Compose created for this project
docker network ls | grep myproject
# Inspect embedded DNS registrations and container IPs on that network
docker network inspect myproject_default | jq '.[0].Containers'
# docker-compose.yml — sharing a network across two separate Compose projects
networks:
shared-backend:
external: true # must already exist: docker network create shared-backend
services:
checkout-worker:
image: checkout-worker:1.8
networks:
- default # project-local network for internal service discovery
- shared-backend # explicit bridge to reach services in another project
# Create the shared network once, before either project starts
docker network create shared-backend
# Avoid host port collisions between two independently-run stacks
# by letting Docker pick a free ephemeral host port instead of hardcoding 5432
ports:
- "0:5432"Interview Tip
A junior engineer typically says 'Compose services can reach each other by name because of Docker networking,' but for a senior role the interviewer is actually testing whether you know networks are scoped per-project while published host ports are a global daemon-wide resource — that asymmetry is exactly where multi-stack CI hosts and shared dev machines break. Strong candidates explain the embedded DNS resolver at 127.0.0.11, why it re-resolves on container restart, and how to deliberately bridge two isolated projects with an external network when integration across repos is required. This question also probes whether someone has actually operated Compose on a shared host versus only ever running one stack at a time locally.
◈ Architecture Diagram
┌── Project A network ──┐ ┌── Project B network ──┐
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │ postgres │←─DNS────┐│ │ │ postgres │←─DNS───┐│
│ └──────────┘ ││ │ └──────────┘ ││
│ checkout-worker ──────┘│ │ payments-api ────────┘│
└─────────────────────────┘ └─────────────────────────┘
both bind host port 5432 → ✗ collision💬 Comments
Quick Answer
Start from the network layer outward: confirm both containers are actually attached to the same Compose network, then verify DNS resolution of the service name from inside the calling container, then test raw TCP connectivity to the port, and only then look at application-level auth or config errors. Most 'can't connect' incidents in Compose turn out to be a service accidentally left off a custom network, or a port mismatch between what the app is listening on inside the container versus what's configured downstream.
Detailed Answer
Think of this like troubleshooting why a phone call between two departments in an office isn't connecting. You don't start by suspecting the recipient's phone is broken — you first check whether both extensions are even on the same phone system, then whether dialing the extension number rings anywhere at all, then whether the call connects but nobody picks up, and only last whether the person who answers is refusing the specific request being made. Networking debugging should follow the same outside-in narrowing, because jumping straight to 'the app must be misconfigured' skips several cheaper, faster checks.
Compose intentionally exposes each of these layers separately so you can test them independently: docker network inspect shows you attachment (is the container even plugged into the network), docker exec ... nslookup shows you name resolution, docker exec ... nc -zv shows you raw TCP reachability, and only after those three pass does an actual failed connection point at something inside the application — wrong credentials, wrong database name, or a startup race condition. Skipping straight to reading application logs is the single most common reason engineers waste 30+ minutes on what turns out to be a one-line networks: misconfiguration.
Step by step: first, confirm attachment — run docker inspect checkout-worker and check .NetworkSettings.Networks, and do the same for postgres-db; if they're not on a common network key, that's your root cause immediately, usually because someone added a custom networks: block to one service and forgot the default network no longer auto-attaches once you define an explicit one. Second, exec into checkout-worker and run getent hosts postgres-db — if this fails to resolve, either the network attachment is wrong (step one already caught it) or the service was renamed and stale DNS caching in a long-lived connection pool is still holding an old IP. Third, test the actual port with nc -zv postgres-db 5432 from inside checkout-worker — if DNS resolves but the port doesn't respond, the app inside postgres-db likely isn't listening yet (a health check gap) or is bound to localhost inside its own container instead of 0.0.0.0. Only once TCP connects and you still see errors do you move to application logs for auth failures, wrong database names, or connection pool exhaustion.
At scale, this exact failure pattern is the most common cause of 'flaky' integration tests in CI — the network layer is fine, but the healthcheck is missing or too permissive, so checkout-worker starts before postgres-db's TCP listener is actually open, producing intermittent, hard-to-reproduce failures depending on scheduling luck. Teams that add condition: service_healthy on depends_on convert this from an intermittent CI flake into a deterministic wait, which is why it's worth checking as step zero before diving into DNS at all.
The gotcha: docker compose ps reporting a container as 'healthy' only reflects what its own healthcheck test command checked — it says nothing about whether a *different* service can actually reach it, since healthchecks run from inside the container's own namespace. Engineers regularly get burned assuming 'healthy' means 'reachable from anywhere,' when in reality a container can pass its own loopback-based healthcheck while still being unreachable from a sibling container due to a firewall rule inside the image or a service incorrectly binding to 127.0.0.1 instead of all interfaces.
Code Example
# Step 1: confirm both containers share a network
docker inspect checkout-worker --format '{{json .NetworkSettings.Networks}}' | jq 'keys'
docker inspect postgres-db --format '{{json .NetworkSettings.Networks}}' | jq 'keys'
# Step 2: check DNS resolution of the service name from inside the caller
docker exec checkout-worker getent hosts postgres-db
# Step 3: test raw TCP reachability on the actual port
docker exec checkout-worker nc -zv postgres-db 5432
# Step 4: only now check app-level logs for auth/config errors
docker compose logs checkout-worker --tail=100
# Bonus: confirm postgres-db is listening on all interfaces, not just localhost
docker exec postgres-db ss -tlnp | grep 5432Interview Tip
A junior engineer typically jumps straight to reading application logs when a connection fails, but for a senior role the interviewer is actually looking for a disciplined outside-in methodology — network attachment, then DNS, then TCP, then application — because that order finds the cheapest, fastest signal first and avoids wasted time staring at misleading app-level stack traces. Bringing up the fact that a 'healthy' status from docker compose ps only reflects a container's own internal healthcheck, not cross-container reachability, is a strong signal of real hands-on incident experience rather than textbook knowledge. The best answers also connect this to CI flakiness, since this exact failure mode is one of the top causes of non-deterministic integration test failures.
◈ Architecture Diagram
checkout-worker ──?──▶ postgres-db 1.attach? ──▶ 2.DNS? ──▶ 3.TCP? ──▶ 4.app logs ✓ same net ✓ resolves ✗ refused → not listening yet
💬 Comments
Quick Answer
Monitor per-container CPU and memory usage against their configured limits, container restart counts, and host-level disk and memory pressure, since Compose has no scheduler to reschedule a killed container elsewhere — a resource problem on that single host directly becomes an outage. The most actionable signal is memory usage trending toward the container's `mem_limit` combined with restart_count incrementing, which predicts an OOM kill before it happens rather than after.
Detailed Answer
Running a Compose stack in production is like running a small restaurant out of a single kitchen instead of a chain with a central commissary — if the grill and the fryer both get slammed with orders at the same time, there's no second kitchen down the street to send overflow orders to. Kubernetes has a scheduler that can move a crashed workload to a healthier node; Compose has no such thing. Whatever host is running docker compose up is the entire blast radius, so noticing resource pressure early matters more here than almost anywhere else, because the fallback when something runs out of memory is simply 'the container dies and restarts in place,' not 'gets rescheduled somewhere with more headroom.'
This single-host reality is exactly why Compose stacks need tighter, more proactive monitoring than their Kubernetes equivalents. Compose was built as a local/single-node tool, not a distributed orchestrator, so it deliberately doesn't include the observability and self-healing machinery Kubernetes has — that's the trade-off for its simplicity. Teams running Compose in staging or small production environments need to bolt on their own visibility, because without it, a memory leak in checkout-worker doesn't get flagged and rescheduled elsewhere — it just silently degrades the whole host until postgres-db, which happens to share that host, also starts swapping and slowing to a crawl.
Step by step, the useful signal path looks like this: docker stats (or its scraped equivalent via cAdvisor/Prometheus node exporter) reports live CPU and memory usage per container; compare that against each service's configured mem_limit and cpus in the compose file. As a container's memory usage approaches its limit, the kernel's OOM killer inside that container's cgroup will eventually kill its main process, Docker will report the container exited with code 137, and if restart: always or on-failure is set, Compose immediately restarts it — which, if the underlying leak is still there, restarts, leaks again, and repeats in a crash loop that shows up as a climbing RestartCount in docker inspect. Host-level metrics (available memory, swap usage, disk I/O wait) matter just as much, because containers without explicit limits can consume the entire host's memory and starve every other container, not just themselves.
At production scale, teams scrape docker stats-equivalent metrics into Prometheus via cAdvisor and alert on three tiers: container memory usage above 80% of its limit for 5+ minutes (leading indicator), any container restart in the last 10 minutes (lagging but urgent), and host-level memory pressure or swap activity (systemic risk to every co-located container). Disk is the most commonly forgotten dimension — Postgres write-ahead logs and application log files filling the host's disk will eventually cause every container on that host to fail unpredictably, often with confusing 'no space left on device' errors that look unrelated to the actual disk exhaustion.
The gotcha: a service without an explicit mem_limit doesn't get 'no limit' in a good sense — it gets access to the entire host's memory, meaning one leaking service can starve out completely unrelated, otherwise-healthy containers on the same box, and the OOM killer might pick the *wrong* victim (postgres-db instead of the actual leaking checkout-worker) because Linux's OOM heuristics score based on memory usage and adjustability, not which process actually caused the problem. Engineers who set generous or absent limits on 'just the worker' are often surprised when the database gets killed instead during a memory crunch.
Code Example
# docker-compose.yml — set explicit resource ceilings so OOM kills target the right container
services:
checkout-worker:
image: checkout-worker:1.8
mem_limit: 512m # hard cap enforced by the kernel cgroup
cpus: 0.5 # fractional CPU share
restart: on-failure:5 # cap restart attempts instead of restarting forever
# Live per-container resource usage, refreshed continuously
docker stats --no-stream
# Check how many times a container has restarted and its last exit code
docker inspect checkout-worker --format '{{.RestartCount}} exit={{.State.ExitCode}}'
# Host-level memory and swap pressure (the signal Compose itself won't show you)
free -m && vmstat 1 5
# Confirm whether the last kill was an OOM event specifically
docker inspect checkout-worker --format '{{.State.OOMKilled}}'Interview Tip
A junior engineer typically lists generic metrics like 'CPU and memory usage,' but for a senior role the interviewer is actually looking for the insight that Compose has no scheduler — so resource exhaustion doesn't get rescheduled away like it would in Kubernetes, it takes down the whole host including unrelated co-located services. The strongest answers explain why the OOM killer can pick the wrong victim when limits aren't set per-service, and connect restart_count climbing plus memory trending toward mem_limit as a leading indicator worth alerting on before the kill happens, not just logging it after. Mentioning disk exhaustion from logs or WAL files as an oft-forgotten host-level risk also signals real production experience with Compose specifically, versus assuming Kubernetes-style resource management applies unchanged.
◈ Architecture Diagram
┌──────────── single Docker host ────────────┐ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌─────────┐ │ │ │ api │ │ worker │ │redis │ │postgres │ │ │ └────────┘ └───┌────┘ └──────┘ └─────────┘ │ │ │ leaks memory │ │ ↓ │ │ host memory pressure ──▶ ✗ OOM kill │ │ (may kill wrong container) │ └──────────────────────────────────────────────┘
💬 Comments
Quick Answer
Give every pipeline run a unique Compose project name (via -p or COMPOSE_PROJECT_NAME) so its containers, networks, and volumes never collide with a concurrent run on the same CI host, and always pair `up` with a guaranteed `down -v` teardown even on test failure. Binding to ephemeral host ports instead of fixed ones avoids port collisions when multiple pipeline jobs run in parallel on shared runners.
Detailed Answer
Picture a hotel that reuses the same room number for every guest without checking anyone out first — the moment two guests are assigned room 204 at overlapping times, someone's luggage gets mixed up with someone else's. That's exactly what happens when a CI system runs docker compose up for two concurrent pipeline jobs on the same runner without namespacing them: by default, Compose derives its project name from the current directory name, so two checkouts of the same repo running in parallel both try to create a network and containers named after the same project, and the second job either fails outright or, worse, silently reuses the first job's still-running containers and gets contaminated test data.
Compose's project-name mechanism exists specifically to let multiple independent instances of the same stack run side-by-side without any manual coordination — but it only helps if you actually set a unique name per run instead of relying on the default directory-derived one, which is identical across every checkout of the same repo. CI systems are the textbook case where you want deliberately unique namespacing every single run, because the whole point of CI is parallel, disposable, repeatable execution, and 'repeatable' breaks the instant one run's leftover state can bleed into the next.
Internally, when you pass -p <unique-name> or set COMPOSE_PROJECT_NAME, every resource Compose creates — the bridge network, named volumes, and container names — gets prefixed with that project name instead of the directory name. This means job A's <project-a>_default network and job B's <project-b>_default network are two entirely separate Docker networks even though both were built from the identical docker-compose.yml file, and their containers, despite having the same service names internally (both have a service called 'postgres-db'), get different actual container names like project-a_postgres-db_1 and project-b_postgres-db_1. This is what makes true parallel execution on a shared runner safe.
At CI scale, the two things that still bite teams are host port publishing and leftover volumes. If your compose file hardcodes a fixed host port mapping like 5432:5432, two parallel jobs will still collide on the host's port 5432 regardless of project name, because published ports are a Docker-daemon-wide resource, not scoped per project — the fix is binding the host side to 0 (Docker picks a free ephemeral port) and having your test suite read the actual assigned port via docker compose port postgres-db 5432 rather than assuming 5432. The other recurring issue is teardown: a test failure that causes the pipeline step to exit early without running docker compose down -v leaves orphaned containers and volumes accumulating on the runner until disk fills up, so the teardown must run in a finally-equivalent (a post-step or trap) regardless of whether the tests passed.
The gotcha: docker compose down without -v deletes containers and networks but leaves named volumes behind, so a 'clean' teardown that forgets -v still accumulates orphaned Postgres data volumes run after run, and after enough CI runs the runner's disk fills up in a way that looks like a random, unrelated Docker daemon failure rather than an obvious volume leak — engineers often burn hours looking at the wrong layer before realizing docker system df shows dozens of forgotten anonymous volumes.
Code Example
#!/bin/bash
set -euo pipefail
# Unique project name per CI run avoids collisions on shared runners
export COMPOSE_PROJECT_NAME="ci-${CI_PIPELINE_ID}-${CI_JOB_ID}"
# Ensure teardown runs even if tests fail, freeing containers/networks/volumes
trap 'docker compose -p "$COMPOSE_PROJECT_NAME" down -v --remove-orphans' EXIT
# Bring up the stack, binding to ephemeral host ports to avoid port collisions
docker compose -p "$COMPOSE_PROJECT_NAME" up -d --wait # --wait blocks until healthy
# Discover the actual host port Docker assigned instead of assuming a fixed one
PG_PORT=$(docker compose -p "$COMPOSE_PROJECT_NAME" port postgres-db 5432 | cut -d: -f2)
# Run the integration test suite against the dynamically assigned port
DATABASE_URL="postgresql://checkout:pass@localhost:${PG_PORT}/checkout" npm run test:integration
# Explicit teardown call (trap above is the safety net if this line is skipped)
docker compose -p "$COMPOSE_PROJECT_NAME" down -v --remove-orphansInterview Tip
A junior engineer typically says 'just run docker compose up in the pipeline,' but for a senior role the interviewer is actually looking for awareness that CI runners execute jobs concurrently on shared infrastructure, so project-name isolation and ephemeral port binding aren't optional polish — they're the difference between a pipeline that scales to parallel jobs and one that intermittently fails with mysterious port or data-contamination errors. The strongest answers mention guaranteed teardown via a trap or post-step so a failing test suite doesn't leak containers, and specifically call out that `docker compose down` without `-v` still leaves named volumes behind, which is the classic slow disk-fill failure mode nobody notices until the runner itself starts failing unrelated jobs.
◈ Architecture Diagram
┌── CI runner (shared host) ──────────────┐ │ job A: project=ci-101 ──▶ net ci-101_x │ │ job B: project=ci-102 ──▶ net ci-102_x │ │ (isolated, no name collision) │ │ teardown: down -v ──▶ ✓ no leaked vols │ └──────────────────────────────────────────┘
💬 Comments
Quick Answer
Docker Compose merges multiple YAML files in the order you pass them with -f, with later files overriding matching keys in earlier ones rather than replacing the whole service block. Teams keep a base docker-compose.yml with shared service definitions and thin override files (docker-compose.prod.yml) that only change what differs, like removing bind-mount volumes and adding restart policies and resource limits.
Detailed Answer
Think of it like a base employment contract with addendums. The base contract (docker-compose.yml) spells out your job title, salary band, and reporting line — the things true in every office. A regional addendum (docker-compose.prod.yml) doesn't rewrite the whole contract, it just amends specific clauses: different working hours here, a different bonus structure there. HR doesn't hand you two separate contracts to reconcile yourself; the system merges them into one effective document. Docker Compose's multi-file support works the same way — you keep one canonical description of your stack and layer small, environment-specific patches on top instead of maintaining three near-duplicate 200-line YAML files that drift out of sync.
Compose was designed this way because the alternative — full separate files per environment — is a maintenance trap. If checkout-worker gains a new environment variable in dev, someone has to remember to also add it to staging.yml and prod.yml, and inevitably one gets missed until a production incident reveals it. By making files additive, the base file is the single source of truth for topology (which services exist, how they're networked) while overrides only carry the deltas that are genuinely environment-specific, like credentials, replica counts, or removing a live-reload volume mount that should never exist in production.
Internally, when you run docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d, Compose reads each file in order and performs a deep merge on the resulting data structure. Scalar keys (like image or restart) get overwritten by the later file. List keys like ports and volumes are replaced entirely, not appended — this trips people up because they expect merging to be additive everywhere. Map keys like environment and labels are merged key-by-key. Compose resolves this into a single in-memory Compose model before it ever talks to the Docker Engine API, so what you see with docker compose config (which prints the fully merged, resolved YAML) is exactly what gets sent to create containers, networks, and volumes.
At production scale, teams also lean on profiles to conditionally include services — a debug profile might add a pgAdmin or Redis Commander container that only starts when you pass --profile debug, keeping the production compose file free of debugging tools by default. What to watch: config drift between the base file and overrides (catch this in CI by running docker compose config against each environment combination and diffing against a checked-in snapshot), and secrets accidentally left in an override file that gets committed to git. A common on-call scenario is someone updating docker-compose.yml for a new port mapping but forgetting the prod override still hard-codes the old port, causing a silent mismatch that only surfaces when the load balancer health check starts failing.
The non-obvious gotcha: because list-type keys are replaced wholesale rather than merged, if your base file defines volumes: [./data:/data, ./logs:/logs] and your override only needs to add a third volume, you must repeat all three in the override — Compose will not append your new entry to the base list. Engineers who assume Compose merges lists item-by-item (the way it merges maps) end up silently losing volume mounts in production, and the failure mode looks like a missing directory error deep inside the container rather than an obvious YAML problem.
Code Example
# docker-compose.yml (base — shared across all environments)
services:
payments-api:
image: payments-api:2.4.1
volumes:
- ./src:/app/src # dev live-reload mount, must be dropped in prod
- ./logs:/app/logs
environment:
- LOG_LEVEL=debug
# docker-compose.prod.yml (override — only the deltas)
services:
payments-api:
restart: always # scalar keys: last file wins
volumes:
- ./logs:/app/logs # list keys REPLACE, not merge — must repeat what you keep
environment:
- LOG_LEVEL=warn # map keys merge per-key, this overrides just LOG_LEVEL
- NODE_ENV=production
deploy:
resources:
limits:
memory: 512M # cap memory only in prod, not dev
# See the fully merged config before applying it
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
# Apply the merged stack in detached mode
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Start only with the debug profile's extra services (e.g. pgAdmin) in dev
docker compose --profile debug up -dInterview Tip
A junior engineer typically answers that you 'use different compose files for different environments,' but for a senior role the interviewer is actually looking for whether you understand the merge semantics well enough to explain why list keys replace instead of append — that's the detail that causes real production incidents when someone assumes Compose behaves like a deep recursive merge everywhere. Seniors should also bring up validating merged output in CI with `docker compose config` before it ever reaches a cluster, and treating the base file as the source of truth for topology while overrides only carry secrets, resource limits, and environment-specific toggles. Bonus points for mentioning profiles as a cleaner alternative to maintaining a fully separate debug compose file.
◈ Architecture Diagram
┌─────────────┐ -f ┌──────────────┐
│ base.yml │──────────▶│ merged model │
└─────────────┘ └──────────────┘
┌─────────────┐ -f ↑
│ prod.yml │────────────────┘
└─────────────┘ (later file wins on
scalars/maps, REPLACES
list keys entirely)💬 Comments
Quick Answer
Plain depends_on only waits for a container to start (its PID 1 process launches), not for the application inside it to be ready to accept traffic — a Postgres container can report 'started' seconds before it finishes replaying WAL files and can actually accept connections. Pairing depends_on with condition: service_healthy and a real healthcheck (like pg_isready) makes Compose wait for actual readiness before starting the dependent service.
Detailed Answer
Imagine a relay race where the baton handoff rule is 'start running as soon as your teammate leaves the starting blocks,' rather than 'start running once your teammate is actually within arm's reach.' The first rule sounds fine until you realize the runner might trip, false-start, or take a few strides before finding their stride — and now you're reaching for a baton that isn't there yet. Docker Compose's default depends_on is that first rule: it guarantees start order, not readiness. Your checkout-worker container gets scheduled to start the instant the postgres container process launches, not once Postgres has actually finished its startup sequence and opened its listening socket for real queries.
Compose was designed this way because 'started' and 'ready' are fundamentally different concepts that only the application itself can answer. Docker doesn't know that Postgres needs to run crash recovery, load extensions, and open a TCP listener before it's actually usable — from Docker's point of view, a container is 'up' the moment its entrypoint process exists. The healthcheck mechanism exists specifically to let each service define, in its own terms, what 'ready' means: for Postgres that's pg_isready succeeding, for an HTTP API that's a 200 response on /health, for a queue consumer that might be a successful connection handshake to the broker.
Internally, when you define a healthcheck block, the Docker Engine runs the specified test command inside the container on a schedule (interval), and gives it timeout seconds to complete. If it fails retries times in a row, the container's health status flips from starting to unhealthy; if it succeeds, it flips to healthy. When a dependent service declares depends_on: db: condition: service_healthy, Compose's orchestration logic polls the dependency's health status before issuing the start call for the dependent container — it literally will not create checkout-worker's container until postgres reports healthy, or it fails the whole up command if postgres never becomes healthy within its healthcheck's retry budget.
At production and CI scale, this matters most during full-stack spin-ups — integration test pipelines that bring up a database, a message broker, and five services are exactly where naive depends_on causes flaky, non-deterministic test failures: the app connects before the DB is ready, throws a connection-refused error, and the whole pipeline run fails intermittently, which teams often misdiagnose as 'flaky tests' rather than a missing health check. What to monitor: healthcheck failure counts in docker inspect (an unhealthy container that never recovers usually means a bad healthcheck test command, not necessarily an unhealthy app), and startup latency creep, since every added health-checked dependency adds to the critical path of docker compose up.
The gotcha experienced engineers hit: healthchecks only run after the container's start_period (a grace window during which failures don't count against retries) has elapsed, and if you don't set start_period for a slow-starting JVM-based service, the healthcheck can exhaust its retries and mark the container unhealthy before the app has even finished its warm-up — causing Compose to declare the dependency permanently broken even though it would have become healthy ten seconds later.
Code Example
# docker-compose.yml
services:
postgres-db:
image: postgres:16
environment:
- POSTGRES_USER=checkout
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U checkout"] # real readiness probe, not just process check
interval: 5s # how often to run the probe
timeout: 3s # how long the probe itself can take
retries: 5 # consecutive failures before marking unhealthy
start_period: 10s # grace period before failures count
checkout-worker:
image: checkout-worker:1.8
depends_on:
postgres-db:
condition: service_healthy # wait for healthy, not just started
# Bring up the stack and watch health transitions live
docker compose up -d
docker compose ps # shows (healthy)/(unhealthy)/(starting) per service
# Inspect the raw healthcheck log if a service stays unhealthy
docker inspect --format='{{json .State.Health}}' checkout-worker | jqInterview Tip
A junior engineer typically answers 'add depends_on so services start in the right order,' but for a senior role the interviewer is actually looking for the distinction between process-started and application-ready, and why that distinction causes real flakiness in CI and staging environments specifically at startup. Strong answers name the exact mechanism — condition: service_healthy polling the Docker health status — and mention start_period as the fix for slow-starting JVM or Spring Boot services that get marked unhealthy prematurely. The strongest candidates also point out that healthchecks are a Compose/Swarm-level construct, not something Kubernetes readiness probes share syntax with, showing they understand tool boundaries rather than pattern-matching one config format onto another.
◈ Architecture Diagram
┌─────────────┐ healthcheck ┌───────────┐
│ postgres-db │──────────────▶│ healthy │
└─────────────┘ pg_isready └─────┌─────┘
│ condition met
↓
┌──────────────────┐
│ checkout-worker │
│ container starts │
└──────────────────┘💬 Comments
Quick Answer
Compose defines and runs multi-container apps from a single YAML file — ideal for local dev and simple single-host stacks.
Detailed Answer
docker-compose.yml declares services, networks, and volumes; docker compose up starts them together with a shared network so they resolve each other by service name. It's great for development and CI, but for production orchestration across many hosts you'd use Kubernetes or Swarm.
Interview Tip
Position Compose for dev/single-host, not multi-node prod.
💬 Comments
Quick Answer
They join a default network and reach each other by service name as the hostname.
Detailed Answer
Compose creates a network and attaches each service; a service named db is reachable at hostname db on its container port. You don't publish ports for internal traffic — only expose ports to the host with ports: when you need external access. depends_on controls start order (not readiness).
Code Example
services:
web:
build: .
depends_on: [db]
db:
image: postgres:16Interview Tip
Clarify depends_on orders startup but doesn't wait for readiness.
💬 Comments
Quick Answer
ports publishes a container port to the host (host:container); expose only documents/opens a port to other containers, not the host.
Detailed Answer
ports: '8080:80' maps host 8080 to container 80, reachable from outside. expose: '80' makes the port available on the Compose network but not the host. For service-to-service traffic you need neither if they share a network — the container port is already reachable by service name.
Interview Tip
Internal services usually need neither — call that out.
💬 Comments
Quick Answer
Use a named volume mounted into the service; named volumes survive down (but not down -v).
Detailed Answer
Bind mounts tie data to a host path; named volumes are managed by Docker and persist across recreation. docker compose down keeps named volumes; add -v to delete them. For databases, always use a named volume so data survives container recreation.
Code Example
services:
db:
image: postgres:16
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:Interview Tip
Warn that down -v deletes named volumes.
💬 Comments
Quick Answer
Use environment variables (via env_file or a .env), and Compose secrets for sensitive values.
Detailed Answer
Non-sensitive config goes in environment: or an env_file. The top-level .env feeds variable interpolation like ${TAG}. For secrets, Compose secrets mount files into /run/secrets rather than baking values into the image or env, which are more exposed. Never commit real secrets.
Interview Tip
Distinguish .env (interpolation) from env_file (container env).
💬 Comments
Quick Answer
--build rebuilds images before starting; -d runs the stack detached in the background.
Detailed Answer
By default up reuses existing images; --build forces a rebuild after code changes. -d frees your terminal and runs services in the background (view logs with docker compose logs -f). Combine as up -d --build for a fresh detached start.
Interview Tip
Mention logs -f to follow a detached stack.
💬 Comments
Quick Answer
Use --scale for stateless replicas, and layer compose.override.yml or -f files for per-environment settings.
Detailed Answer
docker compose up --scale web=3 runs three replicas of a stateless service (behind a proxy/load balancer). Compose merges docker-compose.yml with docker-compose.override.yml automatically, or you pass multiple -f files (base + prod) to override images, replicas, and env per environment.
Interview Tip
Explain the automatic override.yml merge behavior.
💬 Comments
Context
A product whose local development required hand-installing Postgres, Redis, Kafka, MinIO, and five internal services with version-matched configs — a wiki page of 40 steps that took new engineers days and broke differently on every OS.
Problem
Onboarding took 2-3 days of environment fighting; 'works on my machine' bugs traced to version drift between laptops (Postgres 14 vs 16 behavioral differences shipped one real bug); and integration bugs surfaced late because most engineers ran only their own service locally against mocks.
Solution
Built a compose-based dev environment as a product: pinned-digest images for infrastructure services, healthcheck-gated startup ordering (depends_on: condition: service_healthy so the app waits for migrated-and-ready Postgres, not merely started), profiles separating the core stack from heavy optionals (kafka, minio only when working on those features), bind-mount + file-watch for hot reload on the service being developed with the rest running as images, and a seed service that loads anonymized fixtures then exits. The compose file lives in the monorepo, versioned with the code it runs.
Commands
docker compose --profile core up -d
depends_on: {postgres: {condition: service_healthy}}; healthcheck: pg_isready + migration-complete markerdocker compose watch # rebuild/sync on source change for the active service
Outcome
Onboarding went from days to under an hour (clone, compose up, coffee); the version-drift bug class ended (everyone runs identical pinned images); cross-service integration bugs surface at development time because running the real neighbors is now free.
Lessons Learned
Healthcheck semantics carry the whole experience — 'started' vs 'ready to accept work' is the difference between a working stack and a flaky one. Profiles kept laptop resource usage sane; without them engineers on smaller machines ran everything or nothing.
💬 Comments
Context
A CI pipeline whose integration tests ran against a shared, long-lived test database and message broker — tests interfered across concurrent jobs, state leaked between runs, and 'retry until green' had become the unofficial merge process.
Problem
Cross-job interference made failures non-deterministic (two PRs' tests writing the same tables), the shared infra drifted from production versions, and genuinely-failing tests were indistinguishable from environment flakes — eroding trust in CI entirely.
Solution
Made each CI job hermetic: docker compose -p job-$CI_JOB_ID up spins a private project (isolated networks, volumes, containers) with production-matched pinned images; tests start only after compose's health gates pass (service_healthy conditions plus an explicit wait-for script asserting app-level readiness); teardown is compose down -v --remove-orphans in an always-run cleanup step, with a scheduled reaper for projects orphaned by runner crashes. Test data is seeded per-run from fixtures — no state survives a job.
Commands
docker compose -p it-$CI_JOB_ID -f compose.ci.yml up -d --wait
pytest tests/integration --base-url http://localhost:$MAPPED_PORT
cleanup (always): docker compose -p it-$CI_JOB_ID down -v --remove-orphans
Outcome
Integration-test flake rate fell from ~18% of runs to under 2% (the remainder are real races in the code — now visible); concurrent jobs stopped interfering by construction; the retry-until-green culture faded once red meant something. Runner disk stopped filling after the orphan reaper landed.
Lessons Learned
The --wait flag plus health gates replaced a folder of sleep-based scripts — readiness must be asserted, not assumed. Project-name scoping (-p) is the isolation mechanism; without it, parallel jobs on one runner share the default network namespace of the directory name.
💬 Comments
Symptom
After renaming or removing a service in docker-compose.yml and running `docker compose up -d`, the old container for the removed service keeps running in the background — still holding its port, volume, or IP — even though it no longer appears in `docker compose ps`.
Error Message
WARN[0000] Found orphan containers ([old-service-1]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up.
Root Cause
Docker Compose only manages containers for services currently defined in the compose file; when a service is renamed or removed, Compose warns about the orphan but does not stop or remove it automatically on a plain `up` or `down`. Multiple GitHub issues (docker/compose#12314, #9718, #12214) document that even the flag Compose itself suggests (--remove-orphans) is rejected by some subcommands like create and run, making the warning misleading about how to actually clean up.
Diagnosis Steps
Solution
Explicitly run `docker compose down --remove-orphans` (not `up`) to stop and remove containers no longer defined in the compose file, then confirm with `docker ps -a` that no project-labeled containers remain. For containers created by a subcommand that rejects the flag, remove them directly with `docker rm -f <container>`.
Commands
docker compose down --remove-orphans
docker ps -a --filter label=com.docker.compose.project=<project>
docker rm -f <orphan_container_id>
Prevention
Always include --remove-orphans in CI/CD deploy scripts on both up and down, not just when a warning is noticed. When a compose project spans multiple compose files, treat orphan cleanup as a scheduled check, since services defined only in some files routinely orphan on cutover.
💬 Comments
Symptom
After modifying docker-compose.yml (adding a network, splitting services across multiple compose files, or upgrading Docker Engine), services can ping each other by container name but application-level HTTP/TCP calls between them fail or hang.
Error Message
curl: (7) Failed to connect to api port 8080: Connection refused (while `ping api` succeeds)
Root Cause
ICMP (ping) succeeding while TCP/HTTP fails indicates basic IP connectivity is present on the Compose-managed bridge network, but something interferes above layer 3 — commonly the target process binding to 127.0.0.1 instead of 0.0.0.0 inside the container, a host firewall rule reset by a Docker Engine upgrade that Compose's iptables rules depend on, or the two services ending up on different Compose-managed networks after a multi-file split.
Diagnosis Steps
Solution
Confirm the target process binds 0.0.0.0 (not localhost) inside its container, verify both services are attached to the same Docker network with `docker network inspect`, and if a Docker Engine/daemon restart is suspected to have reset iptables rules, restart the Docker daemon to let it re-apply its network rules rather than manually patching iptables.
Commands
docker network inspect <network_name>
docker exec <container> ss -tlnp
docker compose up -d --force-recreate
Prevention
Pin explicit network definitions in compose files rather than relying on default project networks when splitting services across multiple compose files. Add a startup health check that verifies inter-service TCP connectivity (not just ICMP) as part of deployment validation.
💬 Comments
Symptom
A finance reconciliation report shows a small percentage of queue messages processed twice, intermittently, for months. All known consumers are correct. Eventually a host inventory sweep finds an old VM running a year-old container of the consumer service — one the team believed decommissioned.
Error Message
No error anywhere. The zombie container ran happily with restart: always, surviving every host reboot, processing messages with credentials that still worked, from an image version predating idempotency-key support.
Root Cause
The service's 'decommissioning' had been: remove from the new deployment platform, delete the repo's compose file, revoke... nothing. The old VM — from before the platform migration — still had the compose project on disk with restart: always; every reboot resurrected it. Old-but-valid credentials meant it kept consuming. Because it predated the idempotency work, its processing wasn't deduplicated. The migration's decommission checklist covered the new platform only.
Diagnosis Steps
Solution
Stopped and removed the zombie project (compose down), revoked its credentials, and reconciled the affected financial records with the double-processing window quantified from its container logs (fortunately intact). Fleet-wide: an inventory job now reports every host's running containers against the service catalog, flagging anything unmatched; credential hygiene tightened (expiring credentials so orphans die on rotation).
Commands
docker ps --format '{{.Names}} {{.Image}} {{.Status}}' # per host, swept centrallydocker compose -p legacy-consumer down -v && revoke credentials
inventory job: running containers vs service-catalog diff -> weekly report
Prevention
Decommissioning is complete when the credentials stop working, not when the deploy config is deleted — expiring/rotating credentials turn forgotten workloads into loud failures instead of silent ones. Run periodic what-is-actually-running inventory against the service catalog; restart: always means 'until someone explicitly stops it', which is exactly its danger on forgotten hosts.
💬 Comments