Three named targets (app, horizon, scheduler) sharing a runtime-base with PHP 8.3-FPM, opcache, redis, and pinned php-fpm pool config. Composer + Node build stages are separate so vendor/ and public/build/ are baked into the runtime image. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EZSCALE — Docker Compose Dev Environment
Multi-service development stack for the EZSCALE Laravel app. Replaces the bare-metal composer run dev workflow with a fully-containerized environment that mirrors the production topology (nginx + PHP-FPM + MariaDB + Valkey + Horizon).
Prerequisites
- Docker Engine 24+ with Compose V2 (
docker compose, notdocker-compose) - WSL2 with files cloned under
/home/$USER/(NOT/mnt/c/...— bind-mount performance is unusable on the Windows-mounted filesystem) - A modern browser (Chrome, Firefox, Safari) —
*.docker.localhostresolution requires no/etc/hostsedits
First-time setup
make init
That single command:
- Copies
.env.docker.example→.env.docker - Builds all custom images (PHP-FPM, nginx, vite)
- Pulls third-party images (Traefik, MariaDB, Valkey, Mailpit)
- Boots MariaDB and Valkey, waits for them to be healthy
- Runs
composer installandnpm install - Brings up the rest of the stack
When make init finishes, you should be able to open:
- 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 (catches all outgoing email)
- https://vite.ezscale.docker.localhost — Vite dev server (for HMR)
- http://localhost:8080 — Traefik dashboard (insecure, dev-only)
The first time you visit any of these, your browser will warn about a self-signed cert. Accept once — Traefik issues a wildcard cert covering all subdomains.
Daily commands
make up # bring stack up
make down # stop (volumes preserved)
make logs # tail all logs
make logs SVC=horizon # tail one service
make sh # bash inside app container
make artisan ARGS="migrate" # any artisan command
make composer ARGS="require foo/bar"
make npm ARGS="install foo"
make test # php artisan test --compact
make pint # format dirty PHP
make fresh # migrate:fresh --seed
make destroy # nuclear: stop + wipe volumes
make help prints the full list.
Architecture
| Service | Image | Role |
|---|---|---|
traefik |
traefik:v3.6 |
Edge proxy + TLS termination + routing |
app |
custom (PHP 8.3-FPM Debian) | Application container |
web |
nginx:1.30-alpine |
Serves public/, proxies PHP |
vite |
custom (Node 24 Alpine) | HMR dev server |
horizon |
same as app |
Queue worker supervisor |
scheduler |
same as app |
schedule:work runner |
mariadb |
mariadb:12 |
Primary database |
valkey |
valkey/valkey:9-alpine |
Sessions/cache/queues |
mailpit |
axllent/mailpit:v1.29 |
SMTP catcher |
Traefik routes the three Laravel subdomains (marketing/account/admin) to the same nginx container. Laravel's Route::domain() in bootstrap/app.php handles per-subdomain dispatch internally.
Environment
The stack reads .env.docker at the repo root — separate from website/.env. This keeps the Docker workflow from fighting any bare-metal composer run dev setup you might still want to use.
Critical Docker-specific values:
DB_HOST=mariadb
REDIS_HOST=valkey
MAIL_HOST=mailpit
APP_URL=https://ezscale.docker.localhost
Third-party API keys (Stripe, PayPal, VirtFusion, etc.) need to be added to your .env.docker if you want to test those integrations.
Volumes
Persisted across make down (lost only on make destroy):
mariadb_data— MySQL datavalkey_data— Valkey AOF persistencemailpit_data— captured emailtraefik_certs— self-signed cert cache
The Laravel source (./website) is bind-mounted live — your edits show up immediately. vendor/ and node_modules/ are visible on the host for IDE autocomplete.
TLS
Traefik auto-generates a single self-signed wildcard cert for *.ezscale.docker.localhost on first boot. The cert lives in the traefik_certs volume.
If you want a green-padlock experience instead of accepting the warning once per browser:
brew install mkcert # or apt/winget equivalent
mkcert -install
mkcert -cert-file docker/traefik/certs/cert.pem \
-key-file docker/traefik/certs/key.pem \
"ezscale.docker.localhost" "*.ezscale.docker.localhost"
Then update docker/traefik/dynamic.yml to point certificates: at those files. Currently configured for self-signed; mkcert is left as a future enhancement.
Common gotchas
Port 80 or 443 already in use.
Edit docker-compose.yml's traefik service and remap to e.g. 8000:80, 8443:443. Then access the stack at https://ezscale.docker.localhost:8443.
make init hangs on "waiting for mariadb".
First MariaDB boot creates the system tablespace and can take 20-30s. The healthcheck has a 30s start_period to accommodate this. If it really stalls, make logs SVC=mariadb to see why.
Permission errors on storage/logs/laravel.log.
The PHP container's UID matches your host UID (1000) by default. If your host UID differs, rebuild with UID=$(id -u) GID=$(id -g) make build.
Horizon dashboard 403s.
Horizon's gate is in App\Providers\HorizonServiceProvider::gate(). In dev all admin users have access; you need to log in with an admin role first.
Vite assets don't load.
Check make logs SVC=vite. The Laravel Vite plugin auto-injects the dev URL — if it can't reach https://vite.ezscale.docker.localhost, assets fall back to the manifest. Make sure that hostname is reachable in your browser.
Composer/npm install slow.
First-time composer install takes 1-2 min. After that the vendor/ dir is cached on disk. Same for node_modules.
Co-existing with bare-metal dev
This stack does NOT delete or modify website/.env. If you previously used cd website && composer run dev, that still works — it reads website/.env and connects to whatever local PHP/MySQL/Redis you had.
Pick one or the other for any given session. Don't run both simultaneously (they'd fight over ports and sessions).
Spec
Full design rationale in docs/superpowers/specs/2026-04-25-docker-compose-dev-environment-design.md.