diff --git a/PRINCIPLES/dockerization-and-deployment.md b/PRINCIPLES/dockerization-and-deployment.md new file mode 100644 index 0000000..5e1244e --- /dev/null +++ b/PRINCIPLES/dockerization-and-deployment.md @@ -0,0 +1,727 @@ +# Blax Software — Laravel Dockerization & Deployment Principles + +This document is the single source of truth for how every Blax Software +Laravel **application** (not package) is containerized and deployed. It is +the application-side companion to +[[laravel-composer-packages]] — packages describe a library's contract, +this describes how a host app actually runs in dev and in production. + +Two flavours of `deploy.sh` exist in the fleet: + +- A **canonical** flavour — minimal, do-the-right-thing script that covers + composer-install / migrate / cache / restart workers. Use this by + default. +- An **extended** flavour — same script plus optional version tagging, + pre-deploy encrypted DB backups, and a hard WebSocket restart with + liveness verification. Adopt it when the app needs those features. + +If you are creating a new Laravel app, copy these conventions verbatim. If +an app deviates, justify it inline (README) and ideally fold the +improvement back here. + +--- + +## 1. Use the shared `blaxsoftware/laravel` image — never a custom Dockerfile + +Every Laravel app runs on a tag of `blaxsoftware/laravel`: + +```yaml +services: + app: + image: blaxsoftware/laravel:laravel13-php8.4 +``` + +The image bakes in nginx + php-fpm + supervisor, the PHP extensions every +Blax project needs, composer, and the supervisor scaffolding that the +`ENABLE_*` flags below switch on. There is **no per-project `Dockerfile`** +and no `build:` block in compose — the image is the contract, every app +gets the same base, upgrades happen by bumping the tag. + +### Tag scheme + +``` +blaxsoftware/laravel:laravel-php +``` + +Examples that exist today: + +| Tag | Use for | +|---|---| +| `laravel13-php8.4` | New apps (Laravel 13, PHP 8.4) | +| `laravel12-php8.4` | Apps on Laravel 12 | +| `laravel11-php8.4` | Apps on Laravel 11 | +| `laravel10-php8.3` | Legacy Laravel 10 apps | + +Always pin a specific framework + PHP tag (`laravel13-php8.4`), never +`latest`. The matrix is rebuilt centrally; bump the tag in your +`docker-compose.yml` when you upgrade Laravel. + +### Forbidden + +- A `Dockerfile` in the project root that `FROM`s `php:8.x-fpm` and + re-installs nginx/supervisor/extensions. That re-invents the image, drifts + away from the fleet, and breaks the "bump one tag, all apps stay current" + story. A per-project `Dockerfile` plus `build: ./docker` block is the + anti-pattern; migrate any app still doing this (see §9). +- `build:` keys in compose pointing at a per-project Dockerfile. +- Installing extra system packages via container-side `apt` in deploy + scripts. If you need an extension that isn't in the base image, raise it + on `blaxsoftware/laravel` and bake it into the image. + +### Why + +One image, one supervisor config, one PHP build. Every app upgrade is a +tag change, every fleet-wide CVE patch is one image rebuild. Custom +Dockerfiles in each repo guarantee they will drift — different PHP minor +versions, different ext list, different OS base. We stopped doing that. + +--- + +## 2. Process management via `ENABLE_*` env flags + +The image's supervisor reads a small set of env vars at boot and starts +the corresponding workers. **Don't write your own supervisor entries for +these** — flip the flag and the image does it: + +| Env var | Effect | Default | +|---|---|---| +| `ENABLE_QUEUE` | Starts `php artisan queue:work` under supervisor | off | +| `ENABLE_SCHEDULER` | Runs `php artisan schedule:run` every minute | off | +| `ENABLE_HORIZON` | Starts `php artisan horizon` (use instead of `ENABLE_QUEUE` when you've adopted Horizon) | off | +| `ENABLE_LARAVEL_PERMS` | On boot, chowns `storage/` + `bootstrap/cache/` to `www-data` so Laravel can write logs/sessions/caches | off | +| `PUSHER_PORT` | Port for the websockets server when you publish a custom supervisor entry that needs it (see §3) | unset | + +Canonical app block: + +```yaml +app: + image: blaxsoftware/laravel:laravel13-php8.4 + environment: + ENABLE_QUEUE: "true" + ENABLE_SCHEDULER: "true" + ENABLE_HORIZON: "false" + ENABLE_LARAVEL_PERMS: "1" + PUSHER_PORT: "6001" +``` + +Set `ENABLE_HORIZON: "true"` **instead of** `ENABLE_QUEUE` — never both. + +--- + +## 3. Custom supervisor configs: WebSocket server + +The one process the image does NOT start for you is your app's WebSocket +server (it lives in your app, not the base image). Drop the config in +`docker/supervisor/` and mount it to `/etc/supervisor/custom.d`: + +```yaml +app: + volumes: + - ./:/var/www/html + - ./docker/supervisor:/etc/supervisor/custom.d +``` + +`docker/supervisor/websocket.conf`: + +```ini +[program:websocket] +command=/usr/local/bin/php -d variables_order=EGPCS /var/www/html/artisan websockets:serve --host=0.0.0.0 --port=6001 +autostart=true +autorestart=true +user=www-data +priority=30 +startsecs=5 +startretries=100 +stopsignal=TERM +stopwaitsecs=15 +stdout_logfile=/proc/1/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/proc/1/fd/2 +stderr_logfile_maxbytes=0 +``` + +Notes: + +- `user=www-data` is non-negotiable — Laravel's storage permissions are + set up for `www-data` by `ENABLE_LARAVEL_PERMS`. +- Logs go to `/proc/1/fd/{1,2}` so `docker logs app` shows the WS output + inline with nginx/php-fpm. +- `priority=30` runs the WS after php-fpm/nginx (priority 10/20) so the + app routes that the WS auth callback hits are already serving. + +Anything else you need (custom workers, one-off daemons) follows the same +pattern: one file per program in `docker/supervisor/`. + +--- + +## 4. Compose file split: `docker-compose.yml` (prod-shape) + override (local-only) + +Two compose files, each with one job: + +- **`docker-compose.yml`** — the production deployment shape. HTTP only, + no port exposes, traefik on the plain `web` entrypoint. Committed. + This is the *only* file the deploy script loads. +- **`docker-compose.override.yml`** — local-dev additions: mkcert TLS labels + on traefik's `websecure` entrypoint, a `mysql ports: 3306` expose so you + can connect with TablePlus. Auto-loaded by `docker compose up`, **not** + loaded by `docker compose -f docker-compose.yml …`. Committed. + +The production deploy script always uses `-f docker-compose.yml` explicitly +to ensure the override is skipped on the server. Local devs run `docker +compose up` (no `-f`) and get both files merged automatically. + +> Alternative pattern: a third file `docker-compose.local.yml` exists in +> place of the auto-loaded override, and developers opt in via +> `COMPOSE_FILE` env or explicit `-f` flags. This works but is +> friction-heavy; **prefer the override.yml auto-load pattern** unless you +> have a specific reason (e.g. CI needs a clean compose without dev labels). + +### Reference: `docker-compose.yml` + +```yaml +networks: + web: + external: true + internal: + driver: bridge + +# YAML anchor — all production traefik labels declared once, reused across +# services. Each Host() rule accepts BOTH the prod hostname AND the local +# *.localhost.at dev alias, so this file works on both sides; the +# override.yml adds the websecure (mkcert TLS) routers on top for local. +# +# In production the app sees plain HTTP only — upstream nginx terminates +# TLS and proxies through to traefik, and traefik's web entrypoint owns +# the HTTP→HTTPS redirect logic. So declaring entrypoints: web here is +# sufficient end-to-end on the prod host. +x-traefik-labels: &traefik-labels + traefik.enable: "true" + traefik.docker.network: "web" + + # App HTTP + traefik.http.routers..rule: "Host(``) || Host(`.localhost.at`)" + traefik.http.routers..entrypoints: "web" + traefik.http.routers..service: "-http" + traefik.http.services.-http.loadbalancer.server.port: "80" + + # App WebSocket + traefik.http.routers.-ws.rule: "Host(`ws-`) || Host(`ws-.localhost.at`)" + traefik.http.routers.-ws.entrypoints: "web" + traefik.http.routers.-ws.service: "-ws" + traefik.http.services.-ws.loadbalancer.server.port: "6001" + +services: + app: + image: blaxsoftware/laravel:laravel13-php8.4 + container_name: -app + restart: unless-stopped + working_dir: /var/www/html + volumes: + - ./:/var/www/html + - ./docker/supervisor:/etc/supervisor/custom.d + environment: + ENABLE_QUEUE: "true" + ENABLE_SCHEDULER: "true" + ENABLE_HORIZON: "false" + ENABLE_LARAVEL_PERMS: "1" + PUSHER_PORT: "6001" + networks: [web, internal] + depends_on: + mysql: { condition: service_healthy } + redis: { condition: service_healthy } + labels: + <<: *traefik-labels + + mysql: + image: mysql:8.0 + container_name: -mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: "${DB_PASSWORD:-secret}" + MYSQL_DATABASE: "${DB_DATABASE:-}" + volumes: + - ./docker-data/mysql:/var/lib/mysql + networks: [internal] + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_PASSWORD:-secret}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: -redis + restart: unless-stopped + volumes: + - ./docker-data/redis:/data + networks: [internal] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 +``` + +### Reference: `docker-compose.override.yml` (local dev only) + +```yaml +# Local-dev only — mkcert TLS on traefik's websecure entrypoint, mysql +# port expose. Auto-loaded by `docker compose up`; deploy.sh uses +# `-f docker-compose.yml` explicitly so this file is ignored in prod. +services: + app: + labels: + traefik.http.routers.-tls.rule: "Host(`.localhost.at`)" + traefik.http.routers.-tls.entrypoints: "websecure" + traefik.http.routers.-tls.tls: "true" + traefik.http.routers.-tls.service: "-https" + traefik.http.services.-https.loadbalancer.server.port: "80" + + traefik.http.routers.-wss.rule: "Host(`ws-.localhost.at`)" + traefik.http.routers.-wss.entrypoints: "websecure" + traefik.http.routers.-wss.tls: "true" + traefik.http.routers.-wss.service: "-wss" + traefik.http.services.-wss.loadbalancer.server.port: "6001" + + mysql: + ports: + - "3387:3306" # pick a unique host port per app to avoid collisions +``` + +--- + +## 5. Traefik conventions, hostnames, and TLS + +**Routing fronts every app.** No app ever publishes 80/443 directly — +traefik does, on the shared `web` Docker network. Apps just declare +labels. + +### The shared `web` network + +`web` is an externally-created bridge network on each host (dev laptop + +prod server) where traefik listens for containers with +`traefik.enable=true`. Every app's `app` service joins it; `mysql` / +`redis` / other backing services stay on the per-app `internal` network. + +If `web` doesn't exist on a fresh box, create it once: + +```bash +docker network create web +``` + +### Hostname conventions + +- **Local development** uses subdomains of `localhost.at`. The domain has + a wildcard A record pointing at `127.0.0.1`, so anything like + `.localhost.at` or `ws-.localhost.at` resolves locally + without `/etc/hosts` edits. Devs install a mkcert-signed wildcard cert + for `*.localhost.at` once and traefik terminates TLS on the + `websecure` entrypoint using it. +- **Production hostnames are app-defined** in the `Host()` rule on each + router — typically a subdomain of `blax.at` (e.g. + `api-.blax.at`, `ws-api-.blax.at`), but the rule is the + only source of truth. Use whatever public hostname the app is + reachable at. + +The same router rule accepts both the prod hostname and the local +`localhost.at` alias with an `||`, so one set of labels works in both +environments: + +``` +Host(``) || Host(`.localhost.at`) +``` + +### Two entrypoints, two roles + +- `web` — plain HTTP, port 80. +- `websecure` — TLS, port 443. + +Every router in `docker-compose.yml` declares `entrypoints: web`. The +`websecure` routers live in the override and only matter on a dev box +(see "TLS termination" below for why this is OK in prod). + +A third `mobile` entrypoint exists on some hosts for Android-emulator +access (`10.0.2.2`, no HTTPS redirect). Add this only if you genuinely +need emulator traffic; for normal apps the two-entrypoint setup is +enough. + +### TLS termination + +The TLS termination point is different between environments, and that's +intentional: + +- **Local dev**: traefik terminates TLS itself using the mkcert wildcard + for `*.localhost.at`. The `websecure` routers from + `docker-compose.override.yml` carry the `tls: "true"` label and traefik + serves the cert. The `web` entrypoint also exists for the plain-HTTP + alias if you need it. +- **Production**: an upstream nginx terminates TLS using the real + certificate, then proxy-passes plain HTTP to traefik. Traefik in turn + manages the HTTP-to-HTTPS redirect for any client that arrived over + HTTP. The result, viewed from the app container: incoming traffic is + always plain HTTP on port 80 regardless of how the client connected, + and the redirect logic lives in the traefik entrypoint configuration + on the prod host, not in the app's compose file. + +The practical consequence for your compose files: `docker-compose.yml` +only declares `web`-entrypoint routers, and that's enough for prod +because the entire `https → http → traefik → app` chain is handled +upstream. `docker-compose.override.yml` adds the `websecure` routers so +that local dev gets working HTTPS without an extra nginx hop. + +### Router naming scheme + +| Router suffix | What it serves | Where declared | +|---|---|---| +| `` | App HTTP on port 80 | `docker-compose.yml` | +| `-ws` | WebSocket plain on port 6001 | `docker-compose.yml` | +| `-tls` | App HTTPS (mkcert, local only) | `docker-compose.override.yml` | +| `-wss` | WebSocket secure (mkcert, local only) | `docker-compose.override.yml` | + +The router *names* must be globally unique across all apps on a host — +that's why each one starts with the app's slug. + +### Forbidden + +- An app service publishing `ports: [80:80]` or `ports: [443:443]`. Use + traefik labels instead. +- Putting TLS / `websecure` labels in `docker-compose.yml`. TLS in this + file would force traefik to attempt cert handshakes in prod, which is + upstream nginx's job. TLS labels live in the override (local dev) only. +- Hardcoding `loadbalancer.server.port: 6001` for the app's HTTP router. + Port 80 for HTTP, port 6001 for the WS service — don't mix them up. + +--- + +## 6. Persistent data: `./docker-data/` in the repo, gitignored + +Bind-mount mysql and redis storage to a `docker-data/` folder right next +to the source: + +``` +docker-data/ + mysql/ # mysql:8.0 datadir + redis/ # redis dump.rdb +``` + +The folder is gitignored (`docker-data/` in `.gitignore`). It survives +`docker compose down`. The deploy script `mkdir -p`s it on first run so +fresh boxes Just Work. + +### Why not named docker volumes? + +- Trivially backed up — `tar -caf data.tar.xz docker-data/` from the + repo root is the entire prod state. +- Survives container/image churn including accidental + `docker volume prune -af`. +- Discoverable — anyone with the repo can see where the data lives. +- The repo path identifies which app owns the data when you have ten + apps on one host. + +--- + +## 7. The `deploy.sh` script — canonical pattern + +Every app has a `deploy.sh` at the repo root that runs end-to-end deploys. +Use the **canonical** flavour below as the default; use the **extended** +flavour when you also need version tagging, a pre-deploy DB backup, or a +hard WebSocket restart with liveness verification (see §7.3). + +### 7.1 Canonical deploy.sh + +```bash +#!/bin/bash + +set -e + +SELF="$0" +COMPOSE_CMD=(docker compose -f docker-compose.yml) + +# ── Parse arguments ────────────────────────────────────────────────── + +FLAG="" + +for arg in "$@"; do + case "$arg" in + --after-pull|--force-recreate) + FLAG="$arg" ;; + *) ;; + esac +done + +echo "==> Starting deploy script..." + +# ── Self-update check (first run only) ─────────────────────────────── + +if [ "$FLAG" != "--after-pull" ] && [ "$FLAG" != "--force-recreate" ]; then + echo "==> Checking for updates..." + + ORIGINAL_HASH=$(md5sum "$SELF" | cut -d' ' -f1) + + git pull --rebase --stat + + UPDATED_HASH=$(md5sum "$SELF" | cut -d' ' -f1) + + PASSTHROUGH_ARGS=("--after-pull") + + if [ "$ORIGINAL_HASH" != "$UPDATED_HASH" ]; then + echo "==> Script updated — re-running..." + echo + "$SELF" "${PASSTHROUGH_ARGS[@]}" + exit $? + fi + + echo "==> No script changes detected." + echo + "$SELF" "${PASSTHROUGH_ARGS[@]}" + exit $? +fi + +echo "==> Running deployment steps..." + +# ── Deployment steps ───────────────────────────────────────────────── + +REPO_UID=$(stat -c '%u' . 2>/dev/null || id -u) +REPO_GID=$(stat -c '%g' . 2>/dev/null || id -g) + +DEPLOY_UID=${DEPLOY_UID:-$REPO_UID} +DEPLOY_GID=${DEPLOY_GID:-$REPO_GID} + +APP_EXEC=("${COMPOSE_CMD[@]}" exec -T -u "${DEPLOY_UID}:${DEPLOY_GID}" app) + +echo "==> Using production compose file only (docker-compose.yml)." +echo "==> Deployment user in app container: ${DEPLOY_UID}:${DEPLOY_GID}" + +echo "==> Preparing writable directories..." +mkdir -p \ + vendor \ + bootstrap/cache \ + storage/framework/cache \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + docker-data/mysql \ + docker-data/redis + +if [ "$(id -u)" -eq 0 ]; then + echo "==> Fixing ownership on writable directories (root mode)..." + chown -R "${DEPLOY_UID}:${DEPLOY_GID}" \ + vendor \ + bootstrap/cache \ + storage \ + docker-data \ + 2>/dev/null || true +else + echo "==> Skipping host chown (not root)." +fi + +echo "==> Ensuring containers are up before exec..." +"${COMPOSE_CMD[@]}" up -d mysql redis +"${COMPOSE_CMD[@]}" up -d --no-deps app + +echo "==> Preparing git/composer environment in container..." +"${APP_EXEC[@]}" bash -c "export HOME=/tmp; git config --global --add safe.directory /var/www/html || true" +"${APP_EXEC[@]}" bash -c "mkdir -p /var/www/html/vendor /var/www/html/bootstrap/cache /var/www/html/storage" + +echo "==> Installing composer dependencies..." +"${APP_EXEC[@]}" bash -c \ + "export HOME=/tmp; git config --global --add safe.directory /var/www/html || true; COMPOSER_HOME=/tmp/composer-home COMPOSER_CACHE_DIR=/tmp/composer-cache XDG_CACHE_HOME=/tmp composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev" + +echo "==> Running migrations..." +"${APP_EXEC[@]}" bash -c "php artisan migrate --force" + +echo "==> Caching config/routes/views..." +"${APP_EXEC[@]}" bash -c "php artisan config:cache && php artisan route:cache && php artisan view:cache" + +echo "==> Rebuilding/starting containers..." + +if [ "$FLAG" == "--force-recreate" ]; then + "${COMPOSE_CMD[@]}" up -d --force-recreate --build app +else + "${COMPOSE_CMD[@]}" up -d --no-deps --build app + + echo "==> Restarting queue worker (picks up new code)..." + "${APP_EXEC[@]}" php artisan queue:restart + + echo "==> Sending hard restart signal to WebSocket server..." + "${APP_EXEC[@]}" php artisan websocket:steer restart || true + + echo "==> WebSocket will restart within ~5 seconds (supervisor auto-restarts)." +fi + +echo "==> Deployment complete!" +``` + +### 7.2 What each step does (and why) + +1. **Self-update via md5sum + `--after-pull`** — the script `git pull`s + the repo, hashes itself before and after, and if the script file + changed, *re-executes its new copy* with `--after-pull`. This means + improvements to deploy.sh are picked up on the next deploy without a + "remember to re-run after the pull" footgun. The new copy gets the + flag so it skips the pull step. +2. **Production compose only** — `COMPOSE_CMD=(docker compose -f + docker-compose.yml)`. No override.yml on prod. +3. **UID/GID detection** — uses the repo owner's UID/GID by default + (overridable via `DEPLOY_UID`/`DEPLOY_GID` env). Container processes + that touch the bind-mounted source run as that UID so file ownership + stays consistent. Prevents the classic "host can't edit a file that + container wrote as UID 33" problem. +4. **`mkdir -p` writable dirs** — ensures the bind-mount points exist + *before* the container starts. Docker would otherwise create them as + root. +5. **Conditional chown** — only runs when the script is invoked as root + (e.g. by the deploy webhook). When a developer runs it manually as + their own user, the chown is skipped (it would fail anyway). +6. **Up `mysql` + `redis` first, then `app`** — `--no-deps` on `app` + means we don't restart the DB just because the app code changed. +7. **`composer install --no-dev`** inside the container with + `HOME=/tmp` and isolated composer dirs — avoids touching anything + under `/var/www/html/.composer` (which would be on the bind-mounted + host filesystem) and avoids "dubious ownership" git errors. +8. **`migrate --force`** — `--force` is required because the artisan + prompt detects non-TTY and would otherwise abort. +9. **`config:cache && route:cache && view:cache`** — production + optimizations. Note these run *after* `migrate` so any migration- + triggered config change is captured. +10. **`--no-deps --build app`** — rebuild the app container but don't + touch mysql/redis. Switches the running container atomically. +11. **`queue:restart`** — sets the `illuminate:queue:restart` cache + timestamp; running workers see it on their next loop and exit, and + supervisor restarts them with the new code. +12. **`websocket:steer restart`** — Blax's `laravel-websockets` package + polls the cache for a restart sentinel. The new WS process picks up + the new code; old clients reconnect automatically. + +### 7.3 Optional add-ons (extended flavour) + +Add these if the app needs them. They sit between the composer-install +and the rebuild steps: + +- **Pre-deploy DB backup** via `php artisan workkit:db:backup` (encrypted + dump to `storage/backups/`). `set -e` means a failed backup aborts the + deploy — we'd rather block than push migrations forward without a + recovery point. Restore via `php artisan workkit:db:restore`. +- **Version tagging** — `--patch` / `--minor` / `--major` / `--version=X.Y.Z` + flags bump a `vX.Y.Z` git tag and push it before the deploy steps run. + Also maintains a moving `deploy` tag that always points at the last + successful deploy. +- **WebSocket hard-restart with verify** — `php artisan + websocket:restart-hard` (sends SIGTERM directly to the + `websockets:serve` PID, escalates to SIGKILL if needed) followed by a + `pgrep` check that the process came back. Fails the deploy loudly if + the WS server isn't running afterwards. + +Don't adopt these unless the app has the matching infrastructure — the +`workkit:db:backup` artisan command (from `blax-software/laravel-workkit`) +and the `websocket:restart-hard` command (from +`blax-software/laravel-websockets`). + +### 7.4 Calling deploy.sh from anywhere + +The first execution does `git pull` and re-execs itself, so the script +doesn't care which commit it starts on — old or new. Just: + +```bash +ssh prod cd /srv/ && bash deploy.sh +``` + +Or wire it to a webhook / CI job. The `--after-pull` flag is internal — +don't pass it manually. + +--- + +## 8. Local dev: same compose, traefik + mkcert + +Once a developer has a single host-level setup in place (traefik on the +`web` network, mkcert wildcard for `*.localhost.at`), every Blax Laravel +app behaves identically: + +```bash +# First time on a new repo +docker compose up -d # picks up docker-compose.yml + override.yml +docker compose exec app composer install +docker compose exec app php artisan migrate + +# Browse at https://.localhost.at +# WebSocket at wss://ws-.localhost.at +``` + +`docker compose up` auto-loads the override.yml because the file exists +in the repo root. No flags needed. + +### Updating to a newer image + +Bump the tag in `docker-compose.yml` (e.g. `laravel12-php8.4 → +laravel13-php8.4`), then: + +```bash +docker compose pull app +docker compose up -d --force-recreate --build app +``` + +No rebuild step, no Dockerfile edits — the image is the only source of +binary changes. + +--- + +## 9. Migration path: existing apps still on a custom Dockerfile + +If you find an app with a per-project `Dockerfile` and a `build:` block, +migrate it to the shared image in this order: + +1. Replace the `build: ./docker` block with `image: + blaxsoftware/laravel:laravel-php`. +2. Delete the `Dockerfile` and the `./docker/Dockerfile` build directory + (keep `./docker/supervisor/` — the supervisor configs still apply). +3. Add the `ENABLE_*` env vars to the `app` service (§2). Remove any + supervisor entries for queue/scheduler that are now redundant. +4. Split the existing compose into `docker-compose.yml` (prod-shape) + + `docker-compose.override.yml` (local TLS + port exposes). Move + `websecure` / `wss` routers into the override. +5. Replace the existing deploy script with the canonical one from §7.1. +6. Move data volumes onto `./docker-data/*` bind mounts (§6) if they + aren't already. Use `docker cp` from the old volume into the new + bind-mount path before tearing down. +7. `docker compose down && docker compose up -d --force-recreate`. + +The data survives because mysql/redis storage is now a bind-mount on +disk, not a named volume tied to the old image. + +--- + +## Checklist for a new Blax Laravel app + +- [ ] App service uses `image: blaxsoftware/laravel:laravel-php` + — no `build:`, no per-project Dockerfile. +- [ ] `ENABLE_QUEUE`, `ENABLE_SCHEDULER`, `ENABLE_LARAVEL_PERMS` are set + explicitly on the `app` service (true/false strings). +- [ ] WebSocket process declared in `docker/supervisor/websocket.conf`, + mounted at `/etc/supervisor/custom.d`. No supervisor entries + duplicated for queue/scheduler/horizon (the image owns those). +- [ ] `docker-compose.yml` describes the **production** shape — HTTP only, + traefik labels on `web` entrypoint, no port exposes, mysql/redis + data bind-mounted under `./docker-data/`. +- [ ] `docker-compose.override.yml` adds **local-only** TLS routers on + `websecure` and any host-port exposes (e.g. `mysql 3306`). No + duplicated production labels. +- [ ] Traefik labels declared once via `&traefik-labels` YAML anchor; + each `Host()` rule accepts both the prod hostname AND the + `.localhost.at` dev alias. +- [ ] External `web` network referenced for traefik traffic; internal + `internal` bridge network for mysql/redis. Backing services are + NOT on `web`. +- [ ] `docker-data/` is in `.gitignore`. The deploy script `mkdir -p`s + it on first run. +- [ ] `deploy.sh` is the canonical script from §7.1 plus any extended + add-ons from §7.3 you actually need. +- [ ] deploy.sh self-update via md5sum + `--after-pull` is intact — + don't strip it. +- [ ] deploy.sh runs `composer install --no-dev`, `migrate --force`, + `config:cache && route:cache && view:cache`, then `queue:restart` + and `websocket:steer restart`. +- [ ] No app service publishes ports 80/443. Routing goes through + traefik labels exclusively. +- [ ] Bumping Laravel version is a one-line tag change in + `docker-compose.yml` + `docker compose pull && up -d + --force-recreate --build app` — never a Dockerfile edit.