Docker Compose Basics
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
notice-board/
├── compose.yaml
├── Dockerfile
└── src/
└── index.phpThe PHP page connects to MySQL and counts the notices. It reads its connection settings from environment variables, so the same code works anywhere:
<?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:
FROM php:8.5-apache
RUN docker-php-ext-install pdo_mysql
COPY src/ /var/www/html/The compose.yaml file
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:
serviceslists 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.4uses a ready-made image instead.ports,environmentandvolumesare the same ideas as-p,-eand-vindocker run.depends_onmakes Compose startdbbeforeweb. (Starting first is not the same as being ready; the next lesson fixes that with healthchecks.)- The top-level
volumessection declares the named volumedbdata.
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:
docker compose up -d --build
docker compose ps[+] 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:
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 volumesdocker 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
localhostas 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, rundocker compose up -d --build(or use a bind mount or Compose watch in development). - Writing
docker-composewith a hyphen. That is the retired Compose v1 tool. Usedocker compose. - Running Compose from the wrong folder. Compose looks for
compose.yamlin the current folder; use-f path/to/compose.yamlotherwise.
Key points
- Compose describes a multi-container app in
compose.yaml;docker compose up -dstarts it anddocker compose downremoves it. - Each entry under
servicesbecomes one or more containers, built withbuildor pulled withimage. - 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,restartandconfigwork with service names;down -valso 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
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- dbdocker compose up -d adminer
docker compose logs adminerThe 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?
Compose attaches all services to a default network with DNS by service name.
2. What does docker compose down -v do that docker compose down does not?
-v removes the volumes declared in the file, which is usually not what you want.
3. What is the preferred file name for Compose files today?
The older names still work, but compose.yaml is preferred.
4. You changed only the ports of web and run docker compose up -d. What happens?
Compose compares the desired configuration with what is running and changes only what differs.
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
