A developer at a laptop watching four containers start up with green checks

By Ines Duarte ·

Running a Docker Compose Stack Without Guessing

One YAML file describes every container your app needs, and one command starts them together. Here is what each part of the file does, how the Dockerfile fits in, and the checks that turn "it starts on my machine" into "it starts everywhere".

What a Compose File Actually Describes

A docker-compose.yml (or compose.yaml) is a list of services. Each service is one container image plus how to run it: the ports to publish, the folders or volumes to mount, the environment variables to set, and which other services it needs first. Run docker compose up -d in that folder and Docker creates a private network, starts the containers in dependency order and gives each one a hostname equal to its service name. Your app reaches the database at db:5432, not at an IP address.

The file has no version: line any more. That key belonged to the old Compose v1 file formats; the current tool follows the Compose Specification and ignores it with a warning. If a tutorial starts with version: "3.8", it is describing 2020.

Image or build

A service either pulls an image: from a registry (postgres:16-alpine) or build:s one from a Dockerfile in a folder you point at. Databases, caches and proxies are pulled; your own application is built. Pin image tags to a version. postgres on its own means postgres:latest, and "latest" is whatever the maintainers pushed this morning, which is how a stack that worked in March fails in June.

Ports and expose

ports: publishes a container port on your machine, written host:container. "8080:80" means your browser talks to localhost:8080 and nginx inside sees port 80. Quote them: YAML reads 22:22 as a base-60 number. expose: only documents a port for other containers; they can reach every container port anyway, so it is mostly a note. Databases usually need no ports: at all, because only other services talk to them. If you publish one for a desktop client, bind it to localhost: "127.0.0.1:5432:5432".

Volumes: named or bind

Two kinds, one syntax. ./src:/app/src is a bind mount: a folder on your machine appears inside the container, live, which is what you want for source code during development. dbdata:/var/lib/postgresql/data is a named volume: Docker owns the storage, it survives docker compose down, and you delete it deliberately with down -v. Named volumes must also be declared once under a top-level volumes: key, or Compose refuses to start. Anything that must survive a container being recreated (database files, uploads) belongs in a named volume, never inside the container.

Environment and secrets

environment: sets variables inside the container. Official images are configured this way: POSTGRES_PASSWORD, MYSQL_DATABASE, WORDPRESS_DB_HOST. Writing a password in the YAML is fine on a laptop and wrong anywhere the file is shared. Put it in a .env file next to the Compose file as POSTGRES_PASSWORD=..., reference it as ${POSTGRES_PASSWORD}, and add .env to .gitignore. The generator does this rewrite for you when the .env option is on.

depends_on and healthchecks

depends_on in its short form only orders the start. Postgres is "started" a second before it accepts connections, so an app that connects on boot crashes, restarts, and works on the second try, which looks like a flaky app and is really a race. The fix is a healthcheck: on the database (pg_isready, mysqladmin ping, redis-cli ping) and the long form on the dependant:

depends_on:
  db:
condition: service_healthy

Now the app container is not created until the database has answered its probe. Give the check a start_period so a slow first initialisation is not counted as failures.

restart policies

Without restart:, a crashed container stays down and nothing comes back after a reboot. unless-stopped restarts on crashes and on boot but respects a manual docker compose stop; it is the right default for services. always ignores the manual stop. on-failure suits one-shot jobs that should retry a few times and then give up.

How the Dockerfile Feeds the Compose File

The Compose file says which containers to run and how to wire them. The Dockerfile says what is inside one of them. A service with build: . tells Compose to run docker build on the Dockerfile in the current folder and use the result as that service's image. Everything else about the service (ports, volumes, environment) is Compose's job, and repeating it in the Dockerfile (an ENV with a database password, a VOLUME line) only causes confusion about which one wins.

The Dockerfile's own concerns are layer order and image size. Copy the dependency manifest first and install, then copy the source, so a code change does not reinstall every package. Do the build in one stage and ship from a fresh one so compilers and dev dependencies stay behind. Switch to a non-root user before CMD. Add a HEALTHCHECK so the Compose file's condition: service_healthy has something to read. The Dockerfile generator produces exactly that shape for Node, Python, PHP, Go and static sites.

First-Run Checklist

  1. docker compose config prints the file as Compose understands it, with variables substituted. If this errors, nothing else will work; the message names the line.
  2. docker compose up without -d the first time, so the logs of every service scroll in one terminal and the crash is visible.
  3. "port is already allocated": another process or another project owns that host port. Change the left number in ports:, not the right one.
  4. A container that exits immediately with code 0 had no long-running process: the CMD finished. Exit code 1 or 2 means it crashed; read its log with docker compose logs app.
  5. "connection refused" from the app to the database means it connected before the database was ready, or used localhost instead of the service name. Inside a container, localhost is the container itself.
  6. A change to the Dockerfile needs docker compose up --build; a change to the Compose file needs only up. A change to a named volume's contents needs nothing, which is the point of volumes.
  7. Permission errors on a bind mount usually mean the container user's uid differs from yours. Match them with user: "1000:1000" or chown the folder in the Dockerfile.
  8. Before sharing the file, paste it into the validator. It catches the unpinned tag and the plain-text password you stopped seeing.

Production differences

Same file, three changes. Drop the bind mounts of source code; the built image already contains it. Remove every published database port. Move secrets out of the file entirely, into .env on the server or a secrets manager, and put a reverse proxy (Caddy, Traefik, nginx) in front of the web service so it terminates TLS and the app never listens on the host directly. Compose supports a second file (docker compose -f compose.yaml -f compose.prod.yaml up) that overrides just those keys, which keeps one source of truth for the services themselves.