← All writing
DevOps · 21 July 2026 · 3 min read

Docker and Nginx for a PHP team: a local environment that behaves like production

A Compose set-up for Laravel with PHP-FPM, Nginx, MySQL and Redis, plus the details that cause most "works on my machine" bugs: versions, extensions, permissions and Windows file performance.
Imron Athoriq Firjatulloh
Backend Engineer · PT Serasi Autoraya (Astra International)
"It works on my machine" usually means "my machine has a different PHP version, a different MySQL mode and an extension the server does not". When I worked on backend features as an intern, a shared Docker and Nginx set-up removed a whole category of those bugs. Here is the set-up I would give any PHP team today.

Mirror production, not a tutorial

Before writing a Compose file, write down what production runs: PHP version and extensions, web server, database engine and version, cache, and any php.ini settings that differ from defaults (memory limit, upload size, timezone). The local environment should match those exactly. A MySQL 5.7 laptop and a MySQL 8 server will disagree about something, and it will be on a Friday.

The Compose file

yaml
services: app: build: ./docker/php # php:8.3-fpm + the extensions production has volumes: - ./:/var/www/html environment: DB_HOST: mysql REDIS_HOST: redis depends_on: [mysql, redis] nginx: image: nginx:1.27-alpine ports: ["8080:80"] volumes: - ./:/var/www/html:ro - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro depends_on: [app] mysql: image: mysql:8.0 # same major/minor as production environment: MYSQL_DATABASE: app MYSQL_ROOT_PASSWORD: secret volumes: [mysql-data:/var/lib/mysql] redis: image: redis:7-alpine volumes: mysql-data:
And the PHP image, with extensions declared in code rather than remembered:
dockerfile
FROM php:8.3-fpm RUN apt-get update && apt-get install -y libzip-dev libpng-dev \ && docker-php-ext-install pdo_mysql zip gd bcmath opcache \ && pecl install redis && docker-php-ext-enable redis COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY php.ini /usr/local/etc/php/conf.d/app.ini

Nginx in front of PHP-FPM

nginx
server { listen 80; root /var/www/html/public; index index.php; client_max_body_size 20m; # match production uploads location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass app:9000; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 60s; } location ~ /\.(?!well-known) { deny all; } # never serve .env or .git }
Two lines here prevent real incidents: root points at public/, not the project root, and hidden files are denied. A misconfigured server that serves .env hands out your database password.

The details that bite

▸
File permissions. PHP-FPM runs as www-data; your files belong to you. Laravel needs to write to storage/ and bootstrap/cache/. Run the container with your user ID, or fix ownership of those two folders only.
▸
Windows performance. Bind-mounting a project from the Windows file system into Linux containers is slow. Keep the code inside WSL 2 and run Docker from there; the difference is often several times faster page loads.
▸
Hostnames. Inside Compose, the database host is mysql (the service name), not 127.0.0.1. Most first-day connection errors are this.
▸
SQL modes. MySQL 8 enables ONLY_FULL_GROUP_BY and strict mode. If production has them, local must too, or queries that fail in production pass locally.
▸
Queues and schedulers. Add a queue service running php artisan queue:work from the same image, so jobs run locally the way they do on the server.

One command to start

A Makefile or a short README section with docker compose up -d, composer install and php artisan migrate --seed turns onboarding from a day into an hour, which is the real return on all of this.
The same idea scales down: my Auction Platform API ships a Dockerfile and a Compose file with Nginx in front, so anyone can run exactly what CI tests.
© 2026 Imron Athoriq Firjatulloh · Built with React Native & Expo
Home →