Docker Compose Basics

Docker & DevOps · Lesson 16 of 26 7 min read Updated

A PHP app with a MySQL database already needs two long docker run commands, a network and a volume, typed in the right order with the right options. Add phpMyAdmin and Redis and nobody on the team will remember them all. Docker Compose replaces those commands with one YAML file that describes the whole app. One command starts everything, and another removes it.

In this lesson you will write a compose.yaml for a school notice board built with PHP and MySQL, and learn the everyday Compose commands.

The project

Text
notice-board/
├── compose.yaml
├── Dockerfile
└── src/
    └── index.php

The PHP page connects to MySQL and counts the notices. It reads its connection settings from environment variables, so the same code works anywhere:

PHP
<?php
declare(strict_types=1);

$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST'), getenv('DB_NAME'));
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASSWORD'), [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$pdo->exec('CREATE TABLE IF NOT EXISTS notices (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL
)');
$count = (int) $pdo->query('SELECT COUNT(*) FROM notices')->fetchColumn();

echo 'Connected to MySQL. Notices: ' . $count;

The Dockerfile adds the pdo_mysql extension to the official PHP and Apache image:

Dockerfile
FROM php:8.5-apache
RUN docker-php-ext-install pdo_mysql
COPY src/ /var/www/html/

The compose.yaml file

YAML
services:
  web:
    build: .
    ports:
      - "8080:80"
    environment:
      DB_HOST: db
      DB_NAME: notices
      DB_USER: meera
      DB_PASSWORD: Meera-Change-Me
    depends_on:
      - db

  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: notices
      MYSQL_USER: meera
      MYSQL_PASSWORD: Meera-Change-Me
      MYSQL_ROOT_PASSWORD: Root-Change-Me
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  dbdata:

Reading it from the top:

  • services lists the containers that make up the app. Each key (web, db) is a service name.
  • build: . tells Compose to build an image from the Dockerfile in this folder. image: mysql:8.4 uses a ready-made image instead.
  • ports, environment and volumes are the same ideas as -p, -e and -v in docker run.
  • depends_on makes Compose start db before web. (Starting first is not the same as being ready; the next lesson fixes that with healthchecks.)
  • The top-level volumes section declares the named volume dbdata.

Notice what is not there: no network. Compose creates one network for the project automatically and attaches every service to it, and each service can reach the others by its service name. That is why DB_HOST is simply db. The database has no ports entry, so it is reachable from web but not from outside.

A few YAML rules prevent most errors: indent with spaces only (two per level is common), a - starts a list item, and key: value pairs make a map. Put port mappings in quotes, because YAML can misread some unquoted values with a colon as numbers.

Starting and checking the app

Run Compose from the project folder. -d runs everything in the background, as with docker run:

Terminal
docker compose up -d --build
docker compose ps
Output
[+] Running 4/4
 ✔ Network notice-board_default     Created                                       0.1s
 ✔ Volume "notice-board_dbdata"     Created                                       0.0s
 ✔ Container notice-board-db-1      Started                                       0.7s
 ✔ Container notice-board-web-1     Started                                       0.9s
NAME                 IMAGE              COMMAND                  SERVICE   CREATED         STATUS         PORTS
notice-board-db-1    mysql:8.4          "docker-entrypoint.s…"   db        6 seconds ago   Up 5 seconds   3306/tcp, 33060/tcp
notice-board-web-1   notice-board-web   "docker-php-entrypoi…"   web       6 seconds ago   Up 5 seconds   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp

(The build output for the web image comes first and is left out here.) Compose used the folder name, notice-board, as the project name and named everything after it: the network notice-board_default, the volume notice-board_dbdata, and containers notice-board-db-1 and notice-board-web-1. The -1 is a number, because a service can run several copies.

Open http://localhost:8080. In the first 20 seconds or so you may see a connection error, because MySQL is still setting itself up. Refresh, and the page shows Connected to MySQL. Notices: 0.

Everyday Compose commands

Compose commands use service names, not container names:

Terminal
docker compose logs -f web          # follow one service's logs
docker compose exec db mysql -u meera -p notices
docker compose restart web          # restart one service
docker compose up -d --build        # rebuild and apply changes
docker compose config               # print the final, merged configuration
docker compose stop                 # stop containers, keep them
docker compose down                 # stop and remove containers and the network
docker compose down -v              # ...and also delete the named volumes

docker compose up -d is safe to run again and again. Compose compares the file with what is running and only recreates services whose configuration or image changed. Change a port in compose.yaml, run up -d, and only that service is replaced.

docker compose config is a great checking tool: it reports YAML mistakes and shows the configuration after Compose has filled in all defaults and variables.

Common mistakes

  • Tabs in YAML. YAML does not allow tab characters for indentation. Configure your editor to insert spaces.
  • Using localhost as the database host. Use the service name (db). Each service is its own container.
  • Expecting code changes to appear without rebuilding. build: . builds an image once. After editing code, run docker compose up -d --build (or use a bind mount or Compose watch in development).
  • Writing docker-compose with a hyphen. That is the retired Compose v1 tool. Use docker compose.
  • Running Compose from the wrong folder. Compose looks for compose.yaml in the current folder; use -f path/to/compose.yaml otherwise.

Key points

  • Compose describes a multi-container app in compose.yaml; docker compose up -d starts it and docker compose down removes it.
  • Each entry under services becomes one or more containers, built with build or pulled with image.
  • Compose creates a default network; services reach each other by service name.
  • Names are based on the project name (the folder name by default), such as notice-board-web-1.
  • logs, exec, ps, restart and config work with service names; down -v also deletes volumes.

Exercise

Add a third service to the notice board: the adminer database tool from the official adminer image, reachable at http://localhost:8081. Adminer listens on port 8080 inside its container. Then write the commands to start only the new service and to read its logs.

Show solution
YAML
adminer:
  image: adminer
  ports:
    - "8081:8080"
  depends_on:
    - db
Terminal
docker compose up -d adminer
docker compose logs adminer

The service goes under services, at the same level as web and db. In Adminer's login form, use the server name db, because Adminer runs on the same Compose network. docker compose up -d adminer starts the named service (and db, which it depends on) without touching web.

Quick Quiz

1. How does the web service find the MySQL container?

2. What does docker compose down -v do that docker compose down does not?

3. What is the preferred file name for Compose files today?

4. You changed only the ports of web and run docker compose up -d. What happens?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call