Deployment architecture
Vetty ships as a Docker Compose stack on a single VPS. Traefik fronts everything, terminates TLS, and routes each hostname to the right container. One repo, one build, one deploy script.
Runtime topology
Hostnames
Every routable host is a separate ${*_HOSTNAME} env var in
docker/.env. Traefik routes each one to the right service based on
the labels in docker/compose.yml.
| Env var | Purpose | Backing container |
|---|---|---|
API_HOSTNAME | JSON API + Filament operator/systemadmin panels | vetty-api |
ADMIN_HOSTNAME | Admin SPA (React) | vetty-admin-ui |
TAG_HOSTNAME | Lost-pet QR landing (tag.host/ABC123 → /p/ABC123) | vetty-api (via rewrite middleware) |
DOCS_HOSTNAME | Docs / runbook site (Docusaurus) | vetty-docs |
DB_HOSTNAME | phpMyAdmin — only running under --profile admin | vetty-phpmyadmin |
All hostnames get individual Let's Encrypt certs via Traefik's
letsencrypt resolver. The first request to a fresh host triggers
ACME HTTP-01 — takes 15–60 s for the cert to land, subsequent
requests are hot.
Runtime services
| Service | Container name | Command | Role |
|---|---|---|---|
| API | vetty-api | api (nginx + php-fpm via supervisord) | Serves /api/v1/* + Filament panels |
| Queue worker | vetty-queue | queue:work --queue=notifications,default --tries=3 --max-time=3600 | Drains queued jobs (reminders, receipts, caregiver invites) |
| Scheduler | vetty-scheduler | schedule:work | Cron-triggered tasks — subscription lifecycle, weekly reports |
| Migrate | vetty-migrate | migrate --force (one-shot) | Runs pending migrations. In deploy compose profile — only invoked by deploy.sh. |
| Admin SPA | vetty-admin-ui | nginx | Serves ui/build/. REACT_APP_API_BASE_URL is baked at build time from API_HOSTNAME. |
| Docs | vetty-docs | nginx | Serves docs/build/. Docusaurus is built inside the Dockerfile — no host node install needed. |
| phpMyAdmin | vetty-phpmyadmin | apache | DB browser at DB_HOSTNAME. Under admin compose profile — bring up when needed. |
| MySQL | vetty-db | mysql:8.0 | Persistent volume vetty-db-data. Never on web network — only reachable from services on vetty-internal. |
| Redis | vetty-redis | redis:7-alpine, AOF+everysec | Persistent volume vetty-redis-data. Cache + queue + sessions. AOF ensures queued jobs survive restarts (P1-16 durability finding). |
All app-tier services share the vetty-api image built by
docker/backend/Dockerfile. One composer install builds it,
supervisord dispatches based on the container command arg
(api / queue / scheduler / migrate) via
docker/backend/entrypoint.sh.
Traefik integration
Traefik runs as a separate compose stack on the same VPS
(typically at /opt/traefik). It joins the vetty stack via the
external web docker network. Two files matter:
traefik.yml(static config) — entrypoints, ACME resolver.traefik-dynamic.yml(dynamic file provider) — the middlewares that vetty's routers reference via@filesuffix. Ships asdocker/traefik-dynamic.yml.examplein the repo.
The dynamic file defines:
| Middleware | What it does |
|---|---|
vetty-headers | Security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP for Filament + wss: for Livewire). |
vetty-rate-limit | API-wide throttle. |
vetty-tag-rate-limit | Tighter throttle on the lost-pet QR landing (prevents slug enumeration). |
vetty-dbadmin-auth | Optional htpasswd basic-auth for DB_HOSTNAME. Off by default. |
deploy.sh pre-flights that the dynamic config file exists at one of
the standard paths (/etc/traefik/dynamic/vetty.yml etc.) and dies
loud if it isn't there. Without the dynamic file, Traefik silently
strips the middleware chain and the API + tag hosts ship without
security headers or rate limits.
Deploy flow (deploy.sh)
Key characteristics:
- Idempotent: safe to re-run at any time. Every step is guarded.
- Fails loud: pre-flight checks abort the deploy before anything
mutative.
composer.lockdrift, missing Traefik middlewares, or a working tree with uncommitted changes all abort. - Reference-data sync is destructive:
RolePermissionSeeder'ssyncPermissionSlugs()is a full re-sync — every deploy brings tenants' role_permission rows back in line with the currentPermissionRegistry. This is the antidote to the "permission catalog drift" bugs that hit us early. - Route cache regeneration: the entrypoint runs
optimize:clearbeforeroute:cacheon every api boot, so a stale cached route file from a botched intermediate build can never poison the new container.
Storage volumes
Named volumes (never anonymous — they'd get pruned):
| Volume | Contents | Backup priority |
|---|---|---|
vetty-db-data | MySQL data directory | Critical — full daily mysqldump |
vetty-redis-data | Redis AOF + RDB snapshots | High — losing this drops queued jobs |
vetty-storage | Laravel storage/app (uploads, receipts, pet documents) | Critical — user-uploaded content |
vetty-logs | Laravel logs | Ephemeral — safe to lose |
Backup script: docker/backup.sh (invoked automatically pre-deploy).
Restore drill: docker/restore-drill.sh (should be run once per
quarter minimum to prove backups actually restore).
Environment variables
Full annotated example in docker/.env.production.example. Highest-
value entries:
| Var | Purpose |
|---|---|
APP_KEY | Laravel encryption key. Auto-generated by deploy.sh if blank. |
APP_URL | Must equal https://${API_HOSTNAME} — powers Sanctum stateful, mail links. |
TRUSTED_PROXIES=* | Trust the Traefik proxy — required for HTTPS redirect + real client IP. |
FORCE_HTTPS=true | Force url() helpers to https://. |
DB_*, REDIS_* | Connection strings for the internal MySQL + Redis containers. |
SESSION_DOMAIN | Leave blank OR set to .videocreater.cloud if you want session cookies to work across subdomains. |
OPERATOR_INITIAL_EMAIL / OPERATOR_INITIAL_PASSWORD | Auto-bootstrap the first SystemAdmin. If unset, deploy.sh prompts you to run operator:create interactively. |
RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET / RAZORPAY_WEBHOOK_SECRET | Live payment credentials. |
SENTRY_LARAVEL_DSN | Optional. When set AND the sentry/sentry-laravel package is installed, exceptions flow to Sentry instead of just the log file. |
Post-deploy smoke test
docker/smoke-operator.sh runs automatically at the end of every
deploy.sh and verifies:
system_adminspopulated (login is possible)- Both Filament panels return 200 on their login pages
- No stale
require.permission:notification.readmiddleware persists - Route cache is fresh
package:discoversucceeds (Filament property overrides valid)- Permission catalog synced (proxy check for RolePermissionSeeder success)
Non-fatal on failure — it reports issues but doesn't roll back.
Optional add-ons
phpMyAdmin
Off by default (in admin compose profile). Bring up when needed:
docker compose -f docker/compose.yml --profile admin up -d vetty-phpmyadmin
Authenticates against the DB via phpMyAdmin's own login form. To add
a Traefik basic-auth gate in front, wire the vetty-dbadmin-auth@file
middleware from the dynamic config example.
Sentry (Laravel side)
Optional install; the bootstrap/app.php exception hook is gated on
class_exists() + env var.
docker compose -f docker/compose.yml exec vetty-api composer require sentry/sentry-laravel
echo "SENTRY_LARAVEL_DSN=https://<key>@sentry.io/<project>" >> docker/.env
docker compose -f docker/compose.yml up -d --force-recreate vetty-api
Docs site
vetty-docs builds docs/ (Docusaurus) inside its own Dockerfile and
serves the static output at ${DOCS_HOSTNAME}. Rebuilds on every
deploy.sh. Local preview: cd docs && npm start (localhost:3000).
Scaling notes
Single-VPS is the shipping topology. When it becomes the bottleneck, the split points in order of least disruption:
- Move MySQL to a managed service (RDS / Aiven).
vetty-dbgoes away;DB_HOSTin.envpoints elsewhere. Nothing else changes. - Move Redis to a managed service. Same pattern.
- Add a second
vetty-apireplica on a second VPS. Traefik already load-balances by label, so this is a Compose + DNS change. - Split queue workers onto a dedicated VPS.
vetty-queuemoves; everything else stays.
None of these are needed until a single 2 vCPU / 4 GB VPS starts saturating — with the current stack that's roughly 100–150 active clinics.