Files
website/docs/superpowers/specs/2026-04-25-docker-compose-dev-environment-design.md
Andrew dfdef3d7f4 feat: docker compose dev environment
Replaces the bare-metal `composer run dev` workflow with a fully
containerized 9-service stack orchestrated by docker compose. Single
command brings up the full app — three subdomains (marketing /
account / admin) reachable via Traefik with TLS, MariaDB + Valkey
+ Mailpit + Vite HMR + Horizon + scheduler all wired in.

Components:
- docker-compose.yml: traefik, app (php-fpm), web (nginx), mariadb,
  valkey, mailpit, vite, horizon, scheduler.
- docker/: Dockerfiles, nginx config, entrypoint scripts.
- Makefile: convenience targets (up / down / logs / shell / migrate
  / seed / test / pint / etc).
- .env.docker.example: template for Docker-stack environment vars
  (separate from website/.env so bare-metal devs aren't disrupted).
- website/vite.config.ts: server.host / origin / hmr / cors hooks
  driven by VITE_HOST / VITE_ORIGIN / VITE_HMR_HOST so the same
  config serves both bare-metal and Docker.
- website/bootstrap/app.php: redirectGuestsTo() now uses
  request()->getScheme() so http: dev hosts don't get force-https
  redirects.
- composer.json: drops laravel/sail (replaced by this stack).
- docs/superpowers/specs/2026-04-25-docker-compose-dev-environment-design.md:
  full design spec.

Bare-metal `composer run dev` workflow stays usable for anyone who
prefers it — Docker stack reads .env.docker, doesn't fight
website/.env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:10:53 -04:00

12 KiB

Docker Compose Dev Environment — Design Spec

Overview

Replace the bare-metal composer run dev workflow (which assumes host-installed PHP 8.3, Node 24, MariaDB, Valkey/Redis, and Mailpit) with a fully-containerized multi-service stack orchestrated by docker compose. Removes the laravel/sail dev dependency. The stack runs the existing single Laravel application — including its three subdomains (marketing, account, admin) — across 9 purpose-built services on a single Compose project.

Goals

  • Single command (docker compose up -d or make up) brings up the entire dev environment from a fresh clone
  • All three subdomains reachable in the browser with TLS
  • Hot-reloading frontend assets via Vite, Horizon dashboard, scheduled jobs, queue workers, and trapped outgoing mail
  • Dev/prod parity: nginx + PHP-FPM (matches likely Kubernetes target), official MariaDB & Valkey images
  • No /etc/hosts edits, no host-side Node/PHP installs required
  • Existing composer run dev flow stays usable for anyone who prefers bare-metal — Docker stack reads its own .env.docker, doesn't fight website/.env

Non-Goals

  • Production Dockerfiles / Helm charts (separate workstream; this Dockerfile is dev-tuned)
  • Mocking external provisioning APIs (VirtFusion, SynergyCP, Pterodactyl, Enhance) — those stay external, configured via env
  • HTTPS with locally-trusted CA (mkcert) — using Traefik self-signed; revisit if cert warnings become annoying

Architecture

Service map (9 services)

# Service Image Role Exposed via
1 traefik traefik:v3.6 Edge proxy + TLS termination + routing Host ports 80, 443, 8080
2 app custom (docker/app/Dockerfile) PHP 8.3-FPM Internal only
3 web nginx:1.30-alpine Serves public/, proxies PHP to app:9000 Traefik → 3 vhosts
4 vite custom (docker/vite/Dockerfile) npm run dev for HMR Traefik → vite.ezscale.docker.localhost
5 horizon same image as app php artisan horizon Internal
6 scheduler same image as app php artisan schedule:work Internal
7 mariadb mariadb:12 Primary database (utf8mb4 + utf8mb4_unicode_ci pinned) Internal
8 valkey valkey/valkey:9-alpine Sessions, cache, queues Internal
9 mailpit axllent/mailpit:v1.29 SMTP catcher + UI Traefik → mail.ezscale.docker.localhost

Image base decisions

  • PHP container: php:8.3-fpm-bookworm (Debian) — chosen over Alpine because (a) musl DNS edge cases are a real risk for an app calling many external APIs (Stripe, PayPal, VirtFusion, SynergyCP, Pterodactyl, MaxMind, Outlook IMAP), (b) extension-install Dockerfile is half as complex on Debian, (c) Forge/Vapor and most public Laravel images on Kubernetes use Debian, (d) the 245MB image-size delta is paid once per node per image SHA. Easy to swap later — Dockerfile is structured so FROM swap is the only mechanical change.
  • Other Linux services: Alpine where official Alpine variants exist (nginx, vite, valkey).
  • MariaDB & Mailpit: no official Alpine; MariaDB stays Debian, Mailpit is already FROM-scratch single-binary.
  • Traefik: scratch-based; smaller than Alpine.

Hostnames (browser-facing)

  • https://ezscale.docker.localhost — marketing site
  • https://account.ezscale.docker.localhost — customer dashboard
  • https://admin.ezscale.docker.localhost — admin panel
  • https://account.ezscale.docker.localhost/horizon — Horizon dashboard
  • https://mail.ezscale.docker.localhost — Mailpit UI
  • https://vite.ezscale.docker.localhost — Vite dev server
  • http://localhost:8080 — Traefik dashboard (insecure, dev-only)

*.docker.localhost resolves to 127.0.0.1 natively in modern browsers — no /etc/hosts edits.

TLS strategy

Traefik auto-generates a single wildcard self-signed cert covering ezscale.docker.localhost and *.ezscale.docker.localhost. Browser shows a warning on first visit; after accepting, all subdomains share the trust decision. Configured in docker/traefik/dynamic.yml via tls.stores.default.defaultGeneratedCert.

Subdomain routing

Traefik routes the three Laravel subdomains (marketing/account/admin) to the same web (nginx) container. Nginx uses a single catch-all server_name _; block. Laravel's existing Route::domain(config('app.domains.*')) in bootstrap/app.php handles the per-subdomain dispatch internally — no per-subdomain nginx vhost needed.

Vite (port 5173) and Mailpit (port 8025) are routed to their own containers via separate Traefik label rules.

Service dependency graph

mariadb (healthcheck: mariadb-admin ping) ─┐
valkey  (healthcheck: valkey-cli ping)     ─┼──► app ──► web ──► traefik
                                           │      │
                                           │      ├──► horizon
                                           │      └──► scheduler
mailpit (no healthcheck needed)            ─┘
vite ──► traefik (independent of app)

app, horizon, and scheduler use depends_on with condition: service_healthy for mariadb and valkey. They share the same image and bind-mount the same website/ source — they differ only in command:.

Directory layout

All Docker artifacts under a new docker/ sibling of website/. Nothing inside website/ is restructured.

/home/andrew/local_projects/website/
├── docker-compose.yml                ← NEW
├── .env.docker.example               ← NEW (committed template)
├── .env.docker                       ← NEW (gitignored, copied from example)
├── Makefile                          ← NEW (ergonomic shortcuts)
├── docker/
│   ├── README.md
│   ├── app/
│   │   ├── Dockerfile
│   │   ├── php.ini
│   │   ├── opcache.ini
│   │   ├── php-fpm.conf
│   │   └── entrypoint.sh
│   ├── nginx/
│   │   ├── Dockerfile
│   │   ├── nginx.conf
│   │   └── conf.d/ezscale.conf
│   ├── traefik/
│   │   ├── traefik.yml
│   │   └── dynamic.yml
│   ├── vite/
│   │   └── Dockerfile
│   └── mariadb/
│       └── init/01-create-test-db.sql
└── website/                          ← unchanged

App container — extensions and config

PHP extensions baked in: pdo_mysql intl bcmath gd zip pcntl posix exif sockets opcache redis. That covers Cashier, Horizon (pcntl/posix for signal handling), Inertia, dompdf, webklex/php-imap (which is pure-PHP, doesn't need ext-imap), and phpredis (REDIS_CLIENT=phpredis).

Build args: UID=1000 GID=1000 so the in-container user matches the host user. Files written by composer install, artisan migrate, etc. land as the host user — no chmod dance.

opcache.validate_timestamps=1 and revalidate_freq=0 so file edits show up immediately. Inverse of prod settings.

entrypoint.sh waits for MariaDB, copies .env.docker.env if .env is missing, runs key:generate and migrate --force (both idempotent), creates the storage symlink, then execs the container's CMD. Same image is used for app (CMD php-fpm), horizon (CMD php artisan horizon), and scheduler (CMD php artisan schedule:work).

Volumes & persistence

Named volumes (Docker-managed, persist across docker compose down):

  • mariadb_data/var/lib/mysql
  • valkey_data/data (Valkey AOF/RDB persistence)
  • traefik_certs/etc/traefik/acme (self-signed cert cache)
  • mailpit_data/data (mailpit message store)

Bind mounts:

  • ./website/var/www/html in app, horizon, scheduler, web, vite. Includes vendor/ and node_modules/ (visible on host for IDE).
  • ./docker/nginx/conf.d/etc/nginx/conf.d (read-only)
  • ./docker/traefik/*.yml/etc/traefik/ (read-only)
  • ./docker/mariadb/init/docker-entrypoint-initdb.d (read-only)

WSL2-native filesystem (/home/andrew/... is ext4) makes bind-mounted vendor/ and node_modules/ performant. The named-volume overlay trick used on macOS isn't necessary here.

Environment handling

Two env files coexist:

  • website/.env — used by bare-metal composer run dev (untouched by Docker workflow)
  • .env.docker — used by Docker stack via Compose env_file:. Gitignored. Created by cp .env.docker.example .env.docker.

Docker-specific env values:

APP_URL=https://ezscale.docker.localhost
DOMAIN_MARKETING=ezscale.docker.localhost
DOMAIN_ACCOUNT=account.ezscale.docker.localhost
DOMAIN_ADMIN=admin.ezscale.docker.localhost
SESSION_DOMAIN=.ezscale.docker.localhost

DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=ezscale_billing
DB_USERNAME=ezscale
DB_PASSWORD=ezscale_local

REDIS_HOST=valkey
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025

QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
CACHE_STORE=redis

External API keys (Stripe test, PayPal sandbox, VirtFusion, etc.) are placeholders in .env.docker.example; developers fill them in their local .env.docker.

Dev workflow (Makefile shortcuts)

make up               # docker compose up -d
make down             # docker compose down
make build            # docker compose build --pull
make logs             # docker compose logs -f
make logs SVC=horizon # tails one service
make sh               # shell into app container
make artisan ARGS="..." # runs artisan inside app
make composer ARGS="..." # runs composer inside app
make npm ARGS="..."   # runs npm inside vite
make test             # php artisan test --compact
make pint             # vendor/bin/pint --dirty --format agent
make fresh            # migrate:fresh --seed inside app
make destroy          # docker compose down -v (wipes volumes)

Removals

  • laravel/sail removed from website/composer.json require-dev.
  • website/composer.lock regenerated.
  • The Sail binary (website/vendor/bin/sail) disappears on next composer install.
  • No docker-compose.yml or Dockerfile from Sail to remove — Sail was installed but never published.

Modifications to existing files

  • website/composer.json — drop laravel/sail line
  • website/composer.lock — regenerated
  • Repo-root .gitignore — add .env.docker

Out of scope

  • Production Dockerfile (multi-stage, smaller, opcache locked)
  • Kubernetes manifests / Helm chart
  • mkcert / locally-trusted TLS
  • Mocking provisioning APIs
  • xdebug (can be added later via build arg)
  • Containerized composer install / npm install orchestration on first boot — user runs these manually once per fresh clone via make composer ARGS="install" and make npm ARGS="install"

Risks & mitigations

Risk Mitigation
Self-signed cert warnings annoying users with multiple browsers Document mkcert as future upgrade path
Bind-mounted vendor/ slow on WSL2 if user clones to /mnt/c/... README explicitly tells users to clone under /home/$USER/
Horizon dashboard route at /horizon requires auth in prod — what about dev? App\Providers\HorizonServiceProvider::gate() controls this; in dev, all admin users have access
entrypoint.sh auto-migrate could mask broken migrations Idempotent; failure aborts startup, errors visible in docker compose logs app
Port 80/443 already used on host (e.g. another nginx) README documents how to remap Traefik to 8000/8443 if needed
MariaDB 12 vs MySQL 8 collation drift on real prod migration Compose pins --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci; same flags applied in prod prevents drift