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.