57.13 Project 13: LAMP stack: Apache, PHP 8, MySQL and phpMyAdmin
Why: the problem
Suppose we look after the website of a college in Nashik. It was written in PHP years ago and still runs on one old server with Apache, PHP and MySQL installed by hand. Nobody remembers which PHP extensions were installed, the PHP version is out of support, and a new developer needs two days to copy the setup on a laptop. LAMP means Linux, Apache, MySQL and PHP. It is the classic stack behind Wikipedia (MediaWiki), Facebook in its early days, and most WordPress sites. With Docker each part becomes a container: one PHP plus Apache image that we build with the exact extensions, one MySQL container with a volume, and phpMyAdmin for a database UI. The same compose.yaml runs on the laptop and on the Ubuntu server, so moving to PHP 8.3 becomes a one-line change that we can test first.
What: every term in plain English
- LAMP — Linux, Apache, MySQL, PHP. A web server, a language and a database on Linux.
- php:8.3-apache — Official image with PHP 8.3 running inside Apache as mod_php, serving /var/www/html.
- docker-php-ext-install — A helper script in the official PHP images that compiles and turns on extensions like pdo_mysql.
- PDO — PHP Data Objects, the safe way to talk to MySQL with prepared statements.
- mod_rewrite — An Apache module that turns pretty URLs like /students/5 into index.php?id=5.
- phpMyAdmin — A web UI for MySQL: browse tables, run SQL, import and export.
- docker-entrypoint-initdb.d — A folder in the MySQL image. .sql files here run once when the data volume is empty.
- php.ini — PHP settings like upload size, memory limit and timezone.
How: step by step
- Run plain php:apache. Serve one PHP file from a bind mount.
- Build our own PHP image. Extensions, php.ini, mod_rewrite.
- Add MySQL with seed data. init.sql and a named volume.
- Add phpMyAdmin. Database UI on 127.0.0.1.
- Connect PHP to MySQL with PDO. Read and insert rows.
- Back up and restore. mysqldump.
- Clean up. down -v.
Architecture
Folder layout
lamp/
compose.yaml
.env
Dockerfile
php.ini
apache-site.conf
init.sql
public/
.htaccess
db.php
health.php
index.php
student.php
Example 1: Run PHP and Apache with one command
Goal. Run PHP and Apache with one command.
public/info.php
<?php
echo "PHP " . PHP_VERSION . " on " . php_uname('n') . "\n";
echo "Extensions: " . implode(', ', get_loaded_extensions()) . "\n";
run commands
mkdir -p lamp/public && cd lamp
docker run -d --name web -p 8080:80 -v "$PWD/public":/var/www/html:ro php:8.3-apache
curl -s localhost:8080/info.php
docker logs web --tail 2
What each line does
<?php— Starts PHP code inside the file. Apache sends .php files to PHP to run.PHP_VERSION— A built-in constant with the running PHP version.php_uname('n')— The host name, which is the container ID inside Docker.get_loaded_extensions()— Lists PHP extensions. Note pdo_mysql is missing in the plain image.-v public:/var/www/html:ro— Apache serves our folder. :ro means the container cannot change our files.docker logs— Apache access and error logs go to the container output.
Green output
$ curl -s localhost:8080/info.php
PHP 8.3.13 on 3f1c9a2b7d10
Extensions: Core, date, libxml, openssl, pcre, sqlite3, zlib, ctype, curl, dom, fileinfo, filter, hash, json, mbstring, PDO, pdo_sqlite, session, ...
$ docker logs web --tail 1
172.17.0.1 - - [25/Oct/2026:10:20:11 +0000] "GET /info.php HTTP/1.1" 200 512 "-" "curl/8.5.0"
$ docker rm -f web
Expected result. PHP and Apache run with zero install. pdo_mysql is not in the list, so we build our own image next.
Example 2: Our own PHP image with extensions and settings
Goal. Our own PHP image with extensions and settings.
Dockerfile
FROM php:8.3-apache
RUN apt-get update \
&& apt-get install -y --no-install-recommends libzip-dev libpng-dev \
&& docker-php-ext-install pdo_mysql mysqli zip gd opcache \
&& a2enmod rewrite headers \
&& rm -rf /var/lib/apt/lists/*
COPY php.ini /usr/local/etc/php/conf.d/college.ini
COPY apache-site.conf /etc/apache2/sites-available/000-default.conf
COPY public/ /var/www/html/
RUN chown -R www-data:www-data /var/www/html
HEALTHCHECK --interval=10s --timeout=3s CMD curl -fs http://localhost/health.php || exit 1
php.ini
memory_limit = 256M
upload_max_filesize = 20M
post_max_size = 25M
date.timezone = Asia/Kolkata
expose_php = Off
display_errors = Off
log_errors = On
error_log = /dev/stderr
opcache.enable = 1
apache-site.conf
<VirtualHost *:80>
DocumentRoot /var/www/html
<Directory /var/www/html>
AllowOverride All
Require all granted
Options -Indexes
</Directory>
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
</VirtualHost>
public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^students/([0-9]+)$ student.php?id=$1 [L,QSA]
public/health.php
<?php
http_response_code(200);
echo "ok";
What each line does
docker-php-ext-install— Builds pdo_mysql, mysqli, zip, gd and opcache into the image.libzip-dev libpng-dev— System libraries the zip and gd extensions need to compile.a2enmod rewrite headers— Turns on Apache modules for pretty URLs and security headers.conf.d/college.ini— Every .ini in this folder is loaded, so we add our settings without editing the main php.ini.date.timezone = Asia/Kolkata— Dates in PHP show Indian time.expose_php = Off— Hides the PHP version from response headers.AllowOverride All— Lets .htaccess files work, which many old PHP apps need.Options -Indexes— No folder listings if index.php is missing.chown www-data— Apache runs as www-data and must read the files.
Green output
$ docker build -t college-web:1.0 .
=> naming to docker.io/library/college-web:1.0
$ docker run --rm college-web:1.0 php -m | grep -E 'pdo_mysql|gd|zip'
gd
pdo_mysql
zip
$ docker run --rm college-web:1.0 php -r 'echo date_default_timezone_get();'
Asia/Kolkata
Expected result. The image now has every extension the college app needs, written down in one Dockerfile.
Example 3: The full LAMP stack in Compose with seed data and phpMyAdmin
Goal. The full LAMP stack in Compose with seed data and phpMyAdmin.
compose.yaml
services:
web:
build: .
image: college-web:1.0
ports: ["8080:80"]
environment:
DB_HOST: db
DB_NAME: college
DB_USER: college
DB_PASS: ${DB_PASS}
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: college
MYSQL_USER: college
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db-data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASS}"]
interval: 5s
retries: 20
restart: unless-stopped
pma:
image: phpmyadmin:5.2
ports: ["127.0.0.1:8081:80"]
environment:
PMA_HOST: db
UPLOAD_LIMIT: 50M
depends_on: [db]
volumes:
db-data:
init.sql
CREATE TABLE students (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
branch VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO students (name, branch) VALUES
('Asha Patil', 'Computer'),
('Rohan Deshmukh', 'Mechanical'),
('Sneha Kulkarni', 'IT');
.env
DB_ROOT_PASS=change-me-root
DB_PASS=change-me-app
What each line does
build: . and image:— Compose builds the Dockerfile and tags the result college-web:1.0.DB_HOST: db— PHP reaches MySQL by the service name db on the Compose network.condition: service_healthy— Apache starts only after MySQL answers ping, so the first page does not fail.01-init.sql— Runs only on the first start, when db-data is empty.PMA_HOST: db— phpMyAdmin logs in to the db container.127.0.0.1:8081— phpMyAdmin is not open to the internet; it is a common attack target..env— Real passwords stay in .env, which is in .gitignore.
Green output
$ docker compose up -d --build --wait
Container lamp-db-1 Healthy
Container lamp-web-1 Healthy
Container lamp-pma-1 Started
$ docker compose exec db mysql -ucollege -pchange-me-app college -e 'SELECT id,name,branch FROM students'
id name branch
1 Asha Patil Computer
2 Rohan Deshmukh Mechanical
3 Sneha Kulkarni IT
# Browser: http://localhost:8081 user college, password change-me-app
Expected result. Three containers, seed data loaded on first start, and a database UI only on this machine.
Example 4: PHP pages that read and write MySQL with PDO
Goal. PHP pages that read and write MySQL with PDO.
public/db.php
<?php
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST'), getenv('DB_NAME'));
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
return $pdo;
}
public/index.php
<?php
require __DIR__ . '/db.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$in = json_decode(file_get_contents('php://input'), true);
if (empty($in['name']) || empty($in['branch'])) {
http_response_code(400);
echo json_encode(['error' => 'name and branch are required']);
exit;
}
$st = db()->prepare('INSERT INTO students (name, branch) VALUES (?, ?)');
$st->execute([$in['name'], $in['branch']]);
http_response_code(201);
echo json_encode(['id' => (int) db()->lastInsertId()]);
exit;
}
echo json_encode(db()->query('SELECT id, name, branch FROM students ORDER BY id')->fetchAll());
public/student.php
<?php
require __DIR__ . '/db.php';
header('Content-Type: application/json');
$st = db()->prepare('SELECT id, name, branch FROM students WHERE id = ?');
$st->execute([(int) ($_GET['id'] ?? 0)]);
$row = $st->fetch();
if (!$row) { http_response_code(404); $row = ['error' => 'not found']; }
echo json_encode($row);
What each line does
getenv('DB_HOST')— Reads the variables Compose passes in, so no password is written in code.static $pdo— One connection per request, reused by every query.ERRMODE_EXCEPTION— Database errors throw exceptions instead of failing silently.prepare + execute([...])— Prepared statements block SQL injection, because user input is never pasted into SQL.php://input— The raw JSON body of the POST request.students/2— The .htaccess rule sends this pretty URL to student.php?id=2.
Green output
$ docker compose up -d --build
$ curl -s localhost:8080/
[{"id":1,"name":"Asha Patil","branch":"Computer"},{"id":2,"name":"Rohan Deshmukh","branch":"Mechanical"},{"id":3,"name":"Sneha Kulkarni","branch":"IT"}]
$ curl -s -X POST localhost:8080/ -H 'Content-Type: application/json' -d '{"name":"Kiran Joshi","branch":"Civil"}'
{"id":4}
$ curl -s localhost:8080/students/4
{"id":4,"name":"Kiran Joshi","branch":"Civil"}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/students/99
404
Expected result. The PHP app reads and writes MySQL safely, and pretty URLs work through mod_rewrite.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p lamp/public && cd lampWhat it does: Project folder with the PHP code in public/.
(no output) -
Add files
ls -a . publicWhat it does: Copy Dockerfile, php.ini, apache-site.conf, compose.yaml, init.sql, .env and the PHP files.
apache-site.conf compose.yaml Dockerfile .env init.sql php.ini public .htaccess db.php health.php index.php info.php student.php -
Build and start
docker compose up -d --build --waitWhat it does: Builds college-web:1.0 and waits for health checks.
Container lamp-web-1 Healthy -
List students
curl -s localhost:8080/What it does: Reads the three seed rows.
[{"id":1,"name":"Asha Patil",...}] -
Add a student
curl -s -X POST localhost:8080/ -H 'Content-Type: application/json' -d '{"name":"Kiran Joshi","branch":"Civil"}'What it does: Inserts a row with a prepared statement.
{"id":4} -
Pretty URL
curl -s localhost:8080/students/4What it does: mod_rewrite maps it to student.php.
{"id":4,"name":"Kiran Joshi","branch":"Civil"} -
Watch logs
docker compose logs -f webWhat it does: Apache access and PHP error lines. Ctrl+C to stop.
"GET /students/4 HTTP/1.1" 200 -
Open phpMyAdmin
echo http://localhost:8081What it does: Browse the students table in the browser.
http://localhost:8081 -
Back up
docker compose exec db sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" college' > college.sqlWhat it does: A full SQL dump on the host.
ls -l college.sql -rw-r--r-- 1 ubuntu ubuntu 2311 college.sql -
Prove data survives
docker compose down && docker compose up -d --wait && curl -s localhost:8080/students/4What it does: The db-data volume keeps Kiran's row.
{"id":4,"name":"Kiran Joshi","branch":"Civil"} -
Clean up
docker compose down -v docker image rm college-web:1.0What it does: Removes containers, the volume and the image.
Volume lamp_db-data Removed
When it is red 1
Fatal error: Uncaught PDOException: could not find driver
Cause. The pdo_mysql extension is not installed in the image.
Fix, step by step
- Add docker-php-ext-install pdo_mysql to the Dockerfile.
- Rebuild: docker compose up -d --build.
When it is red 2
SQLSTATE[HY000] [2002] Connection refused
Cause. PHP tried to connect before MySQL was ready, or DB_HOST is localhost.
Fix, step by step
- Use DB_HOST=db, not localhost.
- Keep depends_on with condition: service_healthy.
When it is red 3
The requested URL was not found on this server. (/students/4)
Cause. mod_rewrite is off or AllowOverride is None, so .htaccess is ignored.
Fix, step by step
- a2enmod rewrite in the Dockerfile.
- AllowOverride All in apache-site.conf.
When it is red 4
AH00558: apache2: Could not reliably determine the server's fully qualified domain name
Cause. Only a warning: Apache has no ServerName.
Fix, step by step
- Safe to ignore.
- Or add ServerName localhost to an Apache conf file in the image.
When it is red 5
Table 'college.students' doesn't exist
Cause. init.sql did not run because the db-data volume already existed from an earlier run.
Fix, step by step
- Init scripts run only on an empty volume.
- Run docker compose down -v and start again, or run the SQL by hand with mysql.
Practice tasks
Easy Task 1: Plain
Why: php:apache.
What to do: Serve info.php with the plain image.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run -d --name web -p 8080:80 -v "$PWD/public":/var/www/html:ro php:8.3-apache
curl -s localhost:8080/info.phpEasy Task 2: Extension
Why: pdo_mysql.
What to do: Show that pdo_mysql is in your image but not in the plain one.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm php:8.3-apache php -m | grep pdo
docker run --rm college-web:1.0 php -m | grep pdoEasy Task 3: Seed
Why: init.sql.
What to do: Add a fourth student in init.sql and recreate the volume.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose down -v
docker compose up -d --waitMedium Task 4: Insert
Why: PDO.
What to do: POST two students and list them.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -X POST localhost:8080/ -H 'Content-Type: application/json' -d '{"name":"Meera","branch":"IT"}'Medium Task 5: Rewrite
Why: Pretty URLs.
What to do: Add a rule /branch/IT that lists students of one branch.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
RewriteRule ^branch/([A-Za-z]+)$ branch.php?b=$1 [L,QSA]Medium Task 6: Settings
Why: php.ini.
What to do: Change upload_max_filesize to 50M and prove it.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose exec web php -i | grep upload_max_filesizeMedium Task 7: Backup
Why: mysqldump.
What to do: Back up, drop the table, and restore.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose exec -T db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" college' < college.sqlHard Task 8: Upgrade
Why: PHP version.
What to do: Run the app on php:8.1-apache, then 8.3, and compare php -v.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose exec web php -vHard Task 9: Dev mode
Why: Bind mount.
What to do: Add a compose.override.yaml that mounts ./public so edits show without rebuild.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
services:
web:
volumes: ["./public:/var/www/html"]Easy Task 10: Clean
Why: Habit.
What to do: Remove everything.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lamp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose down -v
docker image rm college-web:1.0Homework: try at home
Upgrade the college app from PHP 8.1 to PHP 8.3: start with FROM php:8.1-apache, run the app, change one line to 8.3, rebuild, and prove with curl and php -v that everything still works. Then add a file-upload page that saves photos to a named volume at /var/www/html/uploads.
Recap
- LAMP becomes three containers: web (Apache + PHP), db (MySQL), pma (phpMyAdmin).
- docker-php-ext-install adds extensions in the Dockerfile.
- Settings go in a conf.d .ini file and an Apache site file.
- PHP finds MySQL by service name through environment variables.
- Prepared statements stop SQL injection.
- Data lives in a named volume and survives down and up.
FAQ
Q. Can I use MariaDB instead of MySQL?
Yes. Change the image to mariadb:11.4 and the variables to MARIADB_*; PHP code stays the same.
Q. Should code be copied into the image or bind mounted?
Copied for servers (fixed version), bind mounted only on a laptop for fast edits.
Q. Is mod_php still used?
Yes, in php:apache images. For high traffic, many teams use nginx with PHP-FPM, which is Project 14.
Q. How do I add Composer?
Add COPY --from=composer:2 /usr/bin/composer /usr/bin/composer to the Dockerfile and run composer install.
Q. How do I see PHP errors?
They go to stderr, so docker compose logs web shows them.
Q. How do I add HTTPS?
Put nginx or Traefik with Let's Encrypt in front, as in Project 4.
Interview questions
-
How would you containerise a legacy PHP application?
Model answer: Pick the right php:X-apache image, install the needed extensions in a Dockerfile, copy settings, move config to environment variables, and run MySQL in its own container with a volume.
-
What is the difference between php:apache and php:fpm images?
Model answer: apache runs PHP inside Apache; fpm runs only a PHP process manager on port 9000 that needs nginx in front.
-
How do you stop SQL injection in PHP?
Model answer: Use PDO prepared statements with placeholders; never join user input into SQL.
-
Why does a table not exist even though init.sql is mounted?
Model answer: The data volume already existed, so the init scripts were skipped.
-
How do you back up a MySQL container?
Model answer: Run mysqldump through docker compose exec and save the output on the host or in S3.
Quick quiz
Q1. What does LAMP stand for?
Linux, Apache, MySQL and PHP.
Q2. How do you add a PHP extension in the official image?
Use docker-php-ext-install in the Dockerfile and rebuild.
Q3. Why use DB_HOST=db and not localhost?
In a container localhost is the container itself; MySQL is the db service on the network.
Q4. When does init.sql run?
Only on the first start, when the MySQL data volume is empty.
Q5. Why bind phpMyAdmin to 127.0.0.1?
So it is not reachable from the internet.
Samajla ka?
Revision card
Interview card
Comparison card