dom4in.net
← All resources
Guide · Docker

Run a LAMP stack with Docker Compose

One compose file replaces an afternoon of apt-get: Apache+PHP, MySQL, and phpMyAdmin, each in its own container, with the database surviving rebuilds on a named volume.

The process

The complete path from an empty folder to a working PHP app talking to MySQL. The volume and networking details in the middle are what make it survive a restart.

Directions

1Create the project folder layout

Make a project folder with two things in it: a src/ directory for your PHP code and a docker-compose.yml. Everything else — the database files, the images — lives in Docker-managed storage, so the folder stays clean enough to commit as-is.

Put credentials in a .env file next to the compose file and add it to .gitignore. Compose reads it automatically, which keeps passwords out of the YAML you commit.

lamp/
├── docker-compose.yml
├── .env              (gitignored)
└── src/
    └── index.php

2Write the compose file

Three services: web builds on the php:apache image with your src/ bind-mounted into the web root; db runs mysql:8 with a named volume for its data directory; phpmyadmin gives you a browser UI on the database. Compose puts all three on one private network where they reach each other by service name — the PHP app connects to host db, not localhost.

services:
  web:
    image: php:8.3-apache
    ports: ["8080:80"]
    volumes: ["./src:/var/www/html"]
  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: app
    volumes: ["dbdata:/var/lib/mysql"]
  phpmyadmin:
    image: phpmyadmin
    ports: ["8081:80"]
    environment: { PMA_HOST: db }
volumes:
  dbdata:

3Add the MySQL extension to PHP

The stock php:apache image ships without database drivers — the single most common surprise in this setup. Add a two-line Dockerfile that installs pdo_mysql (and mysqli if your code uses it), and point the web service at build: . instead of image:.

Rebuild with docker compose build whenever you add extensions. Anything your app needs beyond vanilla PHP — gd for images, zip, intl — goes in the same docker-php-ext-install line.

FROM php:8.3-apache
RUN docker-php-ext-install pdo_mysql mysqli

4Start the stack and connect

Run docker compose up -d. First start pulls images and initializes MySQL, which takes a minute — watch docker compose logs db until it says ready for connections. Then open localhost:8080 for your app and localhost:8081 for phpMyAdmin (log in as root with the password from .env).

In your PHP code the DSN is mysql:host=db;dbname=app — service name as host. If you connect to localhost inside the container you get the classic "connection refused," because localhost there is the web container itself.

5Know what persists and how to reset

docker compose down stops and removes containers but keeps the named volume, so your data survives. docker compose down -v deletes the volume too — that's your clean-slate reset when you want MySQL to re-run its init. Code changes in src/ appear instantly since it's a bind mount; no rebuild needed.

For anything beyond local development, treat this file as the starting point, not the deployment: put real secrets in a secret store, pin image versions instead of tracking latest, and don't publish the database or phpMyAdmin ports on a machine with a public IP.

Common issues & fixes

PHP says 'could not find driver'.

The stock image has no MySQL extension. Add the Dockerfile with docker-php-ext-install pdo_mysql, switch the service to build: ., and docker compose build.

Connection refused connecting to localhost from PHP.

Inside a container, localhost is that container. Use the service name db as the host — Compose's network resolves it.

MySQL container restarts in a loop after a version change.

The data volume was initialized by a different major version. Either match the old version, or docker compose down -v to wipe and re-init (destroys data — dump first).

Changed MYSQL_ROOT_PASSWORD in .env but the old one still works.

Those env vars only apply on first initialization. The password lives in the volume now — change it with an ALTER USER query, or reset the volume.

Port 8080 already allocated.

Another service owns the port. Change the host side of the mapping ("8090:80") — the container side stays 80.

Uploads disappear after a rebuild.

They were written inside the container layer, not a volume. Store uploads under the bind-mounted src/ path or add a dedicated volume for them.

Honesty note: articles are drafted with AI assistance, then a human checks each one against the vendor's current setup flow before it publishes — nothing goes live unreviewed. Vendor interfaces still change; if a step looks different, the underlying record is what matters.