Docker Compose depends_on — Fixing Container Startup Crashes
depends_on controls start order, not readiness.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- You declare desired state (services, volumes, networks) in docker-compose.yml
- Compose translates that into container create/start/stop commands via the Docker API
- Service names become DNS entries on an auto-created bridge network
- services: each entry maps to one container
- volumes: named storage that survives container lifecycle
- networks: control which services can reach each other
- depends_on with healthcheck: startup ordering with readiness gates
Imagine you're opening a restaurant. You need a chef (your app), a waiter (your web server), and a cashier (your database) — all working together, in the right order, talking to each other. Docker Compose is the restaurant manager who reads one master plan (a single YAML file) and spins up every staff member at once, makes sure they can talk to each other, and shuts them all down cleanly at the end of the night. Without it, you'd be hiring each person separately, on different phones, hoping they find each other.
Every real-world application is more than one moving part. Your API needs a database. That database needs a volume so data survives restarts. Your frontend needs to know the API's address. Managing each with individual docker run commands is a copy-paste nightmare that breaks when a new developer joins or you move to a different machine.
Docker Compose is a declarative orchestrator for multi-container applications on a single host. You describe the desired state in YAML — services, networks, volumes, environment variables, health checks — and Compose handles creation, networking, and lifecycle ordering. The file is the documentation. The file is the deployment. There is no gap between what you describe and what runs.
The critical misconception: Compose is not just a convenience wrapper. It manages DNS resolution between services, network isolation between tiers, volume lifecycle across container restarts, and startup ordering with readiness gates. Understanding these mechanisms is what separates a working compose file from a production-grade one.
What Docker Compose Actually Does for Multi-Container Apps
Docker Compose is a declarative tool that defines and runs multi-container Docker applications from a single YAML file. Instead of manually wiring containers with docker run commands, you describe services, networks, and volumes in docker-compose.yml. The core mechanic: Compose translates that YAML into a set of Docker API calls, creating and linking containers in a user-defined order.
In practice, Compose handles three critical properties: service orchestration (which containers start, in what order), network isolation (each service gets its own DNS-resolvable hostname), and volume persistence (data survives container restarts). The depends_on directive controls startup sequencing, but it only waits for the container to start — not for the process inside to be ready. This is the source of most startup crashes.
Use Compose for local development, CI/CD integration tests, and single-host production deployments. It eliminates the need to manually manage container lifecycles, but it is not a production orchestrator — that's Kubernetes or Nomad. For teams running 3–10 services on one host, Compose provides the simplest path to reproducible environments.
The Anatomy of a docker-compose.yml File (and Why Every Key Matters)
A Compose file is a declaration of intent, not a script. You're telling Docker 'here is the world I want' — Compose figures out how to build it. That mental shift matters: you're not writing steps, you're describing a state.
Every Compose file has a few top-level keys. services is the main one — each entry is a container. volumes defines named storage that outlives containers. networks lets you control which services can see each other (by default Compose creates one shared network, which is great for getting started but dangerous if you want to isolate, say, your admin panel from your public API).
The depends_on key is widely misunderstood. It controls start order, not readiness. Your app container will start after the database container starts, but not after Postgres is actually ready to accept connections. That's a real gotcha we'll cover shortly. For readiness you need health checks.
The build key is your escape hatch from public images — point it at a directory with a Dockerfile and Compose builds the image itself before starting the container. This is how you develop locally with live code while still using Compose for orchestration.
Service configuration depth: Each service can specify image or build (mutually exclusive in intent — image pulls from a registry, build creates locally). The restart policy controls behavior on container exit: no (default), always, on-failure, unless-stopped. In production, unless-stopped is almost always correct — it restarts on crash but respects your manual docker compose stop.
Volume lifecycle: Named volumes declared under the top-level volumes: key are managed by Docker. They survive docker compose down but are destroyed by docker compose down -v. Bind mounts (host:path syntax) are managed by the host filesystem. Anonymous volumes (- /app/node_modules) are created by Docker and removed when the container is removed.
- Declarations are idempotent — running up twice does not create duplicate containers.
- The file is the documentation — no gap between what is described and what runs.
- Compose can diff desired state against actual state and converge incrementally.
- Multiple files can merge — base config + environment overrides without duplication.
Top-Level YAML Key Reference Table
The docker-compose.yml file has several top-level keys that control the entire multi-container stack. Each key serves a distinct purpose — volumes persist data, networks isolate traffic, and configs manage non-file configuration. Below is a reference table of every top-level key available in Compose specification version 3.9 (the most widely supported version across Docker Engine and Docker Desktop).
| Key | Required? | Purpose | Example |
|---|---|---|---|
version | Yes | Declares the Compose file format version. Should always be '3.9' for current projects. | version: '3.9' |
services | Yes | Defines the containers to run. Each service is a named entry with image/build, ports, volumes, etc. | services: web: image: nginx |
volumes | No | Declares named volumes that can be referenced by services. Survives docker compose down. | volumes: db_data: |
networks | No | Declares custom networks. By default Compose creates one network per project. | networks: backend: |
configs | No | Manages configuration files (Swarm mode only). Rarely used outside Swarm. | configs: my_config: file: ./app.conf |
secrets | No | Declares secrets (Swarm mode) or references external secrets (Docker Compose v2.5+). | secrets: db_password: external: true |
include | No | References external Compose files (v2.24+). Merges services, volumes, networks from included files. | include: - ./auth/compose.yml |
x- (extension fields) | No | Custom YAML anchors for reuse. Not processed by Compose but can be referenced using <<: merge syntax. | x-logging: &logging driver: json-file |
How they connect: The services block is the only required top-level key because it defines the actual containers. Every other key is optional and exists to support the services. For example, you reference a named volume like my_volume in a service's volumes: list only if you declared my_volume under the top-level volumes: key. If you don't declare it, Docker creates an anonymous volume instead.
The include key is a game-changer for multi-team projects. Instead of one monolithic file, each team owns their own Compose file, and the parent file includes them. This is how you compose microservices without a single 500-line file that no one understands. The included files' services, volumes, and networks are merged at runtime.
- Extension fields are ignored by Compose — they are purely for you to organize.
- YAML anchors (
&alias) and merge keys (<<:) let you compose reusable configurations. - Example: define
x-common-restart: &restart_policy restart: unless-stoppedand reuse it across services. - This keeps your compose file DRY without needing external templating tools.
include key is powerful but dangerous if used carelessly. Each included file creates a separate compose project under the hood, meaning services from different included files cannot reference each other's volumes or networks unless they are explicitly declared as external. Always test the merged output with docker compose config before deploying. A common mistake is including a file that defines a volume but forgetting to mark it as external if it needs to be shared across included files.include to break monolithic files into team-owned modules.Docker Compose Command Cheat Sheet
Docker Compose commands follow a consistent pattern: docker compose <command> [options] [service...]. Below is a quick reference for the most frequently used commands in day-to-day development and debugging. These commands assume you are in the directory containing docker-compose.yml (or using the -f flag to point to one).
| Command | What It Does | Common Flags |
|---|---|---|
docker compose up | Creates and starts containers according to the compose file. Detaching with -d runs in background. | -d (detach), --build (rebuild images before starting), --force-recreate (recreate containers even if config unchanged). |
docker compose down | Stops and removes containers, networks, and default networks. Does NOT remove named volumes by default. | -v (remove named volumes), --rmi all (remove all images), --remove-orphans (clean up containers not in the compose file). |
docker compose ps | Lists containers managed by the compose file, showing status, ports, and health. | -a (show stopped containers too). |
docker compose logs | Shows logs from all services or a specific service. | -f (follow/stream), --tail=50 (show last 50 lines), --timestamps (show timestamps). |
docker compose exec | Runs a command inside a running container. Useful for debugging. | -it (interactive terminal for shell access), --user <user> (run as specific user). |
docker compose run | Runs a one-off command in a new container (for migrations, seeds, etc.). | --rm (remove container after command exits), --service-ports (expose ports defined in compose). |
docker compose build | Builds (or rebuilds) images defined in the compose file. | --no-cache (force fresh build without layer cache), --pull (always pull base image). |
docker compose config | Validates and shows the resolved configuration (merging all -f files and .env interpolation). | --services (list service names only), --volumes (list volume names only). |
docker compose top | Shows running processes inside each container. | None commonly used. |
docker compose images | Lists all images used by the compose services and whether they are built locally or pulled. | -q (show only image IDs). |
docker compose events | Streams real-time events from containers (start, stop, die, health_status). | --json (output as JSON for machine parsing). |
docker compose pause / unpause | Suspends/resumes all processes in a container (SIGSTOP/SIGCONT). | Not commonly used. |
Command examples for real debugging: ```bash # Start the stack and rebuild the API image docker compose up --build -d
# Watch logs from the API service only docker compose logs -f api_server
# Shell into a running container to inspect environment docker compose exec -it api_server sh
# Run a one-off migration (remove container after) docker compose run --rm api_server npx prisma migrate deploy
# Check the entire resolved config (useful before prod deploy) docker compose config
# Stop everything and clean up named volumes (careful — destroys data) docker compose down -v ```
Pro tip: Use docker compose config --services to list all service names in the current project. This is invaluable when you have many services and need to reference them in scripts.
docker compose down -v. The -v flag removes named volumes, which usually contain your database. This is a data-loss event. In production, never run down -v unless you explicitly intend to destroy the database. Use docker compose down without flags — it leaves volumes intact. If you must clean a volume, use docker compose down then docker volume rm <project>_<volume> to remove exactly the volumes you want.up, down, ps, logs, exec, run, build, and config as your daily drivers. docker compose config is the single most important command for validating your compose file before deploying. Never use down -v in production without explicitly confirming you want to destroy persistent data.Environment Variables and Secrets — The Right Way vs. The Dangerous Way
Hardcoding passwords directly in docker-compose.yml is one of the most common and dangerous mistakes in real projects. If that file ever gets committed to a public repo — even accidentally — your credentials are exposed permanently (git history remembers everything).
The correct pattern is a .env file alongside your Compose file. Docker Compose automatically reads .env and substitutes ${VARIABLE_NAME} placeholders. You commit a .env.example with dummy values to your repo, and each developer (and your CI system) fills in the real .env locally. The actual .env lives in .gitignore.
For production, environment variables should come from your hosting platform's secret manager (AWS Secrets Manager, Doppler, HashiCorp Vault) injected at runtime — never baked into an image or a file on disk.
You can also use multiple Compose files with docker compose -f docker-compose.yml -f docker-compose.prod.yml up. The second file merges and overrides the first. This is how you maintain one base config and swap out dev-specific settings (like bind mounts and debug ports) for production-hardened ones without duplicating the whole file.
Secret exposure vectors: - Hardcoded in docker-compose.yml → committed to git → visible in git history forever - ENV in Dockerfile → baked into every image layer → visible in docker history and docker inspect - .env file not in .gitignore → committed to git → same as hardcoded - docker compose config output includes resolved secrets → if captured in CI logs, credentials are exposed
The right pattern for each environment: - Local development: .env file, in .gitignore, .env.example committed as template - CI/CD: secrets injected from CI platform secrets (GitHub Secrets, GitLab CI variables) - Production: secrets from a secrets manager (AWS Secrets Manager, Vault), mounted as files or injected as environment variables at deploy time
- No rotation — changing a secret requires redeploying with a new .env file.
- No audit trail — anyone with filesystem access can read the secret.
- No revocation — if a secret is compromised, you cannot invalidate it without changing it everywhere.
- No access control — the file is readable by any process running as the same user.
- Secrets managers (Vault, AWS Secrets Manager) solve all four problems.
.env File vs environment Key — Two Separate Mechanisms, One Common Confusion
A recurring theme in every Docker Compose workshop: developers confuse the .env file with the environment key inside a service. They think the .env file sets environment variables inside the container. It does not. The .env file exists only for the Compose file parser — it provides values for variable interpolation (${VAR}) in the YAML. The environment key inside a service is what actually sets environment variables inside the container at runtime.
The separation of concerns is critical: - .env → feeds the YAML parser during docker compose config and docker compose up. - environment: (or env_file:) → feeds the container's OS environment.
If you define DB_PASSWORD in .env, the container does not automatically have a DB_PASSWORD environment variable. You must explicitly pass it via environment: or env_file: in the service definition. The .env file just makes ${DB_PASSWORD} resolve in the YAML — that resolved value can then be assigned to an environment: key, but you have to write the mapping.
Example of the confusion: ``yaml # docker-compose.yml services: api: image: my-api environment: - DATABASE_URL=postgres://user:pass@db:5432/mydb ` If the DATABASE_URL contains a password, that password is hardcoded in the YAML. The correct approach is to put the password in .env` and reference it:
In .env: `` DB_PASSWORD=supersecret ` In docker-compose.yml: `yaml services: api: image: my-api environment: - DATABASE_URL=postgres://user:${DB_PASSWORD}@db:5432/mydb ` Now the resolved DATABASE_URL inside the container will contain 'supersecret'. The .env file provided the value, but the environment` key set the variable.
When to use env_file: instead: If you have many environment variables, you can use the env_file: directive to point to a file that gets parsed line-by-line and sets each variable inside the container. This file can be different from the .env file. For example, env_file: ./app.env will read key=value pairs from that file and set them in the container. This is useful when you have a pre-existing env file from a non-Docker setup.
Priority order (highest wins): 1. environment: key with inline values (takes precedence). 2. env_file: pointed file (overrides .env indirectly via ${VAR}). 3. .env file variables (used for interpolation only, not directly passed). 4. Environment variables from the host shell when running docker compose up (if the variable is referenced with ${VAR} but not defined in .env). 5. Defaults from YAML anchors or extension fields.
Deceptively dangerous pattern: Never rely on .env to pass environment variables to the container without explicitly mapping them. If you forget to add the environment: key, your container won't see the variables, and your app might fail with an obscure error like DB_PASSWORD not set while you stare at a .env file that clearly has the value. The .env file is for Compose, not for the container.
- The
.envfile may contain temporary or CI-only variables that are not needed inside the container. - It mixes concerns: the file that controls Compose resolution also controls container environment.
- Debugging becomes harder — you don't know which variables are for Compose and which are for the app.
- If you ever remove a variable from
.envthat was used asenvironment: ${VAR}, the service still gets it viaenv_file, masking the missing interpolation.
DATABASE_URL in .env but never maps it via environment: in the service. The container starts, the app tries to connect to the database, fails with 'cannot connect', and the developer spends an hour debugging before realizing the env var is not set inside the container. Always verify with docker compose exec <service> env | grep DATABASE or docker compose config to check that the variable is actually being passed..env file is for the Compose YAML parser — it substitutes ${VAR} placeholders. The environment: key (or env_file:) is what actually sets variables inside the container. They are two separate mechanisms. Always map your .env variables into environment: if you want them inside the container. Use env_file: as a convenient alternative when you have a stand-alone env file.Networking Between Containers — How Compose DNS Actually Works
This is where most intermediate developers have a fuzzy mental model, and it costs them hours of debugging. When Compose starts your services, it creates a virtual network and registers each service under its own DNS name — that name is simply the service name you defined in the YAML.
So when your API container wants to connect to Postgres, it doesn't use localhost (that points to itself) and it doesn't use an IP address (those change). It uses postgres_db:5432 — the service name and the container port, not the host-mapped port. This is a huge source of confusion: ports: '5432:5432' exposes port 5432 to your laptop. Other containers don't need that — they communicate on the internal network directly.
Multiple networks let you enforce security boundaries at the network layer, not just at the application layer. In our example, the frontend container literally cannot reach the database — there's no route. Even if someone finds an XSS vulnerability in your frontend, they can't pivot directly to the database because Compose's networking won't allow it.
Use docker compose exec api_server sh to shell into a running container and test DNS resolution live with nslookup postgres_db or curl http://api_server:3001/health from the frontend container.
DNS resolution internals: Compose uses Docker's embedded DNS server at 127.0.0.11. When a container resolves a service name, the request goes to this DNS server, which maps the service name to the container's internal IP on the appropriate network. If a container is on multiple networks, it can resolve services on all of them. If a container is on network A but not network B, it cannot resolve services that are only on network B.
Port mapping confusion: The ports directive maps HOST:CONTAINER. Your database connects at localhost:5432 from your laptop (host port). Your API connects at postgres_db:5432 from inside Docker (container port). These are completely separate network paths. Using the host port from inside a container (localhost:5432) either fails or connects to the wrong thing.
- Compose creates a bridge network for the project and registers each service with Docker's embedded DNS server.
- Each container's /etc/resolv.conf points to 127.0.0.11 — Docker's DNS proxy.
- The DNS proxy maps service names to container IPs on the correct network.
- This is automatic — you never configure DNS manually. It is a Compose/Docker Engine feature.
Profiles and Overrides — Running Different Stacks for Dev, Test and CI
A subtle but powerful Compose feature is profiles. You might have services that should only run in certain contexts — a database admin UI like pgAdmin in development, a mock email server in testing, or a metrics exporter in production. Profiles let you tag services and opt into them at runtime.
With docker compose --profile dev up, only services tagged with the dev profile (plus services with no profile) start. Your CI pipeline can run docker compose --profile test up --abort-on-container-exit to spin up integration test dependencies, run tests, and tear down — without launching the full dev UI tooling.
Compose file overrides are the production deployment pattern. You maintain a docker-compose.yml as the base truth and a docker-compose.prod.yml that overrides just the production-specific bits: replaces bind mounts with proper volumes, removes debug ports, adds resource limits, and switches to pre-built images instead of local builds. This avoids the 'it works on my machine' trap without duplicating hundreds of lines of YAML.
Override merge behavior: When multiple files are specified with -f, Compose deep-merges them. Arrays (like ports, volumes, environment) are replaced, not appended — if the override file specifies volumes: [], the base file's volumes are completely replaced. Scalar values (like image, restart) are overwritten. This is why docker compose config is essential — it shows the final merged result before you deploy.
Profile use cases beyond dev/prod: - Testing: profile test for mock services (WireMock, LocalStack) - Monitoring: profile monitoring for Prometheus, Grafana, cAdvisor - Debugging: profile debug for a debugger sidecar or log aggregator - Migration: profile migration for a one-shot database migration container
- Separate files duplicate 80% of the configuration — changes must be applied to both.
- Override files share the base and only specify differences — single source of truth.
- Override files can be combined: -f base.yml -f monitoring.yml -f prod.yml
- docker compose config shows the merged result — no surprises at deploy time.
Volumes That Survive the Wreck — Persistent Data You Can Actually Trust
Every junior dev learns the hard way: docker compose down is a liar. It doesn't kill your data unless you tack on -v. But here's the real problem — most teams treat volumes like magic bags that hold stuff. They're not. They're mounts with specific behaviors, and if you don't understand binding vs named volumes vs tmpfs, your database will vanish when your CI runner resets. Why it matters: containers are cattle, but your Postgres data is a pet. Named volumes outlive containers, survive restarts, and can be shared across services without permission nightmares. Bind mounts are for development — they mirror your host filesystem, but they break in production when your orchestration layer doesn't have the same paths. tmpfs is for secrets you want to incinerate on container stop. The how: define volumes under the top-level volumes: key, then mount them with source and target in each service. Always use driver: local unless you're connecting to NFS or cloud block storage. Never mount a macOS or Windows host path into a Linux container expecting consistent filesystem behavior — you'll get permission issues and symlink garbage. Test your persistence model with a forced kill, not a graceful stop.
./data:/var/lib/postgresql/data), your orchestrator will fail when the host path doesn't exist on the new node. Always use named volumes for stateful workloads that move.Custom Networks Stop the Noise — Why Port Conflicts Are a Self-Inflicted Wound
Default bridge networks work fine until two apps both claim port 8080. Then you learn: Compose creates a dedicated network per project, but by default every service can talk to everything else. That's fine for a three-service app. It's a disaster when you're running a dozen microservices and one misconfigured container starts DNS-recycling requests into the wrong database. The fix: separate your traffic into logical networks. You don't need a single flat plane. Split internal API communication from public-facing ingress traffic, and isolate data-plane traffic from admin or monitoring channels. Why this works: Compose's internal DNS resolves container names to IPs, but on a shared network, any container can reach any other service. By creating an internal or frontend network and attaching only the relevant services, you enforce zero-trust boundaries at the network level — no firewall rules needed. The how: define networks under the top-level networks: key, then assign each service to specific networks with networks: [...]. Use driver: bridge for single-host setups; use attachable: false to prevent stray containers from joining. For extra paranoia, mark the internal network as internal: true — no outbound internet access. Test with docker compose exec <service> ping <other-service> to verify isolation.
frontend, backend, db-network beats net1, net2, net3 — you'll thank yourself in six months when you need to audit traffic flow.Healthchecks That Actually Fail — Stop Waiting for Containers That Aren't Ready
You've seen the pattern: a Dockerfile with CMD to start the app, and Compose with depends_on. Then your Rails app starts before Postgres finishes initializing, throws a connection error, and crashes. depends_on only waits for the container to start — not for the process inside to be healthy. That's the gap. Why it matters: in production, your orchestrator won't route traffic to a container that hasn't passed its healthcheck. If you don't define one, you're relying on timing luck. And timing luck fails under load, on cold starts, and when your database image upgrades to a new major version with a slower init sequence. The fix: add a healthcheck block to every service that depends on another. Use start_period to give slow services time to initialize before the health checker starts hammering them. Use retries and interval that match your SLAs. The how: define healthcheck at the service level with a test command that returns exit code 0 for healthy, 1 for unhealthy. For HTTP services, use curl -f http://localhost/health. For databases, use the built-in client tool (e.g., pg_isready, mongosh --eval). Then use depends_on with condition: service_healthy — this makes Compose wait until the dependency passes its check before starting the dependent service. Without condition, depends_on is just a startup order hint, not a guarantee.
depends_on without condition: service_healthy. It only guarantees the container started, not that the process inside is ready. You'll get race conditions on deployment day.condition: service_healthy — or you're gambling.Install Docker Compose on Ubuntu — Two Paths, One Reliable Outcome
Docker Compose is not installed by default on Ubuntu. The apt package lags behind upstream releases, so you must install manually to get current features. The recommended method downloads the binary directly from the GitHub releases page. First, check your kernel version with uname -s and architecture with uname -m. Then fetch the binary for your platform using curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose. Apply executable permissions with chmod +x /usr/local/bin/docker-compose. Verify the installation with docker-compose --version. This binary approach avoids stale package versions and gives you access to Compose V2 features like docker compose (without the hyphen) commands. Always pin to a specific version in CI/CD pipelines to prevent unexpected breaking changes.
Step 2: Download the Software — Where Most Install Guides Go Wrong
Downloading Docker Compose looks trivial but hides a critical failure point: architecture mismatch. Many copy-paste commands assume x86_64, but if your Ubuntu machine runs on ARM (like a Raspberry Pi or AWS Graviton), the binary will fail silently. Always detect the architecture dynamically using uname -m and substitute it into the download URL. For ARM systems, replace x86_64 with aarch64. The full URL pattern is https://github.com/docker/compose/releases/latest/download/docker-compose-linux-{arch}. After download, verify the SHA256 checksum against the official release page — a corrupted download will produce obscure "Exec format error" messages. This step prevents wasting hours debugging container orchestration when the real problem is a mismatched binary architecture. Store the downloaded binary in /usr/local/bin to ensure it's in the system PATH without modifying environment variables.
uname -m to detect architecture dynamically — hardcoding x86_64 will break on ARM servers.Staging Environment Down for 3 Hours — depends_on Without Healthcheck
- depends_on controls start order, not readiness. Always pair it with a healthcheck.
- Non-deterministic startup failures are almost always race conditions, not configuration errors.
- Destroying the database volume to fix a startup race condition is a dangerous diagnostic step — you lose data.
- Defense in depth: healthcheck at the Compose level AND retry logic at the application level.
- CI runners are often faster than developer machines, hiding timing-dependent bugs.
docker compose logs --tail 100 <service>docker compose ps -aKey takeaways
docker compose up handles creation, networking, and ordering every time.depends_on controls start order, not start *readinesshealthcheck and condition: service_healthy to prevent race conditions when your app starts before the database is accepting connections.localhost between containers, and never use the host-mapped port for container-to-container traffic.-f flag) to maintain one base config and override only what changes between environments.env files for local dev (in .gitignore) and secrets managers for production. Pre-commit hooks catch accidental secret commits.Interview Questions on This Topic
What's the difference between `depends_on` and a health check condition in Docker Compose, and when would a plain `depends_on` cause a race condition in production?
depends_on only controls container start order — it does not wait for the service to be ready. A plain depends_on: postgres_db starts your API as soon as the Postgres container starts, not when Postgres is actually accepting connections (which takes 2-8 seconds). The race condition: API connects, Postgres isn't ready yet, API crashes. Fix: add a healthcheck to Postgres using pg_isready, then use condition: service_healthy in depends_on.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Docker. Mark it forged?
18 min read · try the examples if you haven't