# Deployment

Source: https://docs.externa.qiick.io/docs/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](/docs/installation). Upgrading an existing host: [Upgrade](/docs/upgrade). Backups: [Backup & restore](/docs/backup-restore).

> Minimum safe patches: **8.4.27+** or **8.5.12+** (externa-core **1.2.2+** requires `php: ^8.4.27`). After upgrading on bare metal / Forge / Herd-style hosts, **restart php-fpm** and confirm both CLI (`php -v`) and the FPM worker. Full notice: [discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Hardening below is **not** a fix — apply it and keep it after patching. Checklist: [Security checklist](/docs/security-checklist).

## PHP runtime hardening

Recommended for self-hosted PHP-FPM (Herd, Forge, bare metal). Official Docker images already ship these defaults in [`docker/php/php.ini`](https://github.com/qiick-io/externa-core/blob/develop/docker/php/php.ini) ([core#216](https://github.com/qiick-io/externa-core/issues/216)).

```ini
; Not a substitute for upgrading PHP — keep after patching.
session.upload_progress.enabled = Off
session.use_strict_mode = 1

; Match nginx client_max_body_size (official image uses 100M).
upload_max_filesize = 100M
post_max_size = 100M
max_input_vars = 3000
max_input_nesting_level = 64
max_file_uploads = 20
```

For PHP-FPM, lock upload progress per pool so `.user.ini` cannot re-enable it:

```ini
php_admin_flag[session.upload_progress.enabled] = off
```

Externa does **not** rely on PHP session upload progress, so turning it off has no functional impact. It only blocks the upload-progress path of CVE-2026-109619; the PHP advisory still recommends keeping it Off after patching.

**Do not** set `PHP_VALUE` / `PHP_ADMIN_VALUE` via `fastcgi_param` in nginx — the advisory lists INI changes passed that way as an exploitation path. Configure limits in php.ini / the FPM pool instead.

After any PHP package upgrade:

```bash
# Debian/Ubuntu-style
sudo systemctl restart php8.4-fpm
php -v
# Confirm the same version in FPM (phpinfo or pool status), not only CLI
```

## Deploy checklist

### 1. Environment secrets

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

| Variable                                              | Production note                                                                                          |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `APP_ENV`                                             | `production`                                                                                             |
| `APP_DEBUG`                                           | `false`                                                                                                  |
| `APP_KEY`                                             | Unique; never reuse from another env                                                                     |
| `APP_URL`                                             | Canonical 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_CONNECTION` | Prefer `database` or Redis — not `array`/`sync`                                                          |
| `INITIAL_SUPER_ADMIN_*`                               | Strong password **before** first seed                                                                    |
| `AI_WEBHOOK_TOKEN`                                    | Long random HMAC secret if collection-import webhooks are used                                           |
| `LOCAL_AI_*` / cloud provider keys                    | Point at a reachable gateway; do not commit keys                                                         |
| `AI_REMOTE_IMPORT_HOSTS`                              | Tight allowlist if remote import is enabled                                                              |
| Mail                                                  | Real `MAIL_*` if verification / password reset emails matter                                             |

Full catalog: [Environment variables](/docs/environment-variables).

After changing env on a config-cached host:

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

### 2. Build & migrate

```bash
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:**

```bash
php artisan db:seed --force
```

Runs `PermissionSeeder` → `RoleSeeder` → `CreateSuperAdminSeeder`.

**Existing database after permission enum changes:**

```bash
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.

### 4. Storage link

```bash
php artisan storage:link
```

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

### 5. Queue worker (+ optional Reverb / Pulse)

Prefer Redis + Horizon when available:

```bash
php artisan horizon
```

Or a plain worker:

```bash
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](/docs/operations) · [Minimal vs full stack](/docs/minimal-vs-full-stack).

### 6. Scheduler crontab

```cron
* * * * * 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 the **raw request body** only (`X-AI-Webhook-Signature`; **401** on failure from 1.2.2). Keep CSRF exception only for that path (machine clients).

See [AI configuration](/docs/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](/docs/passkeys#requirements).

### 9. Production database (leave SQLite)

`.env.example` defaults to `DB_CONNECTION=sqlite` for local ease. For production, pick from the [Supported databases](/docs/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 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](/docs/deploy-with-docker)** (GHCR quick Compose, gated first-boot, healthchecks).

> `ghcr.io/qiick-io/externa-core:1.2.2` and `latest` ship **PHP 8.4.27** with hardened `docker/php/php.ini` (Composer requires `^8.4.27` from 1.2.2). Re-pull and recreate containers. Building from the Dockerfile: use `docker build --pull --no-cache`. See [Deploy with Docker](/docs/deploy-with-docker) and [discussion #247](https://github.com/qiick-io/externa-core/discussions/247).

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

| Target        | Role                                                                                  |
| ------------- | ------------------------------------------------------------------------------------- |
| `php-base`    | PHP 8.4-fpm + extensions (pgsql/mysql, redis, gd, zip, …)                             |
| `build`       | `composer install --no-dev` + `npm ci` + `npm run build` (Wayfinder needs vendor/PHP) |
| `vite`        | Local Compose only — PHP + Node for HMR                                               |
| `development` | nginx + php-fpm; source bind-mounted                                                  |
| `production`  | Baked 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).

```bash
# 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 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](/docs/deploy-with-docker).

**Docker build notes (beta.4+):** `php-base` pins **phpredis 6.3.0** from the GitHub tarball (avoids flaky `pecl install redis` on multi-arch bake). npm `prepare` no-ops Lefthook when `.git` is absent so `image-build` stays green (#94).

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

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

### Bring up

```bash
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**.

| Piece        | Notes                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| Services     | `app`, `horizon`, `reverb`, `scheduler`, `pulse`, `pgsql`, `redis` — **no** Vite, Mailpit, or MinIO    |
| Volumes      | Named `externa_prod_storage` for `storage/` (zips + local disks); DB/Redis volumes when Compose-hosted |
| Migrations   | `RUN_MIGRATIONS=true` (sample default) → `migrate --force` on app boot                                 |
| Config cache | `CACHE_CONFIG=true` → `config` / `route` / `view` / `event:cache`                                      |
| Healthcheck  | `GET /health/ready` — `start_period: 60s` (image already baked)                                        |

### Proxy, TLS, WebSockets

Copy-paste Caddy / Traefik / nginx: [Reverse proxy cookbook](/docs/reverse-proxy).

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](/docs/files-configuration).

### Managed DB / Redis (Compose overlay)

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

```bash
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](/docs/installation#docker-compose-local-full-stack).

## Health probes

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

| Endpoint            | Meaning           | Response                                                               |
| ------------------- | ----------------- | ---------------------------------------------------------------------- |
| `GET /up`           | Laravel liveness  | 200 when the app boots                                                 |
| `GET /health/live`  | Process up        | JSON `{ "status": "ok" }`                                              |
| `GET /health/ready` | Ready for traffic | JSON `{ "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`):

```yaml
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.

## Related

- [Operations](/docs/operations) — ongoing worker/scheduler/observability
- [Horizon](/docs/horizon) · [Reverb & Echo](/docs/reverb-and-echo) · [Pulse & Health](/docs/pulse-and-health)
- [Configuration](/docs/configuration) — app/Fortify/session defaults
- [Passkeys](/docs/passkeys) · [Threat model & hosting](/docs/threat-model) · [Security checklist](/docs/security-checklist)
- [Environment variables](/docs/environment-variables) · [Minimal vs full stack](/docs/minimal-vs-full-stack)
- [Supported databases](/docs/supported-databases) — versions, utf8mb4, CI jobs
- [Deploy with Docker](/docs/deploy-with-docker) — GHCR quick Compose, gated first-boot
- [Installation](/docs/installation) — Herd / `composer setup` / Docker local
- [Files configuration](/docs/files-configuration) — `FILES_DISK` / S3 / MinIO
