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

227 lines
12 KiB
Markdown

# 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 |