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