Docker Dev Environment (Part 1): Architecture — One Proxy to Rule Them All

· 5 min read

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:

Comments