Playing with Docker: NGINX, Apache, RabbitMQ, MailHog, MySQL/MariaDB
Set up a complete PHP development environment using Docker Compose — with NGINX, PHP-FPM, Apache, MySQL, MariaDB, Redis, MongoDB, RabbitMQ and MailHog, all wired together with healthchecks and ready to use.
Running docker compose up and having a fully working PHP development environment — with a web server, database, cache layer, message queue and email testing — in under two minutes. No global installs, no version conflicts, no "works on my machine". This guide walks through every service, explains what each one does and provides a production-grade docker-compose.yml with healthchecks included.
Why Docker for PHP development?
PHP applications typically depend on several external services: a web server, a database, a cache, maybe a queue. Installing and managing all of these locally — across Windows, macOS and Linux — leads to version drift and environment inconsistencies.
Docker solves this by packaging each service in an isolated container with a fixed version. The entire stack is defined in a single docker-compose.yml that every developer on the team runs identically.
docker compose down -v.Stack overview
| Service | Image | Purpose | Port(s) |
|---|---|---|---|
| PHP-FPM | php:8.3-fpm | PHP processor (used by NGINX) | 9000 (internal) |
| NGINX | nginx:alpine | HTTP server + reverse proxy to PHP-FPM | 8080 |
| Apache | php:8.3-apache | Alternative HTTP server with PHP built-in | 8081 |
| MySQL | mysql:8.0 | Relational database | 3306 |
| MariaDB | mariadb:11.4 | MySQL-compatible, community fork | 3307 |
| Redis | redis:7-alpine | Cache, sessions, rate limiting | 6379 |
| MongoDB | mongo:7 | NoSQL document store | 27017 |
| RabbitMQ | rabbitmq:3.13-management-alpine | Message queue + Management UI | 5672 / 15672 |
| MailHog | mailhog/mailhog | Local SMTP trap + Web UI | 1025 / 8025 |
Project structure
project/
├── docker-compose.yml
├── .env
├── src/
│ └── index.php ← your PHP application lives here
└── docker/
├── nginx/
│ └── default.conf ← NGINX virtual host
├── apache/
│ └── vhost.conf ← Apache virtual host
└── php/
└── php.ini ← custom PHP settings
The .env file
Keep credentials out of docker-compose.yml by using a .env file. Docker Compose loads it automatically.
# .env — never commit to version control
COMPOSE_PROJECT_NAME=phpdev
# MySQL / MariaDB
DB_ROOT_PASSWORD=rootpass
DB_NAME=dev_db
DB_USER=dev_user
DB_PASSWORD=dev_pass
# MongoDB
MONGO_ROOT_USER=root
MONGO_ROOT_PASSWORD=rootpass
# RabbitMQ
RABBITMQ_USER=dev_user
RABBITMQ_PASS=dev_pass
RABBITMQ_VHOST=dev_vhost
docker-compose.yml — full stack with healthchecks
# docker-compose.yml
# PHP development stack: NGINX + PHP-FPM, Apache, MySQL, MariaDB,
# Redis, MongoDB, RabbitMQ, MailHog
# Run: docker compose up -d
services:
# ── PHP-FPM ────────────────────────────────────────────────────
php:
image: php:8.3-fpm
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_php
restart: unless-stopped
volumes:
- ./src:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/custom.ini
networks:
- dev_network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "php-fpm -t 2>&1 | grep -q 'successful'"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# ── NGINX ──────────────────────────────────────────────────────
nginx:
image: nginx:alpine
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_nginx
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- dev_network
depends_on:
php:
condition: service_healthy
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
# ── Apache (alternative to NGINX — disable one or the other) ──
apache:
image: php:8.3-apache
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_apache
restart: unless-stopped
ports:
- "8081:80"
volumes:
- ./src:/var/www/html
- ./docker/apache/vhost.conf:/etc/apache2/sites-enabled/000-default.conf:ro
networks:
- dev_network
healthcheck:
test: ["CMD", "apache2ctl", "-t"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# ── MySQL ──────────────────────────────────────────────────────
mysql:
image: mysql:8.0
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
networks:
- dev_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1",
"-u", "root", "-p${DB_ROOT_PASSWORD}"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# ── MariaDB (MySQL-compatible alternative) ─────────────────────
mariadb:
image: mariadb:11.4
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_mariadb
restart: unless-stopped
ports:
- "3307:3306"
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
networks:
- dev_network
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# ── Redis ──────────────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- dev_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
# ── MongoDB ────────────────────────────────────────────────────
mongodb:
image: mongo:7
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_mongodb
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: ${DB_NAME}
volumes:
- mongodb_data:/data/db
networks:
- dev_network
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval",
"db.adminCommand('ping').ok"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# ── RabbitMQ ───────────────────────────────────────────────────
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_rabbitmq
restart: unless-stopped
ports:
- "5672:5672" # AMQP protocol
- "15672:15672" # Management UI
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
networks:
- dev_network
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# ── MailHog ────────────────────────────────────────────────────
mailhog:
image: mailhog/mailhog:latest
container_name: ${COMPOSE_PROJECT_NAME:-phpdev}_mailhog
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- dev_network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1",
"--spider", "http://localhost:8025"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
# ── Shared network ─────────────────────────────────────────────
networks:
dev_network:
driver: bridge
# ── Persistent volumes ─────────────────────────────────────────
volumes:
mysql_data:
mariadb_data:
redis_data:
mongodb_data:
rabbitmq_data:
Configuration files
NGINX virtual host — docker/nginx/default.conf
NGINX does not run PHP natively. It delegates .php requests to PHP-FPM over FastCGI.
server {
listen 80;
server_name localhost;
root /var/www/html;
index index.php index.html;
# Pretty URLs (Laravel, Symfony, etc.)
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM proxy
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 120;
}
# Deny .htaccess access
location ~ /\.ht {
deny all;
}
}
Apache virtual host — docker/apache/vhost.conf
Apache with the php:8.3-apache image runs PHP directly via mod_php — no separate FPM service needed.
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/html
<Directory /var/www/html>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
PHP settings — docker/php/php.ini
; docker/php/php.ini
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
max_execution_time = 120
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
; MailHog SMTP
[mail function]
sendmail_path = /usr/sbin/sendmail -S mailhog:1025
Service breakdown
NGINX + PHP-FPM
NGINX is the recommended web server for PHP in modern stacks. It handles static files itself (CSS, JS, images) and forwards PHP requests to the php container over FastCGI on port 9000. The two containers share the ./src volume so they both see the same files.
Access: http://localhost:8080
Apache
php:8.3-apache bundles Apache and PHP in a single image with mod_php enabled. Simpler to configure than NGINX+FPM, and .htaccess files work out of the box — useful if you’re maintaining a legacy codebase that relies on them.
Access: http://localhost:8081
MySQL 8.0
The most widely deployed open-source relational database. MySQL 8.0 brings significant performance improvements, native JSON support and utf8mb4 as the default charset. The mysql_data volume persists data between container restarts.
Connection string: mysql://dev_user:dev_pass@mysql:3306/dev_db
MariaDB 11.4
A fully MySQL-compatible community fork with additional storage engines, better performance on write-heavy workloads and a more open governance model. Runs on port 3307 to avoid conflicting with the MySQL container when both are active. The PHP PDO connection string is identical — just swap mysql for mariadb and 3306 for 3307.
Connection string: mysql://dev_user:dev_pass@mariadb:3306/dev_db
Redis
An in-memory key-value store most commonly used in PHP applications for:
- Session storage — faster than file or database sessions
- Application cache — full-page cache, query cache, object cache
- Rate limiting — sliding window counters
- Pub/Sub — lightweight messaging between processes
The --appendonly yes flag enables persistence so data survives container restarts. The --maxmemory-policy allkeys-lru evicts the least-recently-used keys when memory is full — ideal for a cache.
Connection: redis://redis:6379
MongoDB
A document-oriented NoSQL database. In a PHP stack it complements MySQL/MariaDB when your data is:
- Hierarchical or nested — product catalogs, CMS content
- Schema-less — event logs, user activity streams
- Rapidly evolving — prototypes where the shape of data changes frequently
Connection string: mongodb://root:rootpass@mongodb:27017/dev_db?authSource=admin
RabbitMQ
A message broker that decouples PHP processes from slow or asynchronous operations. Common use cases:
- Email dispatch — publish a job, consume it in a worker
- Image processing — upload triggers a resize queue
- Third-party API calls — don’t block the HTTP response
- Webhooks — retry logic for outbound events
The management plugin (included in the management-alpine image tag) provides a browser-based UI for monitoring queues, bindings and message rates.
Access: http://localhost:15672 → dev_user / dev_pass
AMQP: amqp://dev_user:dev_pass@rabbitmq:5672/dev_vhost
MailHog
A local SMTP server that catches all outgoing email and displays it in a web inbox — without actually delivering anything to a real recipient. Essential for development: you can test registration emails, password resets and notifications safely.
Configure PHP’s sendmail_path in php.ini to point to MailHog on port 1025 (done in the config above). All mail sent via PHP’s mail() function, PHPMailer or Symfony Mailer will appear in the UI instantly.
SMTP: mailhog:1025
Access: http://localhost:8025
Web interfaces at a glance
| Service | URL | Credentials |
|---|---|---|
| PHP app (NGINX) | localhost:8080 | — |
| PHP app (Apache) | localhost:8081 | — |
| RabbitMQ UI | localhost:15672 | dev_user / dev_pass |
| MailHog inbox | localhost:8025 | — |
localhost on the mapped port.
Using the services in PHP
MySQL / MariaDB with PDO
<?php
$pdo = new PDO(
dsn: 'mysql:host=mysql;port=3306;dbname=dev_db;charset=utf8mb4',
username: 'dev_user',
password: 'dev_pass',
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// For MariaDB: host=mariadb;port=3306 (same DSN format)
Redis with Predis
composer require predis/predis
<?php
use Predis\Client;
$redis = new Client([
'scheme' => 'tcp',
'host' => 'redis',
'port' => 6379,
]);
// Cache
$redis->setex('my_key', 3600, json_encode($data));
$cached = $redis->get('my_key');
// Session (set in php.ini instead)
// session.save_handler = redis
// session.save_path = "tcp://redis:6379"
MongoDB with the official driver
composer require mongodb/mongodb
<?php
use MongoDB\Client;
$mongo = new Client('mongodb://root:rootpass@mongodb:27017/?authSource=admin');
$db = $mongo->dev_db;
$coll = $db->events;
$coll->insertOne(['type' => 'page_view', 'url' => '/home', 'at' => new \DateTime()]);
$recent = $coll->find(['type' => 'page_view'], ['limit' => 10]);
RabbitMQ with php-amqplib
composer require php-amqplib/php-amqplib
<?php
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
// Publisher
$connection = new AMQPStreamConnection('rabbitmq', 5672, 'dev_user', 'dev_pass', 'dev_vhost');
$channel = $connection->channel();
$channel->queue_declare('emails', false, true, false, false);
$msg = new AMQPMessage(
json_encode(['to' => 'user@example.com', 'subject' => 'Welcome']),
['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
);
$channel->basic_publish($msg, '', 'emails');
$channel->close();
$connection->close();
<?php
// Consumer (run as a separate worker process)
$channel->basic_consume('emails', '', false, false, false, false,
function (AMQPMessage $msg) {
$data = json_decode($msg->body, true);
// send the email...
$msg->ack();
}
);
while ($channel->is_consuming()) {
$channel->wait();
}
Sending mail through MailHog
If sendmail_path is configured in php.ini (as shown above), mail() works automatically. For PHPMailer:
composer require phpmailer/phpmailer
<?php
use PHPMailer\PHPMailer\PHPMailer;
$mail = new PHPMailer();
$mail->isSMTP();
$mail->Host = 'mailhog';
$mail->Port = 1025;
$mail->SMTPAuth = false;
$mail->setFrom('app@dev.local', 'My App');
$mail->addAddress('user@example.com');
$mail->Subject = 'Test email';
$mail->Body = '<h1>Hello from Docker!</h1>';
$mail->isHTML(true);
$mail->send(); // visible at http://localhost:8025
Useful commands
# Start the stack
docker compose up -d
# Follow logs for all services
docker compose logs -f
# Follow logs for a single service
docker compose logs -f rabbitmq
# Check healthcheck status
docker compose ps
# Open a shell inside the PHP container
docker compose exec php bash
# Run a one-off PHP command
docker compose exec php php artisan migrate
# Stop and remove containers (keeps volumes)
docker compose down
# Stop and remove everything including volumes (wipes database data)
docker compose down -v
# Rebuild images after changing Dockerfile
docker compose up -d --build
A complete PHP dev environment in one file
The stack above covers the full surface area of a modern PHP application: HTTP serving, SQL persistence, caching, document storage, asynchronous messaging and email testing. Every service is containerised, versioned, healthchecked and isolated — with no side effects on the host machine.
Start with what your project actually needs. A typical Laravel app needs NGINX + PHP-FPM + MySQL + Redis. Add RabbitMQ when you introduce queued jobs, MongoDB if you need a document store, and MailHog from day one so email never silently fails in development. Scale up or down by commenting services in and out of docker-compose.yml.
The .env file keeps credentials out of version control, the healthchecks ensure correct startup order, and persistent volumes mean your data survives container restarts. docker compose up -d — and you're ready to build.
Categories