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.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.
Build a PHP-FPM image. Write the nginx config. Compose nginx + php + MySQL. Run the PHP app with PDO. Add the FPM status page. Scale PHP workers. Clean up.
LEMP: nginx serves static files and passes PHP to FPM

How: step by step

  1. Build a PHP-FPM image. pdo_mysql, opcache, pool settings.
  2. Write the nginx config. Static files and fastcgi_pass php:9000.
  3. Compose nginx + php + MySQL. Shared code, health checks.
  4. Run the PHP app with PDO. Tests and results.
  5. Add the FPM status page. See busy and idle workers.
  6. Scale PHP workers. --scale php=3.
  7. Clean up. down -v.

Architecture

Browser :8080 nginx 1.27 (static files, cache 7d) fastcgi_pass php:9000 php: PHP-FPM 8.3 pool (scale to 3) PDO to db:3306 db: MySQL 8.4 (db-data volume)
LEMP stack 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)

  1. Make folders

    mkdir -p lemp/php lemp/nginx lemp/src/public && cd lemp
    

    What it does: Folders for the PHP image, nginx config and code.

    (no output)
    
  2. Add files

    find . -type f | sort
    

    What 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
    
  3. 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 -t
    

    What it does: Finds typos before start.

    syntax is ok
    
  4. Build and start

    docker compose up -d --build --wait
    

    What it does: Builds coaching-php:1.0 and starts all three.

    Container lemp-nginx-1 Started
    
  5. Get results

    curl -s localhost:8080/results
    

    What it does: PHP reads MySQL through PDO.

    [{"roll_no":"KOP101",...}]
    
  6. One roll number

    curl -s localhost:8080/results/KOP101
    

    What it does: Front controller route.

    {"roll_no":"KOP101","name":"Pooja Shinde","score":182}
    
  7. Hidden file blocked

    curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/.env
    

    What it does: nginx denies dot files.

    403
    
  8. Static file

    curl -sI localhost:8080/style.css | head -1
    

    What it does: Served by nginx alone.

    HTTP/1.1 200 OK
    
  9. FPM status

    docker compose exec nginx wget -qO- localhost/fpm-status
    

    What it does: Shows active and idle workers.

    active processes: 1
    
  10. Load test

    docker run --rm --network host williamyeh/hey -n 3000 -c 50 http://localhost:8080/results
    

    What it does: Result-week traffic.

    [200] 3000 responses
    
  11. Scale PHP

    docker compose up -d --scale php=3 && docker compose exec nginx nginx -s reload
    

    What it does: Three FPM containers behind one nginx.

    Container lemp-php-3 Started
    
  12. See logs

    docker compose logs php --tail 5
    

    What it does: FPM and PHP errors from all three containers.

    php-1  | NOTICE: ready to handle connections
    
  13. Clean up

    docker compose down -v
    docker image rm coaching-php:1.0
    

    What 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

  1. docker compose ps php and docker compose logs php.
  2. 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

  1. Use the same path /var/www/html/public in both containers.
  2. 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

  1. Raise pm.max_children only if RAM allows (about 30 MB each).
  2. 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

  1. Rebuild: docker compose up -d --build php.
  2. 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

  1. Start the full stack with docker compose up.
  2. 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.

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

Easy Task 2: Config test

Why: nginx -t.

What to do: Break a semicolon in default.conf and see nginx -t fail.

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

Easy Task 3: Results

Why: App.

What to do: Add a third student in init.sql and list results.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lemp 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
curl -s localhost:8080/results

Medium Task 4: Private

Why: Ports.

What to do: Prove that port 9000 and 3306 are not open on the host.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lemp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
curl -s localhost:9000 || echo closed
docker compose port php 9000 || echo not published

Medium Task 5: Status

Why: FPM.

What to do: Watch active processes during a load test.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lemp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose exec nginx wget -qO- localhost/fpm-status

Medium Task 6: Security

Why: Dot files.

What to do: Show that /.env and /../db.php cannot be downloaded.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lemp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/.env

Medium Task 7: Scale

Why: Workers.

What to do: Scale php to 3 and see three container IDs in /whoami.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the lemp folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose up -d --scale php=3
docker compose exec nginx nginx -s reload

Hard Task 8: Tune

Why: Pool.

What to do: Set pm.max_children=2, load test, and find the warning in logs.

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

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

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

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

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

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

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

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

  4. Why put only the public folder under the web root?

    Model answer: So config and code files can never be downloaded by URL.

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

Ravi Sir's Cards for this lesson

मराठी cards