57.14 Project 14: LEMP stack: nginx, PHP-FPM and MySQL
Why: the problem
Suppose we run the PHP site of a coaching institute in Kolhapur that sells online test series, and during result week traffic jumps ten times. With Apache and mod_php every Apache process carries PHP inside it, uses a lot of RAM, and even images and CSS files are served by a heavy PHP process. LEMP replaces Apache with nginx (the E is for "engine-x"). nginx serves static files itself and passes only .php requests to PHP-FPM, a separate pool of PHP workers. This split is the usual way to run busy PHP sites, and it is the common setup for Laravel and Symfony apps. In Docker that means three containers: nginx, php-fpm and MySQL. They talk over one network, share the code folder, and each one can be scaled and tuned alone.
What: every term in plain English
- LEMP — Linux, nginx (engine-x), MySQL, PHP.
- PHP-FPM — FastCGI Process Manager. A pool of PHP worker processes that listens on port 9000.
- FastCGI — The protocol nginx uses to send a PHP request to PHP-FPM and get the answer back.
- fastcgi_pass — The nginx line that names the PHP-FPM address, here php:9000.
- SCRIPT_FILENAME — The full path of the .php file. nginx sends it so PHP-FPM knows which file to run.
- pm.max_children — The biggest number of PHP workers FPM may start. It limits RAM use.
- Static files — Images, CSS and JS. nginx serves them directly, without PHP.
- Shared code volume — nginx and PHP-FPM must see the same files at the same path.
How: step by step
- Build a PHP-FPM image. pdo_mysql, opcache, pool settings.
- Write the nginx config. Static files and fastcgi_pass php:9000.
- Compose nginx + php + MySQL. Shared code, health checks.
- Run the PHP app with PDO. Tests and results.
- Add the FPM status page. See busy and idle workers.
- Scale PHP workers. --scale php=3.
- Clean up. down -v.
Architecture
Folder layout
lemp/
compose.yaml
.env
init.sql
nginx/default.conf
php/
Dockerfile
zz-pool.conf
app.ini
src/
db.php
public/
index.php
style.css
Example 1: A PHP-FPM image with extensions and a tuned pool
Goal. A PHP-FPM image with extensions and a tuned pool.
php/Dockerfile
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache \
&& apk add --no-cache fcgi
COPY php/zz-pool.conf /usr/local/etc/php-fpm.d/zz-pool.conf
COPY php/app.ini /usr/local/etc/php/conf.d/app.ini
COPY src/ /var/www/html/
HEALTHCHECK --interval=10s --timeout=3s \
CMD SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 || exit 1
php/zz-pool.conf
[www]
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
pm.status_path = /fpm-status
ping.path = /ping
catch_workers_output = yes
php/app.ini
memory_limit = 128M
date.timezone = Asia/Kolkata
expose_php = Off
opcache.enable = 1
opcache.validate_timestamps = 0
What each line does
php:8.3-fpm-alpine— Official PHP image with only PHP-FPM on port 9000, on small Alpine Linux (about 80 MB).fcgi— Gives the cgi-fcgi tool, so the health check can talk FastCGI to FPM directly.zz-pool.conf— Files load in name order; zz loads last, so our values win over www.conf.pm = dynamic— FPM starts 4 workers and grows up to 20 when busy.pm.max_children = 20— At about 30 MB per worker, PHP uses at most about 600 MB.pm.max_requests = 500— Each worker restarts after 500 requests, which clears small memory leaks.ping.path— FPM answers pong on /ping; our health check uses it.opcache.validate_timestamps = 0— PHP never re-checks files for changes. Fast for servers; we rebuild the image to ship new code.
Green output
$ docker build -f php/Dockerfile -t coaching-php:1.0 .
=> naming to docker.io/library/coaching-php:1.0
$ docker run --rm coaching-php:1.0 php-fpm -tt 2>&1 | grep -E 'max_children|status_path'
NOTICE: pm.max_children = 20
NOTICE: pm.status_path = /fpm-status
$ docker images coaching-php
REPOSITORY TAG SIZE
coaching-php 1.0 84.2MB
Expected result. A small PHP-FPM image whose worker pool size we chose on purpose.
Example 2: nginx config: static files direct, PHP to FPM
Goal. nginx config: static files direct, PHP to FPM.
nginx/default.conf
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location ~* \.(css|js|png|jpg|svg|woff2)$ {
expires 7d;
access_log off;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 30s;
}
location = /fpm-status {
allow 127.0.0.1;
allow 172.16.0.0/12;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /fpm-status;
fastcgi_param SCRIPT_NAME /fpm-status;
fastcgi_pass php:9000;
}
location ~ /\. { deny all; }
}
What each line does
root /var/www/html/public— Only the public folder is served. Code like src/db.php is outside it and can never be downloaded.expires 7d— Browsers cache images and CSS for 7 days. These never reach PHP.try_files ... /index.php?$query_string— Unknown URLs like /tests/5 go to index.php, the front controller.try_files $uri =404 (in .php)— Stops requests for .php files that do not exist from reaching PHP.fastcgi_pass php:9000— Sends PHP requests to the php service on its FPM port.SCRIPT_FILENAME— nginx tells FPM the real file path. The path must exist inside the php container too.location = /fpm-status— FPM worker statistics, allowed only from inside Docker networks.location ~ /\.— Blocks .env, .git and other hidden files.
Green output
$ docker run --rm -v "$PWD/nginx/default.conf":/etc/nginx/conf.d/default.conf:ro --add-host php:127.0.0.1 nginx:1.27-alpine nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Expected result. We test the config before starting the stack. --add-host gives a fake php name so nginx -t can resolve it.
Example 3: Compose: nginx, PHP-FPM and MySQL with shared code
Goal. Compose: nginx, PHP-FPM and MySQL with shared code.
compose.yaml
services:
nginx:
image: nginx:1.27-alpine
ports: ["8080:80"]
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./src/public:/var/www/html/public:ro
depends_on:
php: { condition: service_healthy }
restart: unless-stopped
php:
build: { context: ., dockerfile: php/Dockerfile }
image: coaching-php:1.0
environment:
DB_DSN: mysql:host=db;dbname=coaching;charset=utf8mb4
DB_USER: coaching
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: coaching
MYSQL_USER: coaching
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db-data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/01.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASS}"]
interval: 5s
retries: 20
restart: unless-stopped
volumes:
db-data:
init.sql
CREATE TABLE results (
id INT AUTO_INCREMENT PRIMARY KEY,
roll_no VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
score INT NOT NULL
);
INSERT INTO results (roll_no, name, score) VALUES
('KOP101', 'Pooja Shinde', 182),
('KOP102', 'Amit Jadhav', 167);
.env
DB_ROOT_PASS=change-me-root
DB_PASS=change-me-app
What each line does
./src/public mounted in nginx— nginx needs the static files and must see .php paths to apply try_files.Code baked in the php image— PHP runs the copy inside its image, so the php container does not depend on host files.Same path /var/www/html/public— Both containers use the same path, so SCRIPT_FILENAME works in FPM.No ports on php and db— Only nginx is public. PHP-FPM and MySQL are reachable only inside the network.DB_DSN— The full PDO connection string in one variable.php: service_healthy— nginx waits until FPM answers /ping.
Green output
$ docker compose up -d --build --wait
Container lemp-db-1 Healthy
Container lemp-php-1 Healthy
Container lemp-nginx-1 Started
$ docker compose ps --format '{{.Service}} {{.Ports}}'
db 3306/tcp, 33060/tcp
nginx 0.0.0.0:8080->80/tcp
php 9000/tcp
Expected result. Only nginx has a host port. PHP and MySQL stay private.
Example 4: The results app, the FPM status page and scaling
Goal. The results app, the FPM status page and scaling.
src/db.php
<?php
function db(): PDO {
static $pdo;
return $pdo ??= new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
src/public/index.php
<?php
require __DIR__ . '/../db.php';
header('Content-Type: application/json');
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/results' && $_SERVER['REQUEST_METHOD'] === 'GET') {
echo json_encode(db()->query('SELECT roll_no, name, score FROM results ORDER BY score DESC')->fetchAll());
} elseif (preg_match('#^/results/([A-Z0-9]+)$#', $path, $m)) {
$st = db()->prepare('SELECT roll_no, name, score FROM results WHERE roll_no = ?');
$st->execute([$m[1]]);
$row = $st->fetch();
http_response_code($row ? 200 : 404);
echo json_encode($row ?: ['error' => 'roll number not found']);
} elseif ($path === '/whoami') {
echo json_encode(['php_container' => gethostname()]);
} else {
http_response_code(404);
echo json_encode(['error' => 'no route']);
}
src/public/style.css
body { font-family: sans-serif; }
What each line does
require __DIR__ . '/../db.php'— db.php is outside public/, so nginx can never send it as a file.$pdo ??=— Creates the connection only the first time (PHP 7.4 and newer).front controller— Every URL goes to index.php, which picks the route from the path.preg_match('#^/results/...#')— Matches a URL like /results/KOP101 and takes the roll number.gethostname()— Returns the php container ID, so we can see which PHP container answered after scaling.
Green output
$ curl -s localhost:8080/results
[{"roll_no":"KOP101","name":"Pooja Shinde","score":182},{"roll_no":"KOP102","name":"Amit Jadhav","score":167}]
$ curl -s localhost:8080/results/KOP102
{"roll_no":"KOP102","name":"Amit Jadhav","score":167}
$ curl -sI localhost:8080/style.css | grep -iE 'expires|cache'
Cache-Control: max-age=604800
$ docker compose exec nginx wget -qO- localhost/fpm-status
pool: www
process manager: dynamic
active processes: 1
idle processes: 3
max children reached: 0
$ docker compose up -d --scale php=3
$ docker compose exec nginx nginx -s reload
$ for i in 1 2 3 4; do curl -s localhost:8080/whoami; echo; done
{"php_container":"8b1f02a9c3d4"}
{"php_container":"1a7e44c09b2f"}
{"php_container":"c93d10e5a8f1"}
{"php_container":"8b1f02a9c3d4"}
Expected result. Static files are cached, FPM shows its workers, and three PHP containers share the load because Docker DNS returns all their IPs for the name php.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p lemp/php lemp/nginx lemp/src/public && cd lempWhat it does: Folders for the PHP image, nginx config and code.
(no output) -
Add files
find . -type f | sortWhat it does: Copy every file from the examples.
./.env ./compose.yaml ./init.sql ./nginx/default.conf ./php/Dockerfile ./php/app.ini ./php/zz-pool.conf ./src/db.php ./src/public/index.php ./src/public/style.css -
Test nginx config
docker run --rm -v "$PWD/nginx/default.conf":/etc/nginx/conf.d/default.conf:ro --add-host php:127.0.0.1 nginx:1.27-alpine nginx -tWhat it does: Finds typos before start.
syntax is ok -
Build and start
docker compose up -d --build --waitWhat it does: Builds coaching-php:1.0 and starts all three.
Container lemp-nginx-1 Started -
Get results
curl -s localhost:8080/resultsWhat it does: PHP reads MySQL through PDO.
[{"roll_no":"KOP101",...}] -
One roll number
curl -s localhost:8080/results/KOP101What it does: Front controller route.
{"roll_no":"KOP101","name":"Pooja Shinde","score":182} -
Hidden file blocked
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/.envWhat it does: nginx denies dot files.
403 -
Static file
curl -sI localhost:8080/style.css | head -1What it does: Served by nginx alone.
HTTP/1.1 200 OK -
FPM status
docker compose exec nginx wget -qO- localhost/fpm-statusWhat it does: Shows active and idle workers.
active processes: 1 -
Load test
docker run --rm --network host williamyeh/hey -n 3000 -c 50 http://localhost:8080/resultsWhat it does: Result-week traffic.
[200] 3000 responses -
Scale PHP
docker compose up -d --scale php=3 && docker compose exec nginx nginx -s reloadWhat it does: Three FPM containers behind one nginx.
Container lemp-php-3 Started -
See logs
docker compose logs php --tail 5What it does: FPM and PHP errors from all three containers.
php-1 | NOTICE: ready to handle connections -
Clean up
docker compose down -v docker image rm coaching-php:1.0What it does: Removes containers, volume and image.
Volume lemp_db-data Removed
When it is red 1
502 Bad Gateway
connect() failed (111: Connection refused) while connecting to upstream, upstream: "fastcgi://172.19.0.3:9000"
Cause. PHP-FPM is not running or is still starting.
Fix, step by step
- docker compose ps php and docker compose logs php.
- Keep depends_on php: service_healthy so nginx waits.
When it is red 2
File not found. (FastCGI sent in stderr: "Primary script unknown")
Cause. SCRIPT_FILENAME points to a path that does not exist inside the php container.
Fix, step by step
- Use the same path /var/www/html/public in both containers.
- Check: docker compose exec php ls /var/www/html/public.
When it is red 3
WARNING: [pool www] server reached pm.max_children setting (20), consider raising it
Cause. All PHP workers were busy; new requests waited in a queue.
Fix, step by step
- Raise pm.max_children only if RAM allows (about 30 MB each).
- Or scale: docker compose up -d --scale php=3.
When it is red 4
PHP code change does not show after editing src/public/index.php
Cause. opcache.validate_timestamps=0 and code is baked into the image.
Fix, step by step
- Rebuild: docker compose up -d --build php.
- On a laptop, set validate_timestamps=1 and bind mount ./src.
When it is red 5
nginx: [emerg] host not found in upstream "php"
Cause. nginx started without the php service on its network.
Fix, step by step
- Start the full stack with docker compose up.
- Check both services are in the same Compose project.
Practice tasks
Easy Task 1: FPM image
Why: Build.
What to do: Build coaching-php:1.0 and print the pool settings.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -f php/Dockerfile -t coaching-php:1.0 .
docker run --rm coaching-php:1.0 php-fpm -ttEasy Task 2: Config test
Why: nginx -t.
What to do: Break a semicolon in default.conf and see nginx -t fail.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm -v "$PWD/nginx/default.conf":/etc/nginx/conf.d/default.conf:ro --add-host php:127.0.0.1 nginx:1.27-alpine nginx -tEasy Task 3: Results
Why: App.
What to do: Add a third student in init.sql and list results.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose down -v && docker compose up -d --wait
curl -s localhost:8080/resultsMedium Task 4: Private
Why: Ports.
What to do: Prove that port 9000 and 3306 are not open on the host.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s localhost:9000 || echo closed
docker compose port php 9000 || echo not publishedMedium Task 5: Status
Why: FPM.
What to do: Watch active processes during a load test.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose exec nginx wget -qO- localhost/fpm-statusMedium Task 6: Security
Why: Dot files.
What to do: Show that /.env and /../db.php cannot be downloaded.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/.envMedium Task 7: Scale
Why: Workers.
What to do: Scale php to 3 and see three container IDs in /whoami.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose up -d --scale php=3
docker compose exec nginx nginx -s reloadHard Task 8: Tune
Why: Pool.
What to do: Set pm.max_children=2, load test, and find the warning in logs.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose logs php | grep max_childrenHard Task 9: Dev mode
Why: Live edits.
What to do: Make a compose.override.yaml that bind mounts ./src into php with validate_timestamps=1.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
services:
php:
volumes: ["./src:/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 lemp folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose down -v
docker image rm coaching-php:1.0Homework: try at home
Add Redis as a PHP session and cache store for the results page (install the redis extension with pecl in the PHP image), cache /results for 30 seconds, and prove with a load test that requests per second go up.
Recap
- LEMP is nginx + PHP-FPM + MySQL, each in its own container.
- nginx serves static files and sends .php to php:9000 with FastCGI.
- Both containers must see the code at the same path.
- Only nginx publishes a port.
- The FPM pool size limits RAM; scaling adds containers.
- Test nginx config with nginx -t before starting.
FAQ
Q. LAMP or LEMP: which should I pick?
LEMP for busy sites and modern frameworks; LAMP is simpler for old apps that need .htaccess.
Q. Does Laravel run this way?
Yes. Point root to Laravel's public/ folder and use the same fastcgi settings.
Q. Why not put nginx and PHP-FPM in one container?
One process per container is easier to scale, update and log. Some images do combine them with supervisord, but it is harder to run.
Q. How many PHP workers should I use?
Free RAM divided by the memory of one worker (check with docker stats).
Q. Can I use a Unix socket instead of port 9000?
Yes, with a shared volume, but TCP is simpler across containers.
Q. How do I add HTTPS?
Add certbot or use Traefik in front, as in Projects 4 and 58.7.
Interview questions
-
Explain how nginx and PHP-FPM work together.
Model answer: nginx gets the request, serves static files, and for .php sends a FastCGI request with SCRIPT_FILENAME to PHP-FPM, which runs the script and returns the output.
-
How do you troubleshoot a 502 in a LEMP stack?
Model answer: Check that PHP-FPM is running and healthy, the fastcgi_pass name and port, the network, and FPM logs for crashes or max_children.
-
How do you scale PHP in Docker?
Model answer: Scale the php service; Docker DNS returns all IPs and nginx spreads requests; tune pm.max_children per container.
-
Why put only the public folder under the web root?
Model answer: So config and code files can never be downloaded by URL.
-
What is opcache and why turn off timestamp checks in production?
Model answer: It caches compiled PHP; with no timestamp checks PHP skips file checks, which is faster, and new code comes with a new image.
Quick quiz
Q1. What does PHP-FPM do in LEMP?
It runs PHP code in a pool of workers and answers nginx over FastCGI on port 9000.
Q2. Why does nginx serve images itself?
It is much lighter than a PHP worker, so PHP is used only for .php requests.
Q3. Why must code paths match in nginx and php?
nginx sends SCRIPT_FILENAME; FPM opens that path inside its own container.
Q4. What does pm.max_children control?
The biggest number of PHP workers, which limits RAM use.
Q5. What does 502 Bad Gateway mean here?
nginx could not reach PHP-FPM.
Samajla ka?
Revision card
Interview card