Ravindra BagaleCourses & study guides मराठी

Chapter 54: Dockerfile Examples for Node.js, Python Flask and Django, Spring Boot, .NET, Go, PHP, nginx, MySQL, Postgres and Redis

54.6 PHP-FPM with nginx Dockerfile (Laravel style)

Friends, PHP in containers is a little different. PHP-FPM runs the PHP code, but it does not speak HTTP to browsers. nginx stands in front and passes .php requests to FPM on port 9000. Today we build a Laravel-style app step by step: Composer in a build stage, PHP extensions, OPcache, two containers that share the public folder, and storage permissions done safely.

1. Start from a story

At Zomato, an internal partner dashboard was written in Laravel years ago. It ran on one old server with Apache, PHP and every extension anyone had ever installed.

When the team moved it to containers, the first image used php:8.3-apache, ran composer install with dev packages, and gave the storage folder full write access for everyone. Pages were slow because OPcache was off.

Uploads failed after a restart because storage lived inside the container, and a security review flagged the open permissions immediately.

Sneha, the DevOps engineer, rebuilt it the standard way: Composer in its own stage, php-fpm with only the needed extensions, OPcache on, nginx in a second container, and storage on a volume owned by www-data. Today we follow her steps.

BrowserGET /ordersnginx containerport 80, serves /public filesfastcgi_pass app:9000only .php requestsphp-fpm containerruns index.php with OPcacheMySQL / Redisby service name
Picture 1: request path

2. Build the PHP image

Step 1: The sample Laravel app

Why: Create it with Composer in a container.

What: We do not install PHP on the laptop. The composer image creates the project for us.

Example 1: Create project

Type this:

docker run --rm -v "$PWD":/app -w /app composer:2 create-project laravel/laravel partner --no-interaction -q && ls partner | head -5

You will see:

app
artisan
bootstrap
composer.json
composer.lock

What it means: A full Laravel skeleton.

Example 2: Add a route

Type this:

cd partner && printf '%s\n' '' 'Route::get("/health", fn() => response()->json(["ok" => true]));' >> routes/web.php && tail -1 routes/web.php

You will see:

Route::get("/health", fn() => response()->json(["ok" => true]));

What it means: A health route for checks.

Example 3: dockerignore

Type this:

printf '%s\n' 'vendor/' 'node_modules/' '.env' 'storage/logs/*' '.git' > .dockerignore && cat .dockerignore

You will see:

vendor/
node_modules/
.env
storage/logs/*
.git

What it means: Never copy the local .env or vendor folder.

Step 2: Composer in a build stage

Why: No Composer in production.

What: Copy composer.json and composer.lock first, install without dev packages, then copy the code and dump an optimised autoloader.

Example 4: Stage vendor

Type this:

printf '%s\n' '# syntax=docker/dockerfile:1' 'FROM composer:2 AS vendor' 'WORKDIR /app' 'COPY composer.json composer.lock ./' 'RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist' 'COPY . .' 'RUN composer dump-autoload --optimize --classmap-authoritative' > Dockerfile && wc -l Dockerfile

You will see:

7 Dockerfile

What it means: Lock file first for caching.

Example 5: Build stage only

Type this:

docker build -q --target vendor -t partner:vendor . && docker run --rm partner:vendor ls vendor | wc -l

You will see:

38

What it means: Production packages only.

Example 6: Dev packages gone

Type this:

docker run --rm partner:vendor sh -c 'ls vendor | grep -c phpunit'

You will see:

0

What it means: phpunit is a dev package; it is not shipped.

Step 3: php-fpm base and extensions

Why: Only what the app needs.

What: The official php:8.3-fpm image has a helper, docker-php-ext-install, to compile extensions like pdo_mysql and opcache.

Example 7: Runtime stage

Type this:

printf '%s\n' 'FROM php:8.3-fpm AS app' 'RUN docker-php-ext-install pdo_mysql opcache bcmath' 'WORKDIR /var/www/html' >> Dockerfile && grep ext-install Dockerfile

You will see:

RUN docker-php-ext-install pdo_mysql opcache bcmath

What it means: Three extensions, nothing else.

Example 8: Extensions that need libraries

Type this:

printf '%s\n' 'RUN apt-get update && apt-get install -y --no-install-recommends libzip-dev libpng-dev && docker-php-ext-install zip gd && rm -rf /var/lib/apt/lists/*'

You will see:

RUN apt-get update && apt-get install -y --no-install-recommends libzip-dev libpng-dev && docker-php-ext-install zip gd && rm -rf /var/lib/apt/lists/*

What it means: zip and gd need system libraries first.

Example 9: List modules

Type this:

docker build -q --target app -t partner:app . && docker run --rm partner:app php -m | grep -E 'pdo_mysql|Zend OPcache|bcmath'

You will see:

bcmath
pdo_mysql
Zend OPcache

What it means: Exactly what we asked for.

Step 4: Turn on OPcache

Why: Big speed win.

What: OPcache keeps compiled PHP in memory. In production we also stop checking file timestamps, because the code never changes inside an image.

Example 10: Config file

Type this:

printf '%s\n' 'opcache.enable=1' 'opcache.memory_consumption=128' 'opcache.max_accelerated_files=20000' 'opcache.validate_timestamps=0' > opcache.ini && printf '%s\n' 'COPY opcache.ini /usr/local/etc/php/conf.d/opcache.ini' >> Dockerfile && echo ok

You will see:

ok

What it means: validate_timestamps=0 is for production only.

Example 11: Production php.ini

Type this:

printf '%s\n' 'RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"' >> Dockerfile && tail -1 Dockerfile

You will see:

RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

What it means: Hides errors from users, logs them instead.

Example 12: Check

Type this:

docker build -q --target app -t partner:app . && docker run --rm partner:app php -i | grep -m1 validate_timestamps

You will see:

opcache.validate_timestamps => Off => Off

What it means: OPcache ready.

Step 5: Copy code and set ownership

Why: www-data owns only what it writes.

What: Copy the app from the vendor stage. Code stays owned by root and read-only to FPM. Only storage and bootstrap/cache belong to www-data.

Example 13: Copy and chown

Type this:

printf '%s\n' 'COPY --from=vendor /app /var/www/html' 'RUN chown -R www-data:www-data storage bootstrap/cache && chmod -R 0755 storage bootstrap/cache' 'USER www-data' >> Dockerfile && tail -2 Dockerfile

You will see:

RUN chown -R www-data:www-data storage bootstrap/cache && chmod -R 0755 storage bootstrap/cache
USER www-data

What it means: Never open the folders to everyone.

Example 14: Who runs FPM

Type this:

docker build -q --target app -t partner:app . && docker run -d --name fpm partner:app && sleep 2 && docker exec fpm ps -o user,comm | head -3

You will see:

USER     COMMAND
www-data php-fpm
www-data php-fpm

What it means: Master and workers as www-data.

Example 15: Can it write logs?

Type this:

docker exec fpm sh -c 'touch storage/logs/test.log && echo writable'

You will see:

writable

What it means: Only where it needs to.

Step 6: Laravel caches at startup

Why: Faster boot, config from env.

What: config:cache reads environment variables, so run it when the container starts, not at build time. A small entrypoint script does it and then starts FPM.

Example 16: Entrypoint

Type this:

printf '%s\n' '#!/bin/sh' 'set -e' 'php artisan config:cache' 'php artisan route:cache' 'php artisan view:cache' 'exec php-fpm' > docker-entrypoint.sh && chmod 0755 docker-entrypoint.sh && cat docker-entrypoint.sh | tail -1

You will see:

exec php-fpm

What it means: exec makes php-fpm PID 1.

Example 17: Use it

Type this:

printf '%s\n' 'COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/' 'ENTRYPOINT ["docker-entrypoint.sh"]' >> Dockerfile && tail -1 Dockerfile

You will see:

ENTRYPOINT ["docker-entrypoint.sh"]

What it means: Runs on every start.

Example 18: Logs

Type this:

docker build -q --target app -t partner:app . && docker rm -f fpm && docker run -d -e APP_KEY=base64:ZXhhbXBsZS1vbmx5LWtleS1jaGFuZ2UtbWUtMzJieXQ= --name fpm partner:app && sleep 3 && docker logs fpm 2>&1 | head -3

You will see:

INFO  Configuration cached successfully.
INFO  Routes cached successfully.
NOTICE: fpm is running, pid 1

What it means: The APP_KEY here is an example only.

3. nginx in front, Compose for the stack

nginx imagecopies /public from the app stageStatic filescss, js, images served by nginx*.phpfastcgi_pass app:9000php-fpm imagecode + vendor, user www-datastorage volumeuploads survive restarts
Picture 2: two containers, one app

Step 7: nginx config for FPM

Why: The glue.

What: nginx serves files from public and sends anything else to index.php through FastCGI to the app service on port 9000.

Example 19: default.conf

Type this:

printf '%s\n' 'server {' '  listen 8080;' '  root /var/www/html/public;' '  index index.php;' '  location / { try_files $uri $uri/ /index.php?$query_string; }' '  location ~ \.php$ {' '    include fastcgi_params;' '    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;' '    fastcgi_pass app:9000;' '  }' '}' > nginx.conf && grep fastcgi_pass nginx.conf

You will see:

fastcgi_pass app:9000;

What it means: app is the Compose service name.

Example 20: Test syntax

Type this:

docker run --rm -v "$PWD/nginx.conf":/etc/nginx/conf.d/default.conf:ro --add-host app:127.0.0.1 nginx:1.27 nginx -t

You will see:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

What it means: Always test before deploying.

Example 21: Why 8080

Type this:

printf 'We will run nginx as a non-root user, which cannot bind port 80.\n'

You will see:

We will run nginx as a non-root user, which cannot bind port 80.

What it means: Map 80:8080 on the host.

Step 8: An nginx stage with public files

Why: nginx needs the static files too.

What: Add a third stage that starts from nginx-unprivileged and copies only the public folder and the config.

Example 22: Stage web

Type this:

printf '%s\n' 'FROM nginxinc/nginx-unprivileged:1.27 AS web' 'COPY nginx.conf /etc/nginx/conf.d/default.conf' 'COPY --from=vendor /app/public /var/www/html/public' >> Dockerfile && grep -c '^FROM' Dockerfile

You will see:

3

What it means: vendor, app and web stages.

Example 23: Build both targets

Type this:

docker build -q --target app -t partner-app:1.0 . && docker build -q --target web -t partner-web:1.0 . && docker images 'partner-*' --format '{{.Repository}} {{.Size}}'

You will see:

partner-app 168MB
partner-web 54MB

What it means: Two images from one Dockerfile.

Example 24: nginx user

Type this:

docker run --rm partner-web:1.0 id -un

You will see:

nginx

What it means: Unprivileged image runs as nginx.

Step 9: compose.yaml for the stack

Why: Everything together.

What: app, web and mysql. Storage on a named volume. The password comes from a secret file with example content.

Example 25: Write it

Type this:

printf '%s\n' 'services:' '  app:' '    build: {context: ., target: app}' '    environment: {DB_HOST: db, DB_DATABASE: partner, DB_USERNAME: partner, APP_KEY: base64:ZXhhbXBsZS1vbmx5LWtleS1jaGFuZ2UtbWUtMzJieXQ=}' '    volumes: [storage:/var/www/html/storage/app]' '  web:' '    build: {context: ., target: web}' '    ports: ["80:8080"]' '    depends_on: [app]' '  db:' '    image: mysql:8.4' '    environment: {MYSQL_DATABASE: partner, MYSQL_USER: partner, MYSQL_PASSWORD_FILE: /run/secrets/dbpass, MYSQL_RANDOM_ROOT_PASSWORD: "yes"}' '    secrets: [dbpass]' 'secrets: {dbpass: {file: ./dbpass.txt}}' 'volumes: {storage: {}}' > compose.yaml && printf 'example-only-pass' > dbpass.txt && chmod 600 dbpass.txt && docker compose config -q && echo valid

You will see:

valid

What it means: Example secret value only.

Example 26: Up

Type this:

docker compose up -d --build 2>&1 | tail -3

You will see:

✔ Container partner-db-1   Started
✔ Container partner-app-1  Started
✔ Container partner-web-1  Started

What it means: Three containers.

Example 27: Test

Type this:

curl -s localhost/health && curl -sI localhost/favicon.ico | head -1

You will see:

{"ok":true}
HTTP/1.1 200 OK

What it means: PHP through FPM, static straight from nginx.

Step 10: Migrations and queue workers

Why: Same image, different commands.

What: Use the app image for one-off migrations and for long-running queue workers. Only the command changes.

Example 28: Migrate once

Type this:

docker compose run --rm app php artisan migrate --force

You will see:

INFO  Running migrations.
  2014_10_12_000000_create_users_table ...... DONE

What it means: --force is needed in production mode.

Example 29: Worker service

Type this:

printf '%s\n' '  worker:' '    build: {context: ., target: app}' '    entrypoint: ["php","artisan","queue:work","--tries=3"]' > worker.part && sed -i '/^  web:/e cat worker.part' compose.yaml && docker compose up -d worker && docker compose ps worker --format '{{.Status}}'

You will see:

Up 3 seconds

What it means: Same image, a different entrypoint.

Example 30: Scheduler

Type this:

printf '%s\n' 'entrypoint: ["php","artisan","schedule:work"]'

You will see:

entrypoint: ["php","artisan","schedule:work"]

What it means: Replaces the old server cron.

Step 11: Logs to stdout

Why: See errors with docker logs.

What: Laravel writes to storage/logs by default. In containers send logs to stderr so docker logs and your log tools see them.

Example 31: Set channel

Type this:

docker compose exec app printenv LOG_CHANNEL || echo not-set

You will see:

not-set

What it means: Default is a file.

Example 32: Use stderr

Type this:

sed -i 's#APP_KEY:#LOG_CHANNEL: stderr, APP_KEY:#' compose.yaml && docker compose up -d app && docker compose exec app printenv LOG_CHANNEL

You will see:

stderr

What it means: One environment variable.

Example 33: See an error

Type this:

curl -s localhost/missing >/dev/null; docker compose logs app --tail 2

You will see:

app-1  | 172.18.0.4 -  25/Oct/2026:10:41:02 +0530 "GET /index.php" 404

What it means: Logs flow to Docker now.

Step 12: Healthchecks for FPM and nginx

Why: Know both halves are alive.

What: FPM has a built-in ping page. Turn it on, and check it with the cgi-fcgi tool or simply check the app through nginx.

Example 34: Enable ping

Type this:

printf '%s\n' '[www]' 'ping.path = /ping' 'pm.status_path = /status' > zz-health.conf && printf '%s\n' 'COPY zz-health.conf /usr/local/etc/php-fpm.d/zz-health.conf' > hc.part && sed -i '/^USER www-data/e cat hc.part' Dockerfile && grep zz-health Dockerfile

You will see:

COPY zz-health.conf /usr/local/etc/php-fpm.d/zz-health.conf

What it means: FPM now answers /ping with pong.

Example 35: App healthcheck in Compose

Type this:

printf '%s\n' '    healthcheck:' '      test: ["CMD-SHELL", "php-fpm -t 2>&1 | grep -q successful"]' '      interval: 15s'

You will see:

healthcheck:
  test: ["CMD-SHELL", "php-fpm -t 2>&1 | grep -q successful"]
  interval: 15s

What it means: A simple config test that needs no extra tools.

Example 36: Web healthcheck

Type this:

docker compose exec web sh -c 'wget -qO- localhost:8080/health'

You will see:

{"ok":true}

What it means: Checks nginx, FPM and Laravel in one call.

4. Common mistakes and how Sneha avoids them

Mistake one: Composer and dev packages in production. Sneha uses a vendor stage with --no-dev.

Mistake two: OPcache off. She enables it with validate_timestamps=0.

Mistake three: opening storage to everyone. She gives it to www-data with 0755 and keeps the code root-owned.

Mistake four: config:cache at build time, which freezes the wrong environment. She runs it in the entrypoint.

Mistake five: storage inside the container. She mounts a named volume.

Check yourself

Q1. Why does PHP-FPM need nginx?

FPM speaks FastCGI, not HTTP; nginx handles the browser and static files.

Q2. Which port does FPM listen on?

9000.

Q3. Why validate_timestamps=0?

Code never changes inside an image, so skip file checks for speed.

Q4. Where should config:cache run?

At container start, so it uses the real environment.

Friends, Composer in its own stage, php-fpm with only needed extensions, OPcache on, nginx in front, www-data owning only storage, and caches built at start. Did you understand? Let us try it once more. Run the stack and hit one PHP route and one static file.

Ravindra Bagale's Tip

If you see a 502 Bad Gateway from nginx, check fastcgi_pass first. The service name and port 9000 must match the FPM container.

Lab: try at home

  1. Create the Laravel app with the composer image.
  2. Write the vendor stage with --no-dev.
  3. Add php-fpm with pdo_mysql and OPcache.
  4. Write nginx.conf and test it with nginx -t.
  5. Bring up app, web and db with Compose.
  6. Run migrate as a one-off and add a queue worker.

Recap

  • Composer runs only in the build stage.
  • php-fpm image with just the extensions you need.
  • OPcache on, production php.ini.
  • nginx serves public files and passes PHP to app:9000.
  • www-data owns only storage and bootstrap/cache.
  • Same image for web, migrate and workers.

Ravi Sir's Cards for this lesson

मराठी cards