A PHP-FPM and Nginx setup separates web serving from PHP execution, which more closely mirrors how most production PHP applications are deployed than the Apache-with-mod-php approach. Nginx handles static files and proxies dynamic requests to PHP-FPM over FastCGI. In Docker, each concern runs in its own container: Nginx, PHP-FPM, and MySQL.
This guide builds that setup with Docker Compose, covering the Dockerfile, Nginx configuration, Compose file, environment variable handling, and database connectivity.
What this covers:
Project structure for a PHP-FPM Nginx setup
Writing the PHP-FPM Dockerfile
Nginx FastCGI configuration
Docker Compose for PHP, Nginx, and MySQL
Connecting PHP to MySQL using the service name as host
Environment variables with a
.envfileUseful commands for managing the environment
Step 1: Verify Docker Is Installed
docker --version
docker compose versionInstall Docker Desktop from docker.com if either command fails. Docker Desktop includes both the daemon and the Compose plugin.
Step 2: Set Up the Project Structure
mkdir php-docker-app && cd php-docker-appCreate the following structure:
php-docker-app/
├── src/
│ └── index.php
├── Dockerfile
├── docker-compose.yml
├── nginx.conf
└── .envAdd a test file to confirm the setup works:
<?php
// src/index.php
phpinfo();Step 3: Write the Dockerfile for PHP-FPM
FROM php:8.2-fpm
# Install extensions for MySQL access
RUN docker-php-ext-install pdo pdo_mysql mysqli
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY ./src /var/www/htmlPHP-FPM (php:8.2-fpm) runs as a process manager that listens for FastCGI connections. Nginx forwards PHP requests to it over the network rather than running PHP itself. The pdo, pdo_mysql, and mysqli extensions cover both PDO-based and procedural MySQL access.
Step 4: Configure Nginx
Create nginx.conf:
server {
listen 80;
server_name localhost;
root /var/www/html;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.ht {
deny all;
}
}fastcgi_pass php:9000 routes PHP requests to the PHP-FPM container. Inside Docker Compose, containers reach each other by service name, so php resolves to the PHP-FPM container's IP. Port 9000 is the default PHP-FPM port.
The location ~ /\.ht block denies access to .htaccess files, which are Apache-specific and should not be served directly.
The try_files directive is updated from the original $uri /index.php?$query_string to $uri $uri/ /index.php?$query_string to handle directory requests correctly, which matters for frameworks that expect directory-style URLs.
Step 5: Write the Docker Compose File
Create docker-compose.yml:
services:
php:
build: .
volumes:
- ./src:/var/www/html
networks:
- app-network
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./src:/var/www/html
ports:
- "8080:80"
depends_on:
- php
networks:
- app-network
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- db_data:/var/lib/mysql
networks:
- app-network
volumes:
db_data:
networks:
app-network:
driver: bridgeA few deliberate choices:
nginx:alpine uses the Alpine-based Nginx image, which is smaller than nginx:latest (based on Debian). For local development the difference is minor, but it is a better habit.
restart: unless-stopped on the MySQL service restarts the container automatically if it crashes or if the Docker daemon restarts, but not if the container is explicitly stopped with docker compose stop.
The MySQL credentials reference ${VARIABLE_NAME} from the .env file rather than hardcoded values.
The named volume db_data persists the MySQL data between container restarts. Without it, the database is lost every time the container stops.
The version field at the top of Compose files is deprecated in current Docker Compose versions and is omitted here.
Step 6: Create the .env File
Create .env in the project root:
MYSQL_ROOT_PASSWORD=rootpassword
MYSQL_DATABASE=app_db
MYSQL_USER=appuser
MYSQL_PASSWORD=apppasswordAdd .env to .gitignore and commit a .env.example with placeholder values:
# .env.example
MYSQL_ROOT_PASSWORD=
MYSQL_DATABASE=
MYSQL_USER=
MYSQL_PASSWORD=Docker Compose reads the .env file automatically when it is in the same directory as docker-compose.yml. The ${MYSQL_ROOT_PASSWORD} references in the Compose file expand to the values in .env.
Step 7: Build and Start the Environment
docker compose up -d --buildThe first run builds the PHP image and pulls Nginx and MySQL. Visit http://localhost:8080 to confirm Nginx is serving the PHP info page.
Useful commands:
# Follow logs for all services
docker compose logs -f
# Follow logs for a specific service
docker compose logs -f nginx
# Open a shell in the PHP container
docker exec -it $(docker compose ps -q php) sh
# Stop containers (preserves volumes)
docker compose stop
# Remove containers (preserves volumes)
docker compose down
# Remove containers and volumes (deletes the database)
docker compose down -vStep 8: Connect PHP to MySQL
Inside Docker Compose, the MySQL host is the service name mysql, not localhost. Update src/index.php:
<?php
$host = getenv('MYSQL_HOST') ?: 'mysql';
$dbname = getenv('MYSQL_DATABASE');
$user = getenv('MYSQL_USER');
$pass = getenv('MYSQL_PASSWORD');
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo "Connected to database: $dbname";
} catch (PDOException $e) {
echo "Connection failed: " . $e->getMessage();
}utf8mb4 is the correct charset for full Unicode support, including emoji. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION converts PDO errors into exceptions, which is the recommended setting for both development and production.
The environment variables (MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD) passed to the MySQL container are also available to PHP if the php service is given matching environment entries in docker-compose.yml:
php:
build: .
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./src:/var/www/html
networks:
- app-networkMySQL startup timing: MySQL takes several seconds to initialize on first start. PHP may attempt the connection before MySQL is ready. Add a healthcheck to the mysql service and a condition: service_healthy to depends_on to prevent this:
mysql:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
php:
depends_on:
mysql:
condition: service_healthyCommon Issues
Port 8080 is already in use. Change "8080:80" to another host port, such as "8081:80".
MySQL connection refused. The most common cause is MySQL not having finished initializing. Add the healthcheck described above. docker compose logs mysql shows the initialization progress.
Nginx 502 Bad Gateway. PHP-FPM is not running or not reachable. Run docker compose logs php to check for PHP startup errors. Confirm fastcgi_pass php:9000 in nginx.conf matches the service name in docker-compose.yml.
Code changes not reflected. The volume mount (./src:/var/www/html) should reflect changes immediately. If a change is not visible, confirm the file was saved and try a hard refresh in the browser. PHP-FPM does not need to be restarted for PHP file changes.
Key Takeaways
PHP-FPM and Nginx run in separate containers. Nginx forwards PHP requests to PHP-FPM via FastCGI using the service name as the host (
fastcgi_pass php:9000).Use
nginx:alpinefor a smaller Nginx image.Named volumes (
db_data) persist the MySQL database between container restarts.docker compose down -vremoves them.The
.envfile holds credentials. Docker Compose reads it automatically. Never commit it.The MySQL host from PHP is the Compose service name
mysql, notlocalhost.Add a MySQL healthcheck and
condition: service_healthyto prevent the PHP container from starting before MySQL is ready.
Conclusion
A PHP-FPM and Nginx Docker setup takes a few more configuration files than the Apache equivalent, but produces an environment that more closely reflects how most PHP production deployments are structured. The separation of Nginx and PHP-FPM also makes each service independently configurable and replaceable.
Once this foundation is in place, adding services — Redis for caching, a queue worker, a search engine — follows the same pattern: a new entry in docker-compose.yml, a network connection, and any required environment variables.
Running into a specific Nginx or PHP-FPM configuration issue with this setup? Share the error in the comments.




