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>
227 lines
12 KiB
Markdown
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 |
|