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.
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.
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.
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
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.
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.
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.
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.
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.”
- db starts and runs pg_isready -U postgres -d metals every five seconds. Until that succeeds, nothing else is allowed to start.
- api starts once the database is healthy, so its first connection attempt can't fail on a database that's still initializing.
- 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.
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.
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.
| Placeholder | Behavior |
|---|---|
${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. |
Day-to-day commands from the project root
| Command | What it does |
|---|---|
docker compose up -d --build | The one to use. Builds every image, starts all four services in dependency order, returns your prompt. |
docker compose ps | Shows all three with their health status — the fastest way to see which piece isn't up. |
docker compose logs -f api | Follows one service. Swap in ui or db, or omit the name for everything at once. |
docker compose up -d --build ui | Rebuilds and restarts just the UI — enough after editing anything under metals_ui/. |
docker compose down | Stops and removes all three containers and the network. The database volume survives. |
docker compose down -v | Also deletes the volume — a full database reset. All local data is lost. |
Common errors and how to fix them
| What you see | Likely cause | How to fix it |
|---|---|---|
| Set JWT_SECRET_KEY in .env before starting the API | Working as designed — the required variable is missing. | Create .env with JWT_SECRET_KEY=<any long random string>. |
| Ports 5432, 5000, or 8888 already allocated | A 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 unhealthy | A 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 exist | An 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 effect | Compose 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 request | The 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. |