Operations

Markdown

Upgrade

Stay on a newer Externa release after first install. Code comes from Git (or a Docker image tag). php artisan externa:upgrade runs migrate / permissions sync / cache clear by default — it does not pull code unless you pass opt-in --sync-upstream.

Mental model (read this first)

FactMeaning
composer create-projectInstall once. Copies the app tree into a new directory. It is not how you receive later Externa releases.
Your git remoteAfter you git init / push that tree to your repo, that remote tracks your commits — it does not auto-update from qiick-io/externa-core.
Packagist type: projectSame scaffold story: Composer does not replace your app on later composer update.
Recommended update channelGit: clone (or add upstream) and checkout / merge a release tag vX.Y.Z.
php artisan externa:upgradeDefault: after code is already on the new release — migrate --force, permissions:sync, optimize:clear. No composer/npm. Opt-in: --sync-upstream=vX.Y.Z can merge the official tag first.
DockerPull / pin a newer GHCR image tag; keep named volumes; then migrate inside the container (or rely on RUN_MIGRATIONS).

create-project ≠ update channel

Operators who started with Packagist and then pushed to their own GitHub/GitLab still need a deliberate code update (git tag / upstream merge / --sync-upstream, or Docker image bump). Point them here from Packagist, Installation, Quick Start, and the core README.

Security: PHP 8.4.27 / 8.5.12 (CVE-2026-109618 / CVE-2026-109619)

Upgrade the host PHP runtime to 8.4.27+ or 8.5.12+ (CVEs in PHP itself, not Externa — GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp). From externa-core 1.2.2, Composer requires php: ^8.4.27 (#248). After upgrading, restart php-fpm and verify CLI + FPM. Official ghcr.io/qiick-io/externa-core:1.2.2 and latest ship PHP 8.4.27 with hardened php.ini — re-pull and recreate containers; source builds use docker build --pull --no-cache. Full notice: discussion #247. Keep session.upload_progress Off after patching — Deployment — PHP runtime hardening.

Version-specific notes

1.3.x → 1.4.0

PostgreSQL 15+ required

On PostgreSQL, 1.4.0 rebuilds the collections_items_values unique index with NULLS NOT DISTINCT (#278). That syntax needs PostgreSQL 15 or newer; on 14 the migration fails. Upgrade the database server first. The same migration deletes older duplicate rows for non-translatable values (it keeps the newest row per item / field / position), so take a backup before migrating. SQLite, MySQL, and MariaDB skip this migration. The bundled Compose files already use postgres:16-alpine.

Upgrade steps (source)

  1. Back up the database and storage/ — Backup & restore.
  2. Checkout / merge tag v1.4.0 (or php artisan externa:upgrade --sync-upstream=v1.4.0).
  3. composer install --no-dev --optimize-autoloader — new packages include laravel/mcp, spatie/laravel-google-cloud-storage, and azure-oss/storage-blob-laravel.
  4. npm ci && npm run build
  5. php artisan externa:upgrade (add --force in production) — runs migrate --force, permissions:sync, and optimize:clear.
  6. Restart queue workers, scheduler, Horizon (and Reverb / Pulse if you run them). The scheduler now also runs ai:prune-tool-audit daily.
  7. Review the behaviour changes below, then smoke-test login, one collection, the file manager, and AI chat.

Docker: pull ghcr.io/qiick-io/externa-core:1.4.0, recreate containers, and run php artisan externa:upgrade --force in the app container (or rely on RUN_MIGRATIONS=true, then run php artisan permissions:sync). See the www-data and DB_PASSWORD notes below.

New AI settings and removed packages

  • Assistant reasoning and context window (#570): the Assistant now asks real providers for medium reasoning effort, which is slower and uses more tokens. Tune it or turn it off with OPENAI_REASONING_EFFORT, ANTHROPIC_REASONING_EFFORT and GEMINI_REASONING_EFFORT (low / medium / high; any other value or empty = off). LOCAL_AI_REASONING_EFFORT is unset (none) by default. AI_ASSISTANT_PROVIDER picks the Assistant's provider (default local), and OPENAI_CONTEXT_WINDOW / ANTHROPIC_CONTEXT_WINDOW / GEMINI_CONTEXT_WINDOW / LOCAL_AI_CONTEXT_WINDOW set the context size shown next to an answer's token usage — AI configuration. None of these need to be set; the defaults apply when they are missing.
  • Removed packages (#520, #568): laravel/sail, @headlessui/react, the unused @radix-ui/react-* packages (the UI imports from radix-ui) and the Wayfinder Vite plugin are gone. Run composer install and npm ci after pulling; if your fork imports any of them, add them back in your own composer.json / package.json.

What externa:upgrade now does for you

  • files.effective_access backfill (#186, #434): the migrations add and compute the denormalized access column for every existing file. No manual step. php artisan files:backfill-effective-access stays available and is safe to re-run. A file whose access was never computed is now hidden from roles without read_private instead of being treated as public.
  • API key owners (#215, #439): new api_keys.owner_id. Each existing key is owned by its creator when that user is still active; otherwise by the first active super-admin, then admin. Keys stay ownerless only when no admin exists. Effective access becomes key role ∩ key scopes ∩ owner permissions, so review keys that were re-owned. Keys are revoked when their owner is deactivated or deleted.
  • New admin permissions (#439): permissions:sync creates deploy.trigger and ai.prompts.manage and grants newly created permissions to the built-in admin role. Permissions you removed from admin before stay removed. Grant them to other roles yourself — Roles & permissions.
  • Other new tables and columns: api_keys.files_scope (existing keys → all), api_keys.scopes (null = no MCP access), pending_tool_calls, ai_prompts, and sort indexes on collections_items.

Behaviour changes

  • AI write tools are denied by default (#203). With AI_WRITE_TOOLS=false (default), the assistant cannot create, update, delete, import, or roll back anything. To restore writes, set AI_WRITE_TOOLS=true and allowlist tool classes in AI_TOOLS_WRITE_ALLOW or Settings → AI write tools; each write then asks for approval in chat — AI tool approvals & audit.
  • Chat keeps read actions (#439): ManageCollections, ManageCollectionItems, ManageFiles, and the other Manage* tools still answer list / get / search in chat while writes are off. MCP lists Manage*-backed write tools only when the write gate allows them.
  • MCP server (opt-in): /mcp stays 404 until AI_MCP_ENABLED=true. Existing API keys have no mcp:read scope, so they cannot call MCP until you grant it — MCP server.
  • Registration off by default (#272): the registration_enabled default is now false. A value already saved in Settings → Auth & Security wins; installs that never saved it fall back to the new default and close self-registration. Re-enable it there if you rely on sign-up. Register, forgot-password, and reset-password are now throttled per IP (5 per minute), and the passkeys limiter is keyed on IP.
  • Webhook signatures and replay window (#273): outbound webhooks add X-Externa-Signature-V2 over "{timestamp}.{body}"; the legacy X-Externa-Signature stays for one minor — move receivers to V2 (Outbound webhooks). Breaking for inbound AI collection-import callers: they must send X-AI-Webhook-Timestamp (within 300 s), sign "{timestamp}.{body}", and never resend the same signature (replays are rejected) — AI API.
  • Docker workers run as www-data (#320): Horizon, Reverb, scheduler, Pulse, and queue workers drop from root via setpriv. Bind-mounted storage/ or bootstrap/cache volumes created as root may need a one-time chown -R www-data:www-data storage bootstrap/cache (or a recreated volume). The entrypoint still chowns when it starts as root.
  • Docker refuses placeholder DB passwords in production (#272): with APP_ENV=production, the entrypoint exits when DB_PASSWORD is empty, change-me, changeme, password, or secret.
  • Security headers on by default (#187): admin pages now send X-Frame-Options: DENY, a CSP, Referrer-Policy, and Permissions-Policy. If you embed the admin in an iframe, load admin assets from another origin, or serve previews from a disk without a url, adjust SECURITY_* or set SECURITY_CSP — Security headers.
  • SSRF hardening (#274): remote fetches deny CGNAT 100.64.0.0/10 and pin DNS; OIDC discovery and endpoints are now SSRF-checked. A lab IdP on a private or CGNAT host needs OIDC_ALLOW_PRIVATE_HOSTS=true — SSO / OIDC.
  • APP_DEBUG=false is the new .env.example default. Existing .env files are not touched; keep it false in production.
  • API key files_scope (core#322): new column api_keys.files_scope (none | read | all). Migration sets existing keys to all (same Files API behaviour as 1.3.0). New keys with collection_slugs default to none; unscoped keys default to all (or read when read_only). After upgrade, review scoped partner keys and tighten files_scope where least privilege is needed — API abuse controls.
  • Upload type policy (core#327 / PR #362): Settings → Files & Media gains optional extension/MIME allowlists and an Allow SVG opt-in (off by default). Empty allowlists keep the 1.3.0 config/files.php defaults. The hard floor (executables, HTML, SSI .shtml, etc.) still cannot be disabled. Review SVG only if you need logos/icons and accept XSS risk when SVG is served or inlined — File manager — Upload security.

New in 1.4.0 (opt-in, no action needed): batch item writes, system-column and relational filters, sort, Live Preview as_of, deploy hooks, field conversion, GCS / Azure disks, theme presets. Env keys: Environment variables. Full list: core CHANGELOG.md.

1.2.x → 1.3.0

Upgrade steps (source)

  1. Checkout / merge tag v1.3.0.
  2. composer install
  3. npm ci && npm run build
  4. php artisan migrate — adds schedule_error / schedule_failed_at on collections_items (core PR #303)
  5. Set AI_WEBHOOK_TOKEN (required — fail-closed in every environment)
  6. php artisan optimize:clear
  7. Restart queue workers / scheduler / Horizon (and Reverb / Pulse if you run them)

Docker: pull ghcr.io/qiick-io/externa-core:1.3.0 (or latest). When building from a checkout, confirm .dockerignore excludes /backups, /storage/app/*, and agent tooling folders (core PR #294).

Behaviour / breaking notes

  • Breaking — outbound webhooks / Slack SSRF (core#118 / PR #267): loopback, RFC1918, link-local, and other reserved hosts are rejected on save and at delivery unless you opt in. Delivery also refuses HTTP redirects. For LAN receivers (e.g. n8n on a private IP), set WEBHOOKS_ALLOW_PRIVATE_HOSTS=true and refresh config cache. Public HTTPS endpoints need no change — Outbound webhooks.
  • Breaking — upload MIME allowlist (same PR): after the extension denylist, uploads must sniff to a type in config/files.php upload_allowed_mimes, with magic-byte checks for claimed formats. SVG, JSON, and XML are not allowed. SSI/markup extensions (shtml, xml, …) are also denylisted (PR #314) — File manager — Upload security.
  • AI webhook fail-closed + rotation (core#185 / #207): empty AI_WEBHOOK_TOKEN rejects requests in every environment (no local unsigned bypass). Rotate with php artisan ai:rotate-webhook-token (AI_WEBHOOK_TOKEN_PREVIOUS / AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT); then config:clear / restart workers — AI API.
  • TRUSTED_PROXIES now applied (PR #295): previously documented but unread. Set behind a reverse proxy and verify client IP / HTTPS / rate limits / API key IP allowlists — Reverse proxy.
  • AuthZ — only super-admins grant/manage super-admin principals (PR #307); collection grants fail closed for ACL-scoped admins (PR #312) — AuthZ map.
  • Partial item updates — REST PATCH and GraphQL updateItem keep omitted fields/locales (PR #293).
  • OpenAPI in prod images — GET /api/v1/openapi.json works with composer install --no-dev (PR #310; symfony/yaml is a direct require).
  • UI bookmarks — project settings split (core#201 / PR #304): /settings/project is General only. Localization, Auth & Security (OIDC), Content (Live Preview), Files & Media, API, and Integrations each have their own path. Sidebar modules moved to /settings/appearance/sidebar. Deprecated test aliases (project.webhook-test, notifier tests) remain for one minor — prefer project.integrations.*. Guide: Project settings.
  • Also in 1.3.0 (non-breaking defaults): optional API cost / GraphQL / payload caps (core#113), API key scopes including ?include= / cache / Files read_only (core#116 / PR #321), field type/translatable lock with stored values (PR #265). See core CHANGELOG.md [1.3.0].

1.2.1 → 1.2.2

  • Security patch (#250). Composer php constraint is now ^8.4.27 (#248). No new migrations. See the PHP security notice above and discussion #247 — do not treat older 8.4.x as enough.
  • AI collection-import webhook: sign the raw request body only (sha256= + HMAC-SHA256). Invalid or missing signatures return 401 (was 403). Set AI_WEBHOOK_TOKEN in every non-local environment (#214) — AI API.
  • externa:install keeps an existing APP_KEY or generates one, runs the minimal profile without Redis, and can be re-run with --force (#249) — Installation.
  • Docker: pin ghcr.io/qiick-io/externa-core:1.2.2.

1.2.0 → 1.2.1

  • Hotfix for field renames dropping values from pending drafts and older revisions (#218, #223). No new migrations.
  • Right after deploying, before the scheduler runs or anyone publishes, repair drafts/revisions that still hold a pre-rename key: php artisan externa:repair-renamed-field-drafts (dry run), then --apply. See Schema changes.
  • Run npm ci && npm run build (the vite range is now ^8.3.5).
  • Docker: pin ghcr.io/qiick-io/externa-core:1.2.1 (or jump straight to 1.2.2 if you are catching up).

1.0.x → 1.1.0

  • AI chat history schema (laravel/ai ^1.0): conversations use a polymorphic participant, and messages store steps + status instead of tool_calls / tool_results. You must run php artisan migrate (included in externa:upgrade) before opening existing AI conversations. Back up first: Backup & restore.
  • Destructive AI tool actions (delete, force-delete, bulk delete, delete field, AI turn rollback) now wait for Approve / Reject in the chat.
  • Scheduled publish: make sure collections:process-schedules runs on the scheduler (php artisan schedule:work or cron).

1.1.x → 1.2.0

  • Run composer install (adds laravel/socialite ^5.31), then npm ci && npm run build, before externa:upgrade.
  • Migrations add editorial approval columns, collections_items.board_sort / published_at, and users.oidc_sub, and make users.password nullable (SSO-only users). Project settings also gain notifier mute list notifier_event_types (null = all events).
  • Run permissions:sync (included in externa:upgrade) to create can-submit-collections / can-approve-collections.
  • Restart Horizon and Reverb after deploy (presence, Meilisearch sync, and git-export jobs).
  • Docker: pin ghcr.io/qiick-io/externa-core:1.2.0.
  • All new integrations (Meilisearch, git export, Slack/Telegram, OIDC) are off until configured — Environment variables.

Before you start

  1. Backup DB + .env + storage/ — Backup & restore.
  2. Put the site in maintenance mode if operators share the host:
php artisan down
  1. Note current version (composer.json → version or Git tag).

A1. You cloned qiick-io/externa-core (or keep origin = Externa)

cd /path/to/externa-core
git fetch --tags origin
git checkout v1.0.0   # example — use the target release tag
composer install --no-dev --optimize-autoloader

Then continue at Shared steps.

A2. You create-project’d, then pushed to your remote

origin is yours. Add Externa as upstream and merge the release tag:

cd /path/to/my-externa
git remote add upstream https://github.com/qiick-io/externa-core.git   # once
git fetch --tags upstream
git merge v1.0.0   # example — resolve conflicts if you customized app code
composer install --no-dev --optimize-autoloader

Vanilla trees (few or no app edits) usually merge cleanly. Heavy forks: merge/rebase carefully, keep .env / storage / DB.

Or use the assisted CLI path below instead of the manual git steps.

Contributors on develop: merge/rebase onto the target tag or release branch, then the same composer / migrate / build steps.

Assisted sync (--sync-upstream)

Opt-in only. Plain externa:upgrade still does not pull from GitHub. Use this when you want the Artisan command to fetch the official remote and merge a release tag, then run the usual migrate / permissions / cache steps.

What it does

  1. Requires a git working tree (fails clearly if not).
  2. Ensures remote upstream → https://github.com/qiick-io/externa-core.git (adds it if missing).
  3. git fetch --tags upstream, verifies refs/tags/vX.Y.Z.
  4. Lists files that would change (git diff --name-status HEAD vX.Y.Z).
  5. Asks for confirm (“N files will be overwritten / changed — proceed with caution?”) unless --yes.
  6. git merge --no-edit vX.Y.Z — on conflict, stops (no migrate); resolve by hand.
  7. On success (or if you skipped sync): migrate + permissions:sync + optimize:clear.

Risks / when to use

  • May overwrite customized app files and can fail on heavy forks. Prefer Path A by hand if you have large local diffs.
  • Refuses a dirty tree unless --force (not recommended with sync).
  • Does not run composer / npm — install deps after code lands.
  • Prefer dry-run first.
# Preview only — no merge, no migrate
php artisan externa:upgrade --sync-upstream=v1.0.0 --dry-run

# Apply sync (confirm prompt) then migrate / permissions / cache
php artisan externa:upgrade --sync-upstream=v1.0.0

# Non-interactive apply (CI / scripts) — still fails on conflicts
php artisan externa:upgrade --sync-upstream=v1.0.0 --yes

# Production hosts
php artisan externa:upgrade --sync-upstream=v1.0.0 --force --yes

Warning

Assisted sync is a convenience for mostly-vanilla trees. Customized forks should merge carefully, keep .env, and never blind-commit conflict resolutions.

AI-assisted upgrade

When merge conflicts or a large diff make a human review painful, paste context into your coding agent. Keep .env and secrets out of commits. Do not ask the agent to force-push or blind-commit without review.

Template prompt (per version)

Copy and fill vOLD / vNEW (example: v1.0.0 → v1.1.0):

I am upgrading Externa (Laravel app from qiick-io/externa-core) from vOLD to vNEW.

Context to use (paste one or more):
1) GitHub compare: https://github.com/qiick-io/externa-core/compare/vOLD...vNEW
2) And/or CHANGELOG + release notes for vNEW
3) My conflicted / customized files (paste `git status` and the conflict hunks)

Goals:
- Resolve conflicts preserving my intentional customizations
- Keep .env, storage/, and local secrets untouched — never commit them
- Prefer upstream for framework/app defaults I did not customize
- After a clean tree: composer install, npm ci && npm run build, then
  `php artisan externa:upgrade` (or migrate + permissions:sync + optimize:clear)

Constraints:
- Do not blind-commit; show the plan and diffs first
- Do not rewrite git history or force-push
- Call out breaking changes from the compare/CHANGELOG

After the agent proposes a resolution: review, run tests / smoke checklist, then commit yourself.

Path B — Replace tree (vanilla create-project, no useful git history)

When you never put the site under git, or history is disposable:

  1. Backup .env, storage/, and the database.
  2. composer create-project qiick/externa-core:X.Y.Z /tmp/externa-new (pin the target version).
  3. Copy .env (and any local customizations) into the new tree; keep the same DB and storage volume/mount.
  4. Run Shared steps in the new tree, then cut over the web root.

Do not expect composer update qiick/externa-core to refresh application source — the package was copied once at create-project time.

Path C — Docker (GHCR / Compose)

Keep named volumes (externa_*_storage, DB volumes). Bump the image tag, then bring the stack back up:

# Quick deploy sample — pin the release you want (example: 1.2.2)
EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \
  docker compose -f compose.quick.yaml pull
EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \
  docker compose -f compose.quick.yaml up -d

Build-from-source Compose: checkout / merge the matching tag in the repo that owns compose.yaml, then docker compose … up --build -d.

If RUN_MIGRATIONS is off, run migrate (+ permissions) in the app container:

docker compose exec app php artisan migrate --force
docker compose exec app php artisan permissions:sync
# or: docker compose exec app php artisan externa:upgrade --force

Details: Deploy with Docker · volumes note below.

Shared steps (after code is on the new release)

php artisan migrate --force
php artisan permissions:sync
# Only if PermissionEnum removed names and you intend cleanup:
# php artisan permissions:sync --prune

npm ci
npm run build

php artisan optimize:clear
# production hosts that cache config/routes:
# php artisan config:cache && php artisan route:cache && php artisan view:cache

php artisan up

Restart queue workers / Horizon / Reverb / pulse:work / scheduler after deploy so they load new code.

php artisan externa:upgrade

Runs the safe automated subset by default: migrate --force, permissions:sync, optimize:clear.

php artisan externa:upgrade
# production or dirty git tree:
php artisan externa:upgrade --force

Does not (by default) fetch git tags, pull from GitHub, run composer, or run npm. Get the new code first (Path A/B/C), or use opt-in --sync-upstream above. Refuses APP_ENV=production and a dirty git working tree unless --force.

FlagMeaning
--sync-upstream=vX.Y.ZOpt-in: fetch upstream + merge tag (see Assisted sync)
--dry-runWith --sync-upstream: list files only; no merge / migrate
--yesSkip confirm when applying --sync-upstream
--forceAllow production and/or dirty tree
--no-migrate / --no-permissions / --prune-permissionsTune the post-sync subset

Docker volumes

With Compose (compose.yaml / compose.prod.yaml / compose.quick.yaml):

  • Keep named volumes (externa_storage / externa_prod_storage / externa_quick_*, DB volumes).
  • Rebuild or pull the app image after the tag change.
  • Prefer externa:upgrade --force (or migrate + permissions:sync) inside the app container when entrypoint migrate is off.

Smoke checklist

  • [ ] GET /health/ready → 200
  • [ ] Login as an operator
  • [ ] Open one collection + one item
  • [ ] Queue still processes (zip or a trivial job) if you rely on async features

Non-goals

  • Silent auto-pull as the default of externa:upgrade (opt-in --sync-upstream only)
  • Remote zip overwrite without git
  • Composer create-project auto-update magic
  • Cross-engine DB moves (SQLite → Postgres) — dump/restore with engine tools separately