Skip to main content

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 varPurposeBacking container
API_HOSTNAMEJSON API + Filament operator/systemadmin panelsvetty-api
ADMIN_HOSTNAMEAdmin SPA (React)vetty-admin-ui
TAG_HOSTNAMELost-pet QR landing (tag.host/ABC123/p/ABC123)vetty-api (via rewrite middleware)
DOCS_HOSTNAMEDocs / runbook site (Docusaurus)vetty-docs
DB_HOSTNAMEphpMyAdmin — only running under --profile adminvetty-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

ServiceContainer nameCommandRole
APIvetty-apiapi (nginx + php-fpm via supervisord)Serves /api/v1/* + Filament panels
Queue workervetty-queuequeue:work --queue=notifications,default --tries=3 --max-time=3600Drains queued jobs (reminders, receipts, caregiver invites)
Schedulervetty-schedulerschedule:workCron-triggered tasks — subscription lifecycle, weekly reports
Migratevetty-migratemigrate --force (one-shot)Runs pending migrations. In deploy compose profile — only invoked by deploy.sh.
Admin SPAvetty-admin-uinginxServes ui/build/. REACT_APP_API_BASE_URL is baked at build time from API_HOSTNAME.
Docsvetty-docsnginxServes docs/build/. Docusaurus is built inside the Dockerfile — no host node install needed.
phpMyAdminvetty-phpmyadminapacheDB browser at DB_HOSTNAME. Under admin compose profile — bring up when needed.
MySQLvetty-dbmysql:8.0Persistent volume vetty-db-data. Never on web network — only reachable from services on vetty-internal.
Redisvetty-redisredis:7-alpine, AOF+everysecPersistent 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 @file suffix. Ships as docker/traefik-dynamic.yml.example in the repo.

The dynamic file defines:

MiddlewareWhat it does
vetty-headersSecurity headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP for Filament + wss: for Livewire).
vetty-rate-limitAPI-wide throttle.
vetty-tag-rate-limitTighter throttle on the lost-pet QR landing (prevents slug enumeration).
vetty-dbadmin-authOptional 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.lock drift, missing Traefik middlewares, or a working tree with uncommitted changes all abort.
  • Reference-data sync is destructive: RolePermissionSeeder's syncPermissionSlugs() is a full re-sync — every deploy brings tenants' role_permission rows back in line with the current PermissionRegistry. This is the antidote to the "permission catalog drift" bugs that hit us early.
  • Route cache regeneration: the entrypoint runs optimize:clear before route:cache on 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):

VolumeContentsBackup priority
vetty-db-dataMySQL data directoryCritical — full daily mysqldump
vetty-redis-dataRedis AOF + RDB snapshotsHigh — losing this drops queued jobs
vetty-storageLaravel storage/app (uploads, receipts, pet documents)Critical — user-uploaded content
vetty-logsLaravel logsEphemeral — 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:

VarPurpose
APP_KEYLaravel encryption key. Auto-generated by deploy.sh if blank.
APP_URLMust equal https://${API_HOSTNAME} — powers Sanctum stateful, mail links.
TRUSTED_PROXIES=*Trust the Traefik proxy — required for HTTPS redirect + real client IP.
FORCE_HTTPS=trueForce url() helpers to https://.
DB_*, REDIS_*Connection strings for the internal MySQL + Redis containers.
SESSION_DOMAINLeave blank OR set to .videocreater.cloud if you want session cookies to work across subdomains.
OPERATOR_INITIAL_EMAIL / OPERATOR_INITIAL_PASSWORDAuto-bootstrap the first SystemAdmin. If unset, deploy.sh prompts you to run operator:create interactively.
RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET / RAZORPAY_WEBHOOK_SECRETLive payment credentials.
SENTRY_LARAVEL_DSNOptional. 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_admins populated (login is possible)
  • Both Filament panels return 200 on their login pages
  • No stale require.permission:notification.read middleware persists
  • Route cache is fresh
  • package:discover succeeds (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:

  1. Move MySQL to a managed service (RDS / Aiven). vetty-db goes away; DB_HOST in .env points elsewhere. Nothing else changes.
  2. Move Redis to a managed service. Same pattern.
  3. Add a second vetty-api replica on a second VPS. Traefik already load-balances by label, so this is a Compose + DNS change.
  4. Split queue workers onto a dedicated VPS. vetty-queue moves; 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.