A PHP container starts. MySQL is still initializing. The first request fails, even though both containers appear to be running. Starting the pieces is only part of the job; they also need to find each other and start in a useful order.
That coordination is orchestration. Docker Compose describes a multi-container application in one file and manages it as a project. For a local PHP application, two services are enough: a web server running PHP and a MySQL database. Each can be rebuilt or replaced independently.
This example is for development on one machine. It uses the current Compose Specification and the docker compose CLI. The original 2015 version used the old file format and docker-compose; the example below replaces it completely.
Create the Project
Install Docker with Compose using the official installation instructions. Docker Desktop includes Compose; Linux installations can use the Compose plugin. Check that the CLI is available and the engine is running:
docker compose version
docker infoCreate an empty directory called compose-php-demo with these files:
compose-php-demo/
├── compose.yaml
├── Dockerfile
└── src/
└── index.phpRun the commands below from that directory. The example uses PHP 8.5 and MySQL 8.4 image tags, which follow patch updates within those release lines.
Give PHP a Database Driver
The official PHP image provides a helper for installing extensions. Save this as Dockerfile:
FROM php:8.5-apache
RUN docker-php-ext-install pdo_mysqlThe Apache variant serves PHP from /var/www/html. The extra extension lets PHP’s PDO interface connect to MySQL. Application code will be mounted from the host, so editing it will not require rebuilding the image.
Save this connection check as src/index.php:
<?php
header('Content-Type: text/plain; charset=utf-8');
try {
$pdo = new PDO(
'mysql:host=db;dbname=app;charset=utf8mb4',
getenv('DB_USER'),
getenv('DB_PASSWORD'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$database = $pdo->query('SELECT DATABASE()')->fetchColumn();
echo "Connected to {$database}\n";
} catch (PDOException $error) {
http_response_code(503);
error_log($error->getMessage());
echo "Database unavailable\n";
}The successful response requires an actual database query. A failed connection returns HTTP 503, with details in the container log rather than in the browser.
Wire the Services Together
Save the following as compose.yaml. The passwords are disposable examples for this local stack; do not reuse them for a deployed service.
services:
web:
build: .
ports:
- '127.0.0.1:8080:80'
volumes:
- ./src:/var/www/html:ro
environment:
DB_USER: app
DB_PASSWORD: local-app-password
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: local-app-password
MYSQL_ROOT_PASSWORD: local-root-password
volumes:
- db_data:/var/lib/mysql
healthcheck:
test:
- CMD-SHELL
- >-
MYSQL_PWD="$${MYSQL_PASSWORD}" mysql
--protocol=TCP --host=127.0.0.1
--user="$${MYSQL_USER}" "$${MYSQL_DATABASE}"
--execute="SELECT 1"
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
volumes:
db_data:The Compose service reference defines the top-level services: map. build: . builds the web image from our Dockerfile; image: mysql:8.4 uses a published image for the database. These are not mutually exclusive: a service can also declare both a build and an image name.
Names Replace Container Addresses
Compose attaches both services to the project’s default network. PHP connects to db on MySQL’s container port, 3306. It does not need a container IP address or a published database port. Inside web, localhost would refer to the web container itself.
The links attribute still exists, but it is unnecessary for this connection: services sharing the default network can reach each other by service name.
Only the web service publishes a port. 127.0.0.1:8080:80 maps the host’s loopback port 8080 to port 80 inside the container. MySQL remains accessible to the web container without exposing port 3306 on the host.
Running Is Not Ready
The MySQL image creates the database and application user during its first initialization. A running container does not mean that work has finished.
The health check connects over TCP as the application user and runs SELECT 1 against app. The $$ escapes Compose interpolation, leaving the variable expansion to the shell inside the database container. condition: service_healthy makes Compose wait for that check before starting web.
This controls initial startup. It does not repair later database failures or restart PHP when MySQL becomes unhealthy. The PHP page handles a failed connection by returning 503; a larger application also needs an appropriate retry or recovery policy.
Code and Data Have Different Lifetimes
The bind mount exposes src to Apache read-only. Changes made in your editor appear in the container without a rebuild.
The named volume db_data stores database files outside the container’s writable layer. Recreating the database container preserves those files. The MySQL image documentation also explains a common surprise: initialization variables only configure an empty data directory. Changing MYSQL_PASSWORD in YAML will not change a user in an existing database.
Start and Check the Stack
Validate the configuration, build the PHP image, and start the project:
docker compose config --quiet
docker compose up --build --wait --wait-timeout 180
docker compose ps
curl --fail --retry 10 --retry-connrefused --retry-delay 1 http://127.0.0.1:8080/The HTTP response should be:
Connected to app--wait implies detached mode. It waits for MySQL to become healthy and for the web container to be running. Since web has no health check, the separate HTTP request verifies that Apache is serving PHP and PHP can query the database.
If startup fails, inspect the service state and logs:
docker compose ps --all
docker compose logs --tail=100 db webIf port 8080 is already occupied, change the host port in compose.yaml and the URL used by curl. If the database stays unhealthy, check its logs and whether an existing volume was initialized with different credentials.
Change, Rebuild, Stop
Edit src/index.php and reload the page to change application code. After editing the Dockerfile or adding an extension, rerun docker compose up --build --wait --wait-timeout 180; Compose rebuilds the image and recreates affected containers. There is no need to remove every container before a build.
To stop and remove this project’s containers and default network while keeping the database volume:
docker compose downRun the startup command again to reuse the data. Keeping the same project directory name also keeps Compose’s default project name and its volume association. Named volumes are not backups: before changing database release lines, plan a backup and follow the database’s upgrade instructions.
This leaves the stack ready for the next development session: source files on the host, database files in the volume, and the wiring in compose.yaml.






