Docker Dev Environment (Part 1): Architecture — One Proxy to Rule Them All
Most PHP developers start with XAMPP, MAMP, or Laravel Sail. They work fine for a single project. But what happens when you need to run 10 projects simultaneously — some on PHP 8.4, some on PHP 7.4, some on PHP 5.6 — each with its own domain, its own database, and its own tools?
This series documents the Docker setup I use daily to manage all of that. By the end, you'll have:
- A single Nginx reverse proxy handling all traffic
- Multiple PHP-FPM containers (5.6, 7.4, 8.2, 8.4)
- Multiple MySQL versions (5.7, 8.0, 8.4)
- Dev tools like Adminer, Mailpit, pgAdmin, Ungit
- Custom local domains like
blog.yourname.local,project.yourname.local - Self-signed SSL certificates for HTTPS
The Problem with Traditional Setups
| Setup | Limitation |
|---|---|
| XAMPP/MAMP | One PHP version at a time. Hard to switch. |
| Laravel Sail | One project per docker-compose.yml. Each project runs its own MySQL, Redis, etc. |
| Separate docker-compose per project | Port conflicts (80 can only be used once). Duplicated services. |
What we want is a shared infrastructure — one proxy, one MySQL, one Mailpit — that all projects connect to.
Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ Your Machine │
│ │
│ /etc/hosts: │
│ 127.0.0.1 blog.dev.local │
│ 127.0.0.1 project.dev.local │
│ 127.0.0.1 adminer.dev.local │
│ 127.0.0.1 mailpit.dev.local │
│ │
└────────────────────┬────────────────────────────────────┘
│ :80 / :443
▼
┌─────────────────────────────────────────────────────────┐
│ proxy (Nginx Reverse Proxy) │
│ │
│ ┌─ default.conf ─────────────────────────────────┐ │
│ │ map $host → container name │ │
│ │ adminer.dev.local → adminer │ │
│ │ mailpit.dev.local → mailpit │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ vhosts/*.conf ────────────────────────────────┐ │
│ │ blog.dev.local → fastcgi_pass php84:9000 │ │
│ │ project.dev.local → fastcgi_pass php82:9000 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└───┬──────────┬──────────┬──────────┬───────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
php84:9000 php82:9000 php74:9000 php56:9000
(PHP-FPM) (PHP-FPM) (PHP-FPM) (PHP-FPM)
│ │ │ │
└──────────┴──────────┴──────────┘
│
Shared Volume
./httpdocs → /var/www/html
All containers are connected through a single Docker network called proxynet. The proxy is the only container that exposes ports to the host machine.
Project Directory Structure
docker-starfish/
├── docker-compose.yml # Main orchestration file
├── makefile # Quick commands (make up, make bash...)
├── .env # Environment variables
├── httpdocs/ # ← All project source code lives here
│ ├── blog.md/ # Laravel blog project
│ ├── my-saas-app/ # Another Laravel project
│ ├── legacy-wordpress/ # WordPress on PHP 7.4
│ └── phpinfo/ # Simple PHP info page
├── tools/
│ ├── proxy/ # Nginx reverse proxy config
│ │ ├── Dockerfile
│ │ ├── nginx.conf
│ │ ├── default.conf # Map-based routing for tools
│ │ └── vhosts/ # Per-project Nginx server blocks
│ │ ├── blog.conf
│ │ ├── my-saas.conf
│ │ └── template.conf.example
│ ├── php/ # PHP-FPM Dockerfiles
│ │ ├── Dockerfile-8.4
│ │ ├── Dockerfile-8.2
│ │ ├── Dockerfile-7.4
│ │ ├── Dockerfile-5.6
│ │ └── custom.ini
│ ├── mysql-5.7/
│ ├── mysql-8.0/
│ ├── mysql-8.4/
│ └── ssl-cert/ # Self-signed certificates
└── projects/ # Non-PHP projects (optional)
The key design decision: all PHP projects live under httpdocs/. This single directory is mounted into both the proxy and all PHP-FPM containers. This is what makes the whole system work.
The docker-compose.yml
Here's the core of the setup:
services:
# ─── The Gateway ───────────────────────────────────
proxy:
restart: always
container_name: "proxy"
build:
context: ./tools/proxy
dockerfile: Dockerfile
ports:
- 80:80 # Only container exposing HTTP
- 443:443 # Only container exposing HTTPS
networks:
- proxynet
volumes:
- ./httpdocs:/var/www/html:cached
# ─── PHP Runtimes ─────────────────────────────────
php84:
restart: unless-stopped
build:
context: ./tools/php
dockerfile: Dockerfile-8.4
volumes:
- ./httpdocs:/var/www/html:delegated
container_name: "php84"
networks:
- proxynet
php82:
restart: unless-stopped
build:
context: ./tools/php
dockerfile: Dockerfile-8.2
volumes:
- ./httpdocs:/var/www/html:delegated
container_name: "php82"
networks:
- proxynet
php74:
restart: unless-stopped
build:
context: ./tools/php
dockerfile: Dockerfile-7.4
volumes:
- ./httpdocs:/var/www/html:delegated
container_name: "php74"
networks:
- proxynet
# ─── Database ──────────────────────────────────────
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
- mysql-5.7-data:/var/lib/mysql:delegated
networks:
- proxynet
ports:
- 3306:3306
# ─── Dev Tools ─────────────────────────────────────
adminer:
restart: unless-stopped
image: adminer
container_name: "adminer"
environment:
ADMINER_DEFAULT_SERVER: mysql-5.7
networks:
- proxynet
mailpit:
restart: unless-stopped
image: axllent/mailpit
container_name: "mailpit"
networks:
- proxynet
ports:
- 8025:8025
- 1025:1025
# ─── Shared Network ─────────────────────────────────
networks:
proxynet:
name: proxy_network
# ─── Persistent Volumes ─────────────────────────────
volumes:
mysql-5.7-data:
Why This Works
Three things make this architecture possible:
1. Shared Network (proxynet)
Every container joins the same Docker network. Docker runs an internal DNS server at 127.0.0.11 that resolves container names to their internal IPs. So when Nginx config says fastcgi_pass php84:9000, Docker DNS resolves php84 to something like 172.18.0.5.
2. Shared Volume (./httpdocs)
Both the proxy and PHP-FPM containers mount the same host directory. When Nginx sets root /var/www/html/blog.md/public, that path exists in the proxy container (to serve static files) and in the PHP-FPM container (to execute PHP files).
3. Single Entry Point
Only the proxy container maps ports 80 and 443 to the host. No port conflicts. The proxy inspects the Host header and routes to the correct backend.
Makefile for Quick Commands
Instead of typing long docker compose commands:
#!make
include .env
DOCKER_COMPOSE=docker compose
up:
$(DOCKER_COMPOSE) up -d
reup:
$(DOCKER_COMPOSE) up -d --build $(n)
$(DOCKER_COMPOSE) logs $(n)
down:
$(DOCKER_COMPOSE) rm -fsv $(n)
bash:
$(DOCKER_COMPOSE) exec $(n) bash
ls:
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
Usage:
make up # Start all containers
make reup n=proxy # Rebuild and restart proxy
make bash n=php84 # SSH into PHP 8.4 container
make down n=mysql-5.7 # Stop MySQL
make ls # List running containers
What's Next
In Part 2, we'll deep dive into the Nginx reverse proxy — how the map directive works for dynamic routing, how to write vhost configs for PHP projects, and how to set up self-signed SSL.
In Part 3, we'll cover PHP-FPM containers, connecting to multiple MySQL versions, and adding dev tools like Mailpit and Adminer.
Series Navigation:
- Part 1: Architecture (You are here)
- Part 2: Nginx Reverse Proxy Deep Dive →
- Part 3: PHP, Databases & Dev Tools →