

Sarthak Varshney is a Docker Captain, 5x C# Corner MVP, and 2x Alibaba Cloud MVP, with over six years of hands-on experience in the IT industry, specializing in cloud computing, DevOps, and modern application infrastructure. He is an Author and Associate Consultant, known for working extensively with cloud platforms and container-based technologies in real-world environments.
Let me tell you something that happened to me early in my developer journey.
I spent an entire Saturday afternoon trying to get a PHP project running on my laptop. MySQL wasn't talking to Apache. Apache couldn't find PHP. And somehow, I had three different versions of PHP installed and none of them were the right one. By evening, I had rage-quit and ordered pizza.
Sound familiar?
That whole disaster could've been avoided with four lines of Docker Compose config. Today, we're going to build a complete LAMP stack — Linux, Apache, MySQL, and PHP — using Docker Compose. Everything will talk to everything. The code will actually work. And you'll understand why it works, not just copy-paste and pray.
Let's get into it.
LAMP is one of the oldest and most battle-tested web server setups in existence. It's what powers a huge chunk of the internet — WordPress, Wikipedia in its early days, countless company portals, college websites, and more.
The four letters stand for:
Think of it like a restaurant. Apache is the front-of-house — it greets customers (browsers) and takes their orders. PHP is the kitchen — it does the actual cooking, figures out what to put on the plate, and talks to the pantry. MySQL is the pantry — all your ingredients (data) live there. Linux is the building itself — everything runs on top of it.
Without Docker, setting all this up means installing each piece separately, fixing version conflicts, editing config files scattered across your filesystem, and then doing it all over again on your teammate's machine. With Docker Compose? You write one file, run one command, and everything just works. Every time. On every machine.
Before we write a single line, let's plan what we're building. Here's the folder structure we'll end up with:
lamp-project/
├── docker-compose.yml
├── php/
│ └── Dockerfile
└── www/
└── index.php
Simple, right? Three files. That's all it takes to spin up a full production-grade web server stack.
Go ahead and create this structure:
mkdir lamp-project
cd lamp-project
mkdir php www
Let's start with what we actually want to show on screen. Create www/index.php:
<?php
$host = 'db';
$dbname = 'lampdb';
$user = 'lampuser';
$pass = 'lamppassword';
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create a table if it doesn't exist
$pdo->exec("CREATE TABLE IF NOT EXISTS visitors (
id INT AUTO_INCREMENT PRIMARY KEY,
visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
// Log this visit
$pdo->exec("INSERT INTO visitors (visited_at) VALUES (NOW())");
// Count total visits
$count = $pdo->query("SELECT COUNT(*) FROM visitors")->fetchColumn();
echo "<h1>🐳 LAMP Stack is Running!</h1>";
echo "<p>This page has been visited <strong>$count</strong> time(s).</p>";
echo "<p>Database connection: <strong style='color:green'>Successful ✅</strong></p>";
echo "<p>PHP version: " . phpversion() . "</p>";
} catch (PDOException $e) {
echo "<h1>Database Error</h1>";
echo "<p>" . $e->getMessage() . "</p>";
}
?>
What's happening here? When someone visits the page, PHP connects to MySQL (using the hostname db — more on that in a moment), creates a visitors table if it doesn't exist, logs the visit, then shows a count of all visitors. Every time you refresh, the number goes up. Simple, but it proves the entire stack is wired together correctly.
Notice the hostname is db, not localhost or an IP address. This is one of the most important Docker networking concepts — containers talk to each other using their service names as hostnames. We'll define a service called db in Compose, and PHP finds it automatically. Docker's internal DNS handles the rest.
The default php:apache image doesn't have the MySQL extension pre-installed. So we need a tiny custom Dockerfile to add it. Create php/Dockerfile:
FROM php:8.2-apache
# Install the PDO MySQL extension
RUN docker-php-ext-install pdo pdo_mysql
# Enable Apache mod_rewrite (useful for most PHP frameworks)
RUN a2enmod rewrite
Three lines. That's genuinely all we need. docker-php-ext-install is a helper script that comes with the official PHP image — it makes installing extensions way easier than doing it manually. We're adding pdo and pdo_mysql so our PHP code can talk to MySQL.
Now for the main act. Create docker-compose.yml in the root of your project:
version: '3.8'
services:
# ─── Web Server + PHP ───────────────────────────────
web:
build:
context: ./php
dockerfile: Dockerfile
ports:
- "8080:80"
volumes:
- ./www:/var/www/html
depends_on:
db:
condition: service_healthy
networks:
- lamp_network
# ─── MySQL Database ──────────────────────────────────
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: lampdb
MYSQL_USER: lampuser
MYSQL_PASSWORD: lamppassword
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-prootpassword"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lamp_network
# ─── phpMyAdmin (Bonus!) ─────────────────────────────
phpmyadmin:
image: phpmyadmin:latest
ports:
- "8081:80"
environment:
PMA_HOST: db
PMA_USER: lampuser
PMA_PASSWORD: lamppassword
depends_on:
- db
networks:
- lamp_network
# ─── Named Volume ─────────────────────────────────────
volumes:
db_data:
# ─── Network ──────────────────────────────────────────
networks:
lamp_network:
driver: bridge
Okay, this is the part where most tutorials just say "here's the file, copy it." Not us. Let's walk through what every section actually means.
web servicebuild:
context: ./php
dockerfile: Dockerfile
Instead of pulling a pre-built image, we're telling Compose to build our custom image using the Dockerfile we wrote. The context tells Docker where to look for files.
ports:
- "8080:80"
This maps port 8080 on your laptop to port 80 inside the container (where Apache listens). Open http://localhost:8080 in your browser — that's your gateway in.
volumes:
- ./www:/var/www/html
This is a bind mount. Whatever is in your ./www folder on your computer gets mirrored inside the container at /var/www/html. Edit index.php on your laptop, refresh your browser — the change is live. No rebuilding needed.
depends_on:
db:
condition: service_healthy
This is the part beginners usually get wrong. depends_on tells Compose to start the db container before web. But the important part is condition: service_healthy — this says "don't start web until db is actually ready to accept connections," not just "until the container has started." MySQL takes a few seconds to initialize, and without this, PHP would try to connect before MySQL is ready and throw an error.
db serviceenvironment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: lampdb
MYSQL_USER: lampuser
MYSQL_PASSWORD: lamppassword
The official MySQL image reads these environment variables on first startup and automatically creates the database and user for you. No SQL scripts, no manual setup.
volumes:
- db_data:/var/lib/mysql
This is a named volume — different from the bind mount we used for the web files. Named volumes are managed entirely by Docker. Your database data persists even if you stop and remove the container. Without this, every time you restart the stack, your database would be wiped clean. Not ideal.
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-prootpassword"]
interval: 10s
timeout: 5s
retries: 5
This is how Docker knows MySQL is actually ready. Every 10 seconds, Docker runs mysqladmin ping inside the container. If it gets a successful response, the container is marked as healthy. The web service (with condition: service_healthy) waits for this before starting. This is the proper way to handle startup dependencies — no sleep 10 hacks.
networks:
lamp_network:
driver: bridge
We're creating a custom bridge network called lamp_network. Every service that joins this network can reach every other service by name. That's why $host = 'db' works in our PHP code — Docker resolves db to the MySQL container's internal IP automatically.
Navigate to your project folder and run:
docker compose up --build
The --build flag tells Compose to build the custom PHP image from our Dockerfile. You only need this the first time (or whenever you change the Dockerfile).
You'll see a wall of logs. MySQL initializes, Apache starts, PHP extensions get loaded. After about 20-30 seconds, look for something like AH00558: apache2: Could not reliably determine... — that means Apache is up and running (that warning is harmless, by the way).
Now open your browser:
http://localhost:8080http://localhost:8081If you see the "LAMP Stack is Running!" page with a visitor count, you did it. The entire stack is up.
To run it in the background so it doesn't tie up your terminal:
docker compose up --build -d
To stop everything:
docker compose down
To stop and delete all data (including the database volume):
docker compose down -v
"Connection refused" or "could not find driver" errors
This almost always means PHP tried to connect to MySQL before MySQL was ready. Make sure your depends_on uses condition: service_healthy and your db service has a proper healthcheck defined. Without the healthcheck, service_healthy condition has nothing to check against.
Port already in use
Error: Bind for 0.0.0.0:8080 failed: port is already allocated
Something else on your machine is using port 8080. Either stop that service, or change "8080:80" to something like "9090:80" in your Compose file. The left side is your host port — change it to whatever's free.
Changes to PHP files not showing up
If you're using a bind mount (which we are), changes should be instant. If they're not, it's usually a browser caching issue — do a hard refresh with Ctrl+Shift+R (or Cmd+Shift+R on Mac).
"Access denied for user" errors
Double-check that the database name, username, and password in your PHP code exactly match what you set in the environment section of your Compose file. Case-sensitive.
MySQL keeps restarting
Check the logs: docker compose logs db. Often it's a permission issue with the volume, or a previous failed initialization left corrupt data. Try docker compose down -v to wipe the volume and start fresh.
A few commands that'll make you feel like you know what you're doing:
# See all running containers
docker compose ps
# View live logs from all services
docker compose logs -f
# View logs from just the database
docker compose logs -f db
# Open a shell inside the PHP container
docker compose exec web bash
# Connect to MySQL directly from your terminal
docker compose exec db mysql -u lampuser -plamppassword lampdb
That last one is really useful for debugging. You can run SQL queries directly against your database without going through phpMyAdmin.
When you run docker compose up, Docker:
lamp_network bridge networkdb_data named volume (if it doesn't exist)mysql:8.0 and phpmyadmin:latest imagesweb image from php/Dockerfiledb container and runs health checks every 10 secondsdb is healthy, starts the web and phpmyadmin containerslamp_networkAll three containers can talk to each other via service name, but from outside (your browser), you can only reach them through the ports you explicitly mapped. This is isolation in action — your containers aren't just floating around on your host network, they're in their own little private network.
Okay, the stack works. Now make it yours. Here are three challenges, pick the ones that match your level:
Challenge 1 — Beginner: Add a new page called info.php in the www/ folder that displays phpinfo(). Access it at http://localhost:8080/info.php. (Hint: you don't need to rebuild anything — the bind mount handles it.)
Challenge 2 — Intermediate: Modify index.php to also display the last 5 visit timestamps from the visitors table in a simple HTML table. You'll need a SELECT query with ORDER BY visited_at DESC LIMIT 5.
Challenge 3 — Advanced: Add environment variable support. Move the database credentials out of index.php and into the docker-compose.yml under the web service's environment section. Then read them in PHP using $_ENV['DB_PASSWORD'] (and make sure to call putenv or enable variables_order in your php.ini). This is how real applications handle secrets — not hardcoded in source files.
You just built a complete LAMP stack with Docker Compose. Apache, PHP 8.2, MySQL 8.0, and phpMyAdmin — all running together, all talking to each other, all with a single command.
What you learned along the way:
service_healthy is better than just depends_onThe LAMP stack is 25+ years old, but the principles here — isolated services, declarative configuration, network-based communication — are the same principles that power modern microservices architecture. You're not just learning how to run PHP. You're learning how distributed systems think.
Next up in the series, we'll look at how to add Nginx as a reverse proxy in front of Apache, and why you might want to do that. But for now — you've earned that coffee.