Docker Dev Environment (Part 3): PHP-FPM, Multiple Databases & Dev Tools
In Part 1 we designed the architecture. In Part 2 we configured the Nginx reverse proxy. Now let's build the backend: PHP-FPM containers, databases, and dev tools.
PHP-FPM Containers
Why PHP-FPM, Not Apache?
| Apache + mod_php | PHP-FPM | |
|---|---|---|
| Architecture | PHP runs inside Apache | PHP runs as a separate process |
| Resource usage | Heavier (Apache + PHP per request) | Lighter (Nginx serves static files, PHP only handles .php) |
| Scalability | Scale = more Apache instances | Scale PHP independently from web server |
| Multiple versions | Need multiple Apache installs | Just add another FPM container |
In our setup, Nginx handles all HTTP (static files, routing) and delegates PHP execution to separate FPM containers via FastCGI. This clean separation is why we can run 4 PHP versions without conflicts.
Dockerfile-8.4
FROM php:8.4-fpm
# ─── System ──────────────────────────────────────────
RUN apt-get update -y
# Timezone
RUN apt-get install -y tzdata
ENV TZ Asia/Tokyo
# Linux tools (useful for debugging inside the container)
RUN apt-get install -y curl htop procps tmux vim cron
# ─── PHP Extensions ─────────────────────────────────
# Image processing dependencies
RUN apt-get install -y \
libfreetype-dev \
libjpeg62-turbo-dev \
libpng-dev \
libzip-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd
# Use the community extension installer for everything else
RUN curl -sSLf \
-o /usr/local/bin/install-php-extensions \
https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions && \
chmod +x /usr/local/bin/install-php-extensions
RUN install-php-extensions \
calendar \
exif \
gd \
imagick \
intl \
memcached \
mysqli \
pdo \
pdo_mysql \
pdo_pgsql \
pdo_sqlite \
redis \
soap \
xdebug \
zip \
ftp \
@composer
# ─── Composer & Laravel ──────────────────────────────
RUN composer global require laravel/installer
# ─── PHP Config ──────────────────────────────────────
COPY ./custom.ini /usr/local/etc/php/conf.d/
# Allow .html files to be processed by PHP-FPM
RUN echo "security.limit_extensions = .php .html" >> /usr/local/etc/php-fpm.d/www.conf
Key Design Decisions
install-php-extensions tool: Instead of manually compiling each extension (which is painful and error-prone), we use the community extension installer. It handles all dependencies automatically.
@composer: The @ prefix installs Composer itself as a global tool.
security.limit_extensions: By default, PHP-FPM only processes .php files. Adding .html allows you to use PHP inside HTML files if needed.
custom.ini — PHP Configuration
file_uploads = On
upload_max_filesize = 2000M
post_max_size = 2000M
memory_limit = 2000M
max_execution_time = 3000
; Custom error reporting
error_reporting = E_ALL & ~E_NOTICE & ~E_DEPRECATED
These are development-friendly settings. Large upload limits and long execution times are fine for local development (you'd never use these values in production).
Multi-Version Strategy
Each PHP version has its own Dockerfile with the same structure but different base image:
Dockerfile-8.4 → FROM php:8.4-fpm
Dockerfile-8.2 → FROM php:8.2-fpm
Dockerfile-7.4 → FROM php:7.4-fpm
Dockerfile-5.6 → FROM php:5.6-fpm
The extension list may vary slightly between versions (some extensions aren't available for older PHP versions), but the pattern is identical.
How to Use a Specific PHP Version
In your Nginx vhost config, just change the fastcgi_pass target:
# Modern app → PHP 8.4
fastcgi_pass php84:9000;
# Legacy app → PHP 7.4
fastcgi_pass php74:9000;
Running Artisan, Composer, etc.
Since PHP runs inside the container, you need to exec into it:
# Enter the PHP 8.4 container
make bash n=php84
# Now you're inside the container at /var/www/html
cd blog.md
php artisan migrate
composer install
php artisan test
Or run one-off commands without entering the container:
docker exec php84 bash -c "cd /var/www/html/blog.md && php artisan migrate"
Multiple MySQL Versions
Why Multiple Versions?
Real-world scenario: your new project uses MySQL 8.4, but a client's legacy project is still on MySQL 5.7 with features deprecated in 8.x. Running both simultaneously avoids compatibility issues.
docker-compose.yml — Database Services
mysql-5.7:
restart: unless-stopped
container_name: "mysql-5.7"
environment:
MYSQL_ROOT_PASSWORD: ${AIO_PASSWORD}
build:
context: ./tools/mysql-5.7
dockerfile: Dockerfile
volumes:
- ./httpdocs:/data:delegated # Access to project files
- mysql-5.7-data:/var/lib/mysql:delegated # Persistent data
networks:
- proxynet
ports:
- 3306:3306 # Accessible from host at localhost:3306
mysql-8.0:
restart: unless-stopped
container_name: "mysql-8.0"
environment:
MYSQL_ROOT_PASSWORD: ${AIO_PASSWORD}
build:
context: ./tools/mysql-8.0
dockerfile: Dockerfile
volumes:
- ./httpdocs:/data:delegated
- mysql-8.0-data:/var/lib/mysql:delegated
networks:
- proxynet
ports:
- 3308:3306 # Accessible from host at localhost:3308
mysql-8.4:
restart: unless-stopped
container_name: "mysql-8.4"
environment:
MYSQL_ROOT_PASSWORD: ${AIO_PASSWORD}
build:
context: ./tools/mysql-8.4
dockerfile: Dockerfile
volumes:
- ./httpdocs:/data:delegated
- mysql-8.4-data:/var/lib/mysql:delegated
networks:
- proxynet
ports:
- 3309:3306 # Accessible from host at localhost:3309
Port Mapping Strategy
| Service | Container Port | Host Port | Connection from PHP |
|---|---|---|---|
| mysql-5.7 | 3306 | 3306 | mysql-5.7:3306 |
| mysql-8.0 | 3306 | 3308 | mysql-8.0:3306 |
| mysql-8.4 | 3306 | 3309 | mysql-8.4:3306 |
From PHP containers (inside Docker network): Use the container name and port 3306.
From the host machine (TablePlus, DBeaver): Use localhost and the mapped host port.
Laravel .env Configuration
# For a project using MySQL 8.4
DB_CONNECTION=mysql
DB_HOST=mysql-8.4 # Container name, not localhost!
DB_PORT=3306 # Internal port, not the mapped one
DB_DATABASE=my_app
DB_USERNAME=root
DB_PASSWORD=your_password
Common mistake: Using
localhostor127.0.0.1asDB_HOSTin Laravel. Inside a Docker container,localhostrefers to the container itself, not the host machine. Always use the container name.
Persistent Data with Named Volumes
volumes:
mysql-5.7-data:
mysql-8.0-data:
mysql-8.4-data:
Named volumes survive container rebuilds. You can make reup n=mysql-5.7 all day and your data is safe. To actually delete the data:
docker volume rm docker-starfish_mysql-5.7-data
Dev Tools
Adminer — Database Management
adminer:
restart: unless-stopped
image: adminer
container_name: "adminer"
environment:
ADMINER_DEFAULT_SERVER: mysql-5.7
networks:
- proxynet
Access via http://adminer.dev.local. The proxy routes it automatically through the map directive in default.conf.
To connect to different MySQL versions from Adminer:
- Server:
mysql-5.7,mysql-8.0, ormysql-8.4 - Username:
root - Password: your
AIO_PASSWORDfrom.env
Mailpit — Email Testing
mailpit:
restart: unless-stopped
image: axllent/mailpit
container_name: "mailpit"
networks:
- proxynet
volumes:
- mailpit-data:/data
ports:
- 8025:8025 # Web UI
- 1025:1025 # SMTP
environment:
MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
Mailpit catches all outgoing emails. Configure Laravel to send mail to Mailpit:
MAIL_MAILER=smtp
MAIL_HOST=mailpit # Container name
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
Access the web UI at http://mailpit.dev.local to see all captured emails.
pgAdmin4 — PostgreSQL Management
pgadmin4:
restart: unless-stopped
container_name: pgadmin4
image: dpage/pgadmin4:latest
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL}
- PGADMIN_DEFAULT_PASSWORD=${AIO_PASSWORD}
- PGADMIN_LISTEN_PORT=80
networks:
- proxynet
volumes:
- pgadmin4-data:/var/lib/pgadmin
Access via http://pgadmin4.dev.local.
The .env File
All passwords and configuration are centralized in .env:
# Used by docker-compose
AIO_PASSWORD=your_secure_password
AIO_USERNAME=root
PGADMIN_EMAIL=admin@local.dev
LOCAL_COMPOSE_FILE=docker-compose.yml
OS= # Leave empty for Linux/Mac
Never commit
.envto git. Add it to.gitignore.
Daily Workflow
Starting Your Day
cd docker-starfish
make up # Start all containers
make ls # Verify everything is running
NAMES STATUS PORTS
proxy Up 2 minutes 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
php84 Up 2 minutes 9000/tcp
php82 Up 2 minutes 9000/tcp
mysql-8.4 Up 2 minutes 0.0.0.0:3309->3306/tcp
adminer Up 2 minutes 8080/tcp
mailpit Up 2 minutes 0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp
Working on a Project
make bash n=php84 # Enter PHP container
cd blog.md # Navigate to project
php artisan serve # Not needed! Nginx already serves it
composer install # Install dependencies
php artisan migrate # Run migrations
npm run dev # Run Vite (use node container or host)
After Changing Docker Configs
make reup n=proxy # Rebuild proxy after vhost changes
make reup n=php84 # Rebuild PHP after Dockerfile changes
make reup # Rebuild everything
Checking Resource Usage
make ss # docker stats — CPU, memory, network
Complete Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Host Machine │
│ │
│ /etc/hosts ./httpdocs/ (shared volume) │
│ 127.0.0.1 blog.dev.local ├── blog.md/ │
│ 127.0.0.1 adminer.dev.local ├── my-project/ │
│ 127.0.0.1 mailpit.dev.local └── legacy-app/ │
│ │
│ Ports: :80, :443, :3306, :3308, :3309, :1025, :8025 │
└──────────────────────────┬──────────────────────────────────┘
│
Docker Network: proxynet
│
┌──────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌───────────────────────────┐ ┌──────────────────┐
│ proxy │ │ PHP-FPM Containers │ │ Databases │
│ :80 │──│ php84:9000 php82:9000 │ │ mysql-5.7:3306 │
│ :443 │ │ php74:9000 php56:9000 │ │ mysql-8.0:3306 │
└────────┘ └───────────────────────────┘ │ mysql-8.4:3306 │
└──────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Dev Tools │
│ adminer:8080 mailpit:8025/1025 pgadmin4:80 │
└──────────────────────────────────────────────────────────┘
Recap: The Complete Setup
| Component | Purpose | Access |
|---|---|---|
| Proxy (Nginx) | Routes all traffic | *.dev.local:80/443 |
| PHP 8.4 | Modern PHP apps | via fastcgi_pass php84:9000 |
| PHP 7.4 | Legacy apps | via fastcgi_pass php74:9000 |
| MySQL 5.7 | Legacy databases | mysql-5.7:3306 / localhost:3306 |
| MySQL 8.4 | Modern databases | mysql-8.4:3306 / localhost:3309 |
| Adminer | DB management UI | adminer.dev.local |
| Mailpit | Email testing | mailpit.dev.local |
This setup has served me well across dozens of projects — from legacy PHP 5.6 WordPress sites to cutting-edge Laravel 11 applications, all running simultaneously on one machine with zero conflicts.
Series Navigation:
- ← Part 1: Architecture
- ← Part 2: Nginx Reverse Proxy
- Part 3: PHP, Databases & Dev Tools (You are here)