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>
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 -dormake 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/hostsedits, no host-side Node/PHP installs required - Existing
composer run devflow stays usable for anyone who prefers bare-metal — Docker stack reads its own.env.docker, doesn't fightwebsite/.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)muslDNS 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 soFROMswap 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 sitehttps://account.ezscale.docker.localhost— customer dashboardhttps://admin.ezscale.docker.localhost— admin panelhttps://account.ezscale.docker.localhost/horizon— Horizon dashboardhttps://mail.ezscale.docker.localhost— Mailpit UIhttps://vite.ezscale.docker.localhost— Vite dev serverhttp://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/mysqlvalkey_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/htmlinapp,horizon,scheduler,web,vite. Includesvendor/andnode_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-metalcomposer run dev(untouched by Docker workflow).env.docker— used by Docker stack via Composeenv_file:. Gitignored. Created bycp .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/sailremoved fromwebsite/composer.jsonrequire-dev.website/composer.lockregenerated.- The Sail binary (
website/vendor/bin/sail) disappears on nextcomposer install. - No
docker-compose.ymlorDockerfilefrom Sail to remove — Sail was installed but never published.
Modifications to existing files
website/composer.json— droplaravel/saillinewebsite/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 installorchestration on first boot — user runs these manually once per fresh clone viamake composer ARGS="install"andmake 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 |