Docker Dev Environment (Part 3): PHP-FPM, Multiple Databases & Dev Tools

· 7 min read

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 localhost or 127.0.0.1 as DB_HOST in Laravel. Inside a Docker container, localhost refers 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, or mysql-8.4
  • Username: root
  • Password: your AIO_PASSWORD from .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 .env to 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:

Comments