Operations

Deployment

Deploy externa-core like a standard Laravel + Vite app, then ensure Externa-specific workers, scheduler jobs, and AI/file storage are wired. Local setup details remain in Installation. Upgrading an existing host: Upgrade. Backups: Backup & restore.

Deploy checklist

1. Environment secrets

Copy .env.example → production env store and set at least:

VariableProduction note
APP_ENVproduction
APP_DEBUGfalse
APP_KEYUnique; never reuse from another env
APP_URLCanonical URL users browse (prefer HTTPS; required for passkeys). No trailing-slash / proxy mismatch
DB_*Production database (see SQLite swap below)
SESSION_DRIVER / CACHE_STORE / QUEUE_CONNECTIONPrefer database or Redis — not array/sync
INITIAL_SUPER_ADMIN_*Strong password before first seed
AI_WEBHOOK_TOKENLong random HMAC secret if collection-import webhooks are used
LOCAL_AI_* / cloud provider keysPoint at a reachable gateway; do not commit keys
AI_REMOTE_IMPORT_HOSTSTight allowlist if remote import is enabled
MailReal MAIL_* if verification / password reset emails matter

Full catalog: Environment variables.

After changing env on a config-cached host:

php artisan config:cache
# or config:clear during debugging

2. Build & migrate

composer install --no-dev --optimize-autoloader
npm ci
npm run build
php artisan migrate --force

Use your platform’s release process (zero-downtime, maintenance mode, etc.) as appropriate.

3. Seed / sync permissions

Fresh database:

php artisan db:seed --force

Runs PermissionSeederRoleSeederCreateSuperAdminSeeder.

Existing database after permission enum changes:

php artisan permissions:sync
# optional cleanup:
php artisan permissions:sync --prune

Do not re-seed blindly in production if it would reset roles or recreate a known super-admin password unexpectedly. Prefer sync + controlled role admin UI.

php artisan storage:link

Required for public / assets disk URLs (/storage/...). See Files configuration. Ensure storage/ and bootstrap/cache/ are writable by the app user.

5. Queue worker (+ optional Reverb / Pulse)

Prefer Redis + Horizon when available:

php artisan horizon

Or a plain worker:

php artisan queue:work --tries=3 --timeout=300

If using the full realtime/observability stack, also supervise:

  • A Reverb (or compatible) WebSocket server with matching BROADCAST_* / REVERB_* / built VITE_REVERB_*
  • php artisan pulse:work when PULSE_INGEST_DRIVER=redis

Dashboard Health surfaces native Pulse/Horizon metrics (no primary Open Pulse/Horizon CTAs). Restart workers on every deploy. Details: Operations · Minimal vs full stack.

6. Scheduler crontab

* * * * * cd /path/to/externa-core && php artisan schedule:run >> /dev/null 2>&1

This runs:

  • activitylog:clean (daily)
  • files:cleanup-uploads / files:cleanup-zips (hourly)
  • ai:cleanup-attachments (daily)
  • ai:run-sync-sources (every minute)

7. AI gateway

If the assistant is enabled in production:

  1. Set AI_DEFAULT_PROVIDER (default local) and the matching provider keys/URL.
  2. Ensure the app server can reach LOCAL_AI_URL (or the cloud provider) with enough timeout for tool loops (AppAssistant uses 300s). Behind nginx/Herd, also raise fastcgi_read_timeout (often ≥ 600s) so long local-model reasoning does not drop the browser SSE.
  3. Prefer tool-calling-capable models.
  4. Set AI_WEBHOOK_TOKEN if using collection import webhooks; callers must HMAC-sign body + "\n" + collection_id (X-AI-Webhook-Signature). Keep CSRF exception only for that path (machine clients).

See AI configuration.

8. APP_URL and proxies

  • Match the public scheme/host (Herd, reverse proxy, CDN).
  • Set trusted proxies if TLS terminates upstream.
  • Optionally set FILE_PUBLIC_URL_BASE when asset URLs should use a CDN origin while the app stays on another host.
  • The CMS (password, TOTP, APIs) can run on HTTP, but prefer HTTPS in production.
  • Passkeys: require HTTPS in production; APP_URL must match Fortify WebAuthn relying_party_id / allowed_origins. See Passkeys — Requirements.

9. Production database (leave SQLite)

.env.example defaults to DB_CONNECTION=sqlite for local ease. For production, pick from the Supported databases matrix (PostgreSQL recommended; MySQL 8+ / MariaDB 10.6+ also supported).

  1. Provision Postgres 14+ (16 preferred), MySQL 8+, or MariaDB 10.6+.
  2. Set DB_CONNECTION (pgsql | mysql | mariadb), DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD.
  3. MySQL/MariaDB: keep utf8mb4 / utf8mb4_unicode_ci.
  4. Run migrations against the new database.
  5. Keep session/cache/queue tables on a durable store (database or Redis).

SQLite ceilings

SQLite is fine for demos and single-user local work. Concurrent writers, large activity logs, and multi-worker queues push you toward Postgres/MySQL/MariaDB quickly.

Docker production

Prefer the copy-paste operator guide: Deploy with Docker (GHCR quick Compose, gated first-boot, healthchecks).

Official build-from-source path uses the multi-stage Dockerfile and compose.prod.yaml. Sail is not for production (and not required locally either).

Image stages

TargetRole
php-basePHP 8.4-fpm + extensions (pgsql/mysql, redis, gd, zip, …)
buildcomposer install --no-dev + npm ci + npm run build (Wayfinder needs vendor/PHP)
viteLocal Compose only — PHP + Node for HMR
developmentnginx + php-fpm; source bind-mounted
productionBaked app from build + nginx + php-fpm + prod opcache

Roles via docker/entrypoint.sh: app, horizon, reverb, scheduler, pulse, queue.

Multi-arch images (amd64 + arm64)

Production and development targets build for linux/amd64 and linux/arm64 (Apple Silicon + typical VPS).

# Bake both platforms (Buildx). Fails if either arch fails.
docker buildx bake -f docker-bake.hcl production

# Helper wrapper (creates builder `externa-multiarch` if needed):
./scripts/docker-buildx.sh production

# Optional push to a registry (set TAG first):
# TAG=ghcr.io/qiick-io/externa-core:1.0.0-beta.3 PUSH=1 ./scripts/docker-buildx.sh production

CI: on pushes to develop / main, the multiarch-bake job runs QEMU + Buildx for both targets. Pull requests keep the faster single-arch image-build job. GHCR publish runs on v* tags (and workflow_dispatch) — image ghcr.io/qiick-io/externa-core. Quick pull path: Deploy with Docker.

Compose on Apple Silicon usually pulls/builds the matching arch automatically. To pin:

services:
  app:
    platform: linux/arm64 # or linux/amd64

Bring up

cp .env.docker.prod.example .env
# Set APP_KEY, strong DB_PASSWORD / REVERB_*, real mail, TRUSTED_PROXIES
# Recommended: FILES_DISK=s3 + AWS_*
docker compose -f compose.prod.yaml up --build -d

Default published app port is host 8080→80 (APP_PORT in the prod sample — different from local :8000). Reverb still defaults to host 8081→8080.

PieceNotes
Servicesapp, horizon, reverb, scheduler, pulse, pgsql, redisno Vite, Mailpit, or MinIO
VolumesNamed externa_prod_storage for storage/ (zips + local disks); DB/Redis volumes when Compose-hosted
MigrationsRUN_MIGRATIONS=true (sample default) → migrate --force on app boot
Config cacheCACHE_CONFIG=trueconfig / route / view / event:cache
HealthcheckGET /health/readystart_period: 60s (image already baked)

Proxy, TLS, WebSockets

Copy-paste Caddy / Traefik / nginx: Reverse proxy cookbook.

Put TLS in front (Caddy / Traefik / nginx):

  1. Set APP_URL=https://your.domain and TRUSTED_PROXIES=* (or your proxy CIDRs). Prefer SESSION_SECURE_COOKIE=true behind HTTPS.
  2. Proxy HTTP to app:80 (or published APP_PORT).
  3. Proxy WebSocket Upgrade / Connection to the Reverb service (reverb:8080 on the Compose network, or published REVERB_HOST_PORT).
  4. Point public REVERB_HOST / REVERB_PORT / REVERB_SCHEME (and matching VITE_REVERB_*) at the browser-facing WebSocket URL. Rebuild the production image when Vite Reverb env baked into assets changes.

Storage / S3

Prefer FILES_DISK=s3 in production so app replicas do not need sticky local binds for CMS files. Prepared zips under storage/app/zips remain local ephemeral — share the storage volume with Horizon (Compose already does) or accept zip loss across hosts. See Files configuration.

Managed DB / Redis (Compose overlay)

When Postgres and Redis run outside Compose (RDS, Upstash, etc.):

cp .env.docker.prod.managed.example .env
# Set APP_KEY, DB_HOST, REDIS_HOST, secrets, TRUSTED_PROXIES, browser-facing REVERB_*
docker compose -f compose.prod.yaml -f compose.prod.managed.yaml up --build -d
  • Starts app + horizon + reverb + scheduler + pulse only.
  • Bundled pgsql / redis services are behind profiles (bundled-db / bundled-redis) and do not start.
  • GET /health/ready still probes the external DB and Redis when drivers require them.
  • Network/TLS: allow the app containers to reach managed endpoints (security groups / VPC / TLS redis as required by the provider).

Full bundled stack remains: docker compose -f compose.prod.yaml up --build -d.

Local Docker full stack: Installation — Docker Compose.

Health probes

Auth-free machine probes (not the admin Pulse/Health UI):

EndpointMeaningResponse
GET /upLaravel liveness200 when the app boots
GET /health/liveProcess upJSON { "status": "ok" }
GET /health/readyReady for trafficJSON { "status", "checks" }; 503 if DB (or required Redis) fails

Redis is checked when QUEUE_CONNECTION, CACHE_STORE, SESSION_DRIVER, or BROADCAST_CONNECTION is redis.

Compose example (local cold boot — long start_period):

healthcheck:
  test: ['CMD-SHELL', 'curl -fsS http://127.0.0.1/health/ready || exit 1']
  interval: 10s
  timeout: 5s
  retries: 12
  start_period: 300s

Production Compose uses a shorter start_period (baked image, typically 60s).

Smoke test after deploy

  1. GET /up and GET /health/ready → healthy JSON.
  2. Login as super admin (or your provisioned admin) — password and, if enabled, Sign in with passkey.
  3. Open dashboard, files, collections (permissions permitting).
  4. Trigger a small queued job (e.g. zip) and confirm Horizon/worker processes it.
  5. If AI is enabled: GET /ai/status and a short chat with a read-only tool.
  6. Confirm scheduled cleanups appear in logs after the next hour/day.
Previous
Minimal vs full stack