TutorialsDocker › Running the Whole Stack

Docker · Page 6 of 6

docker-compose.yml, Four Services in One Command

Running this project used to mean three terminals: a database container, a Python process, and a static file server. docker-compose.yml replaces all of that with docker compose up. It builds the images, starts PostgreSQL alongside them, puts them all on a private network where they can find each other by name, and — the part worth studying — starts them in the right order by waiting for each to report healthy, not merely started.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Services → tutorials, ui, api, db Start → docker compose up -d --build UI at → localhost:8888 Tutorials at → localhost:8890 API at → localhost:5000 Requires → JWT_SECRET_KEY in .env

Purpose of this page

Get the entire application running locally with one command, and understand how the containers find each other, when each one starts, and where their configuration comes from.

Codey typing on a laptop
one file, four containers

What Compose adds beyond docker run

Everything here could be done with four long docker run commands and a hand-created network. Compose turns that into a checked-in file, which means the setup is identical for everyone and reviewable in a pull request.

A private network with DNSthe big one

Compose creates a network for the project and puts every container on it. Each service name becomes a hostname: the API reaches the database at db, and Nginx reaches the API at api. No IP addresses, no discovery mechanism — the service key in the YAML is the DNS name.

That name resolution is provided by Docker's embedded DNS at 127.0.0.11 — the address the UI's Nginx config points its resolver at.

Build and run in one stepno separate docker build

Services with a build: key are built from source when missing (or when --build is passed) and run immediately. That's why there's no step in this project's local setup that says “first build the images.”

The whole file all four services

docker-compose.ymlservices:
  # The tutorial site. Independent of the application: no database, no API, so
  # it has no depends_on and starts immediately.
  tutorials:
    build: ./tutorials
    image: metals-tutorials:local
    restart: unless-stopped
    ports:
      - "${TUTORIALS_PORT:-8890}:8080"

  ui:
    build: ./metals_ui
    image: metals-ui:local
    restart: unless-stopped
    ports:
      - "${UI_PORT:-8888}:8080"
    environment:
      API_UPSTREAM: http://api:5000
    depends_on:
      api:
        condition: service_healthy

  api:
    build:
      context: .
      dockerfile: metals_api/Dockerfile
    image: metals-api:local
    restart: unless-stopped
    ports:
      - "${API_PORT:-5000}:5000"
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: metals
      DB_USER: postgres
      DB_PASSWORD: postgres
      JWT_SECRET_KEY: ${JWT_SECRET_KEY:?Set JWT_SECRET_KEY in .env before starting the API}
      JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-60}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: metals
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata_metals:/var/lib/postgresql/data
      - ./sql/metals-db.sql:/docker-entrypoint-initdb.d/metals-db.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d metals"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

volumes:
  pgdata_metals:

Service by service what each key does

uibuilt from metals_ui/

build: ./metals_ui is the short form — that folder is both the context and where the Dockerfile is found. image: metals-ui:local names the result, so the built image is tagged rather than left dangling. The API_UPSTREAM: http://api:5000 entry is what makes the Nginx proxy point at the sibling container — the same value the image already defaults to, stated explicitly here so the wiring is visible in one place.

apibuilt from the repo root

The long form of build:, because context and Dockerfile differ: the context is . (the repo root, so requirements.txt is reachable) while the file is metals_api/Dockerfile. That's the same split explained on the API image page, expressed in YAML.

Its database settings point at DB_HOST: db — the service name, not localhost. Inside a container, localhost means that container, which is one of the most common first-time mistakes.

dbofficial image, unmodified

No build: key — PostgreSQL is pulled straight from Docker Hub and configured entirely through environment variables and mounts. Its two volumes do very different jobs: the named pgdata_metals volume persists the data, while the read-only bind mount drops sql/metals-db.sql into the image's auto-initialization folder so the schema loads on first start.

The seed data, the reset procedure, and the two development login accounts are covered in the database tutorial.

tutorialsthe odd one out

The tutorial site you're reading, built from ./tutorials. It's the only service with no depends_on — it reads no database and calls no API, so there's nothing for it to wait for and it starts immediately alongside the database.

It's included here purely for convenience: one command brings up the application and its documentation. Nothing in the application depends on it, and removing the service would change nothing else.

restart: unless-stoppedon every service

Restarts a container automatically if it crashes or if Docker itself restarts — but not if you deliberately stopped it. It's the sensible default for a development stack: survive a laptop reboot, respect an explicit docker compose stop.

Start order and health gates the part worth studying

depends_on on its own only controls the order containers are started in — and a started PostgreSQL is not the same as one accepting connections. Adding condition: service_healthy changes the promise from “it has been launched” to “it has passed its health check.”

Codey pointing to the right
db, then api, then ui
The chaindb → api → ui
  1. db starts and runs pg_isready -U postgres -d metals every five seconds. Until that succeeds, nothing else is allowed to start.
  2. api starts once the database is healthy, so its first connection attempt can't fail on a database that's still initializing.
  3. ui waits for the API to be healthy — using the HEALTHCHECK baked into the API's own Dockerfile, since the compose file defines none for that service.

That last point is the neat part: a health check defined in a Dockerfile and a dependency gate defined in Compose are the same mechanism, meeting in the middle.

start_period: 10sgrace, not tolerance

During the first ten seconds, failures don't count against retries — PostgreSQL is expected to be unavailable while it initializes. Without a grace window, a slow first start (which is exactly what happens the very first time, when the whole schema is loaded) could exhaust the retries and mark the container unhealthy before it ever had a chance.

You can watch the gates work: run docker compose up without -d and the terminal shows db reaching health before api is created, and api reaching health before ui appears.

The .env file it expects required before first run

Compose automatically reads a file named .env next to docker-compose.yml and uses it to fill the ${…} placeholders in the YAML. This project uses three of them, with three different behaviors.

Codey holding up a sticky note
one required, two optional
PlaceholderBehavior
${JWT_SECRET_KEY:?...}Required. The :? form makes Compose refuse to start and print the message after the ? if the variable is missing or empty — far better than an API that starts and then can't issue tokens.
${UI_PORT:-8888}Optional with a default. Publishes the UI on 8888 unless you set UI_PORT to something else — useful when that port is taken.
${TUTORIALS_PORT:-8890}Same pattern for the tutorial site.
${API_PORT:-5000}Same pattern for the API's published port.
${JWT_EXPIRATION_MINUTES:-60}Same pattern; tokens last an hour unless overridden.

Minimum viable .env: a single line setting JWT_SECRET_KEY to any long random string. Copy .env.example and fill it in. Note the database credentials are not read from .env here — they're written literally into the compose file, so the API and PostgreSQL containers can't disagree about them.

Day-to-day commands from the project root

CommandWhat it does
docker compose up -d --buildThe one to use. Builds every image, starts all four services in dependency order, returns your prompt.
docker compose psShows all three with their health status — the fastest way to see which piece isn't up.
docker compose logs -f apiFollows one service. Swap in ui or db, or omit the name for everything at once.
docker compose up -d --build uiRebuilds and restarts just the UI — enough after editing anything under metals_ui/.
docker compose downStops and removes all three containers and the network. The database volume survives.
docker compose down -vAlso deletes the volume — a full database reset. All local data is lost.

Once everything is healthy, open http://localhost:8888 and sign in with admin / password. These tutorials are at http://localhost:8890. The API is also directly reachable at http://localhost:5000 if you want to call it with curl or Postman — though the UI itself always goes through the proxy at /api.

Common errors and how to fix them

What you seeLikely causeHow to fix it
Set JWT_SECRET_KEY in .env before starting the APIWorking as designed — the required variable is missing.Create .env with JWT_SECRET_KEY=<any long random string>.
Ports 5432, 5000, or 8888 already allocatedA local PostgreSQL install, a previously started container, or another project holds the port.Stop the other process, or set UI_PORT / API_PORT in .env. For 5432, edit the mapping in the compose file.
dependency failed to start: container ... is unhealthyA service never passed its health check, so everything downstream was cancelled.Read that service's logs directly: docker compose logs db (or api).
relation "users" does not existAn old volume from before a schema change; the init script only runs on an empty data directory.docker compose down -v then up -d --build — see the database reset steps.
Code changes have no effectCompose reused the existing image.Add --build. Files are copied in at build time, not mounted from your working tree.
UI loads but shows errors for every requestThe API is running but unhealthy, or the proxy can't reach it.docker compose ps to check the API's health, then docker compose logs api.
Codey giving a thumbs up

The whole stack runs locally — next, see how these same images get to Azure in the Deployment tutorial.