Ravindra BagaleCourses & study guides मराठी

Chapter 57: Docker Projects Part 1: Websites, Databases, LAMP, LEMP, WordPress, 3-Tier and Every Language with MySQL and MongoDB

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.
Run plain php:apache. Build our own PHP image. Add MySQL with seed data. Add phpMyAdmin. Connect PHP to MySQL with PDO. Back up and restore. Clean up.
LAMP: Apache with PHP, MySQL and phpMyAdmin on one network

How: step by step

  1. Run plain php:apache. Serve one PHP file from a bind mount.
  2. Build our own PHP image. Extensions, php.ini, mod_rewrite.
  3. Add MySQL with seed data. init.sql and a named volume.
  4. Add phpMyAdmin. Database UI on 127.0.0.1.
  5. Connect PHP to MySQL with PDO. Read and insert rows.
  6. Back up and restore. mysqldump.
  7. Clean up. down -v.

Architecture

Browser :8080 web: college-web:1.0 (Apache + PHP 8.3, mod_rewrite) PDO over the Compose network to db:3306 db: MySQL 8.4 (db-data volume, init.sql) pma: phpMyAdmin on 127.0.0.1:8081
LAMP stack 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)

  1. Make folders

    mkdir -p lamp/public && cd lamp
    

    What it does: Project folder with the PHP code in public/.

    (no output)
    
  2. Add files

    ls -a . public
    

    What 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
    
  3. Build and start

    docker compose up -d --build --wait
    

    What it does: Builds college-web:1.0 and waits for health checks.

    Container lamp-web-1 Healthy
    
  4. List students

    curl -s localhost:8080/
    

    What it does: Reads the three seed rows.

    [{"id":1,"name":"Asha Patil",...}]
    
  5. 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}
    
  6. Pretty URL

    curl -s localhost:8080/students/4
    

    What it does: mod_rewrite maps it to student.php.

    {"id":4,"name":"Kiran Joshi","branch":"Civil"}
    
  7. Watch logs

    docker compose logs -f web
    

    What it does: Apache access and PHP error lines. Ctrl+C to stop.

    "GET /students/4 HTTP/1.1" 200
    
  8. Open phpMyAdmin

    echo http://localhost:8081
    

    What it does: Browse the students table in the browser.

    http://localhost:8081
    
  9. Back up

    docker compose exec db sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" college' > college.sql
    

    What it does: A full SQL dump on the host.

    ls -l college.sql
    -rw-r--r-- 1 ubuntu ubuntu 2311 college.sql
    
  10. Prove data survives

    docker compose down && docker compose up -d --wait && curl -s localhost:8080/students/4
    

    What it does: The db-data volume keeps Kiran's row.

    {"id":4,"name":"Kiran Joshi","branch":"Civil"}
    
  11. Clean up

    docker compose down -v
    docker image rm college-web:1.0
    

    What 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

  1. Add docker-php-ext-install pdo_mysql to the Dockerfile.
  2. 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

  1. Use DB_HOST=db, not localhost.
  2. 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

  1. a2enmod rewrite in the Dockerfile.
  2. 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

  1. Safe to ignore.
  2. 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

  1. Init scripts run only on an empty volume.
  2. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. 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.php

Easy Task 2: Extension

Why: pdo_mysql.

What to do: Show that pdo_mysql is in your image but not in the plain one.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. 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 pdo

Easy Task 3: Seed

Why: init.sql.

What to do: Add a fourth student in init.sql and recreate the volume.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose down -v
docker compose up -d --wait

Medium Task 4: Insert

Why: PDO.

What to do: POST two students and list them.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose exec web php -i | grep upload_max_filesize

Medium Task 7: Backup

Why: mysqldump.

What to do: Back up, drop the table, and restore.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose exec -T db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" college' < college.sql

Hard Task 8: Upgrade

Why: PHP version.

What to do: Run the app on php:8.1-apache, then 8.3, and compare php -v.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose exec web php -v

Hard Task 9: Dev mode

Why: Bind mount.

What to do: Add a compose.override.yaml that mounts ./public so edits show without rebuild.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lamp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose down -v
docker image rm college-web:1.0

Homework: 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

  1. 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.

  2. 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.

  3. How do you stop SQL injection in PHP?

    Model answer: Use PDO prepared statements with placeholders; never join user input into SQL.

  4. 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.

  5. 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?

Ravi Sir's Cards for this lesson

मराठी cards