University of Wales Trinity Saint David
ACCA7023 - Agile and DevOps

Multi-Container Apps with Docker Compose

Overview

In the Containerising Your Own Project lab you started a front end and an API by hand, with a network, names and flags that had to be typed in the right order. Add a database and that becomes a script nobody wants to maintain. Docker Compose describes the whole application in one file and starts it with one command. In this lab you will run the quotes front end, API and a Postgres database together, and show that the data survives the containers being thrown away.

Learning Objectives

By the end of this lab you will be able to:

Concept

Compose files are written in YAML, a format that uses indentation to show structure. Indentation must be spaces, never tabs, and it matters. The main section is services. Each service describes one kind of container: which image to use or how to build it, which ports to publish, which environment variables to set and which volumes to mount. Almost every docker run flag you have used has a matching key.

The file is declarative. It states what should exist, not the commands to create it. docker compose up compares the file with what is running and creates, recreates or leaves alone each container as needed. Run it twice and nothing happens the second time, because everything already matches.

Compose also creates a network for the application, named after the project folder, and attaches every service to it. Each service can reach the others using the service name as a host name, just like the user-defined network you created by hand. The database is never published to your machine. Only the front end is.

Tasks

Task 1 - See the target

This is the application you will describe. Three services share one network, and the database keeps its files in a named volume.

Which service is the only one that needs a ports entry?

The browser only talks to web. web reaches api and api reaches db over the private network, so publishing them would only add risk.

Task 2 - Keep the secret out of the file

The compose file will be committed to version control, so it must not contain the database password. Compose automatically reads a file called .env in the same folder and substitutes its values wherever the compose file says ${NAME}. Create two files in your quotes folder, next to quotes-api and quotes-web. The example file is committed so others know which values to provide. The real .env is not.

.env.example
# Copy this file to .env. Compose reads .env automatically.
POSTGRES_PASSWORD=change-me
cp .env.example .env
echo ".env" >> .gitignore

Edit .env and choose your own password.

Task 3 - Write the compose file

Create compose.yaml in the quotes folder. The web and api services are done. Complete the db service.

  1. Use the image postgres:17-alpine.
  2. Set POSTGRES_USER to quotes, POSTGRES_DB to quotes and POSTGRES_PASSWORD to ${POSTGRES_PASSWORD}.
  3. Mount the named volume db-data at /var/lib/postgresql/data, where Postgres keeps its files.
  4. Add a health check that runs pg_isready -U quotes every 5 seconds, so Compose can tell when the database is actually accepting connections.
compose.yaml
# The whole quotes application: front end, API and database.
# Start it with: docker compose up -d --build
services:
  web:
    build: ./quotes-web
    ports:
      - "8080:8080"
    depends_on:
      - api

  api:
    build: ./quotes-api
    environment:
      DATABASE_URL: postgres://quotes:${POSTGRES_PASSWORD}@db:5432/quotes
      APP_VERSION: compose
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    # TODO: image
    # TODO: environment, three variables
    # TODO: volumes, the named volume db-data
    # TODO: healthcheck
    #   test: ["CMD-SHELL", "pg_isready -U quotes"]
    #   interval: 5s
    #   timeout: 3s
    #   retries: 10

volumes:
  db-data:

Read the api service carefully. build replaces docker build. The host name in DATABASE_URL is db, the service name. depends_on with condition: service_healthy makes Compose wait for the database health check to pass before starting the API, so the API never tries to connect to a database that is still starting. restart: unless-stopped restarts the API if it crashes.

Task 4 - Start everything

docker compose up -d --build
docker compose ps

Watch the output. Compose builds both images, creates the network and volume, starts db, waits until it reports Healthy, and only then starts api and web.

Success condition: http://localhost:8080 shows the quotes page, and the status line under the heading reads API version compose, storage: postgres. One command replaced everything you typed in the previous lab, and added a database.

Task 5 - Work with the running application

Compose commands take service names, not container names. Follow the API's logs, add a quote in the browser, and watch the request arrive. Press Ctrl+C to stop following.

docker compose logs -f api

Run a query inside the database container to see the quote stored in a real table.

docker compose exec db psql -U quotes -c 'SELECT id, author FROM quotes;'

Now look at what Compose created. Both names start with your project folder name.

docker network ls
docker volume ls

Success condition: you can find quotes_default in the network list and quotes_db-data in the volume list.

Task 6 - Throw the containers away and keep the data

docker compose down stops and removes every container and the network. Then bring everything back.

docker compose down
docker compose ps -a
docker compose up -d

Success condition: the quote you added is still there. Every container is brand new, but Postgres found its files in the db-data volume, which down leaves alone. Compare that with the Containerising the Application lab later in the module, where notes held in memory vanish when the container is replaced.

Now remove the volume as well.

docker compose down -v
docker compose up -d

Success condition: only the two starting quotes remain. -v deletes named volumes. It is the right way to reset a development database and a very wrong command to run anywhere real data lives.

Task 7 - Change one service

Edit quotes-web/index.html and change the <h1> heading to something of your own. Then run up again with --build.

docker compose up -d --build

Success condition: the output shows web being recreated while api and db are reported as Running and left alone. Reload the browser to see your heading. Compose noticed that only the web image had changed and replaced only that container.

docker compose down

Task 8 - Evaluate

A new team member clones the repository. List exactly what they must do to run the whole application, and explain why that matters for a team.

Install Docker, copy .env.example to .env and set a password, then run docker compose up -d --build. They do not need Node.js, Postgres or any other tool installed, and they do not need to know the order to start things in or which network to create, because all of that is recorded in compose.yaml. For a team this means new members are productive in minutes, everyone runs the same versions, and the set-up is version controlled and reviewed like code instead of living in someone's memory or an out of date wiki page.

Without condition: service_healthy, the API sometimes fails when the stack starts. Explain why.

Plain depends_on only waits for the db container to be started, not for Postgres inside it to be ready. Postgres takes a few seconds to initialise, especially the first time when it creates the database. If the API starts during that window, its connection is refused and it exits. The health check runs pg_isready, which only succeeds once Postgres accepts connections, so waiting for service_healthy closes the gap. Making the application retry its connection is a good additional defence.

Why must the password be in .env and not in compose.yaml? Is .env a complete solution for production?

compose.yaml is committed and shared, so anything in it is visible to everyone with access to the repository, forever, in its history. .env is excluded from version control, so each person or machine supplies its own value. It is not a complete solution for production, because the secret still sits in a plain text file on disk and appears in the container environment. Production platforms use a secrets manager, such as Azure Key Vault or Docker and Kubernetes secrets, that controls and audits access and can rotate values.

Solution

Try the tasks above before revealing the solution.