# Deploy with Docker

Source: https://docs.qiick.io/externa/deploy-with-docker

Operator path for self-hosted Externa: pull a published image (or build once), set a small `.env`, `docker compose up`, healthy stack. Mirrors the Directus-style friction goal.

> **Related**
> [Deployment](https://docs.qiick.io/externa/deployment) (bare metal + full Docker notes) · [Installation — Docker local](https://docs.qiick.io/externa/installation#docker-compose-local-full-stack) · [Reverse proxy](https://docs.qiick.io/externa/reverse-proxy) · [Environment variables](https://docs.qiick.io/externa/environment-variables)

> Official `ghcr.io/qiick-io/externa-core:1.2.2` and `latest` ship **PHP 8.4.27** on `php:8.4.27-fpm-bookworm` with a hardened `docker/php/php.ini` (`session.upload_progress.enabled=0`, `session.use_strict_mode=1`, body limits matching nginx `client_max_body_size` 100M) — [core#216](https://github.com/qiick-io/externa-core/issues/216), Composer `php: ^8.4.27` from [#248](https://github.com/qiick-io/externa-core/pull/248). Re-pull and recreate containers (`docker compose pull && docker compose up -d`), then check with `docker compose exec <php-service> php -v`. Source builds: `docker build --pull --no-cache` so the base image updates. Details: [discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Bare-metal hardening: [Deployment — PHP runtime hardening](https://docs.qiick.io/externa/deployment#php-runtime-hardening).

## Quick deploy (pull GHCR)

Published multi-arch image: **`ghcr.io/qiick-io/externa-core`** (`linux/amd64` + `linux/arm64`). CI pushes on version tags (`v*`) and optional workflow dispatch.

```bash
# From an externa-core checkout (Compose + env sample live in the repo)
cp .env.docker.quick.example .env

# Required: APP_KEY, strong DB_PASSWORD, APP_URL
php -r "copy('.env', '.env.bak'); file_put_contents('.env', preg_replace('/^APP_KEY=.*/m', 'APP_KEY=base64:'.base64_encode(random_bytes(32)), file_get_contents('.env')));"

# First boot only — create roles + super admin (then set RUN_SEED=false)
# RUN_SEED=true
# INITIAL_SUPER_ADMIN_EMAIL=you@example.com
# INITIAL_SUPER_ADMIN_PASSWORD='…strong…'

docker compose -f compose.quick.yaml up -d
```

Open **`http://localhost:8080`** (host `APP_PORT`, default **8080→80**). Reverb publishes on host **8081**.

| Piece       | Default                                                               |
| ----------- | --------------------------------------------------------------------- |
| Image       | `ghcr.io/qiick-io/externa-core:latest` (`EXTERNA_IMAGE` override)     |
| Services    | `app`, `horizon`, `reverb`, `scheduler`, `pulse`, `pgsql`, `redis`    |
| Volumes     | `externa_quick_storage`, `externa_quick_pgsql`, `externa_quick_redis` |
| Healthcheck | `GET /health/ready` on `app` (`start_period: 90s`)                    |

Pin a release:

```bash
EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.3.0 \
  docker compose -f compose.quick.yaml up -d
```

**Upgrades:** bump `EXTERNA_IMAGE` (or rebuild from a newer git tag), keep named volumes, then migrate / `externa:upgrade` inside the app container when `RUN_MIGRATIONS` is off — [Upgrade — Path C (Docker)](https://docs.qiick.io/externa/upgrade#path-c-docker-ghcr-compose).

Local smoke without GHCR pull (bake once, then quick Compose):

```bash
docker build --target production -t externa:prod .
EXTERNA_IMAGE=externa:prod docker compose -f compose.quick.yaml up -d
```

> **Build context (core 1.3.0+)**
> When building from a checkout, `.dockerignore` excludes `/backups`, `/storage/app/*`, agent tooling folders (`.agents`, `.claude`, `.codex`, …), `.devcontainer`, compose bake files, and related paths so local data is not baked into the image ([core PR #294](https://github.com/qiick-io/externa-core/pull/294)). Entrypoint recreates `storage/app/{public,private,zips,tmp}` at start.

> **/storage/ sandbox (core 1.3.0+)**
> Official nginx config serves public `/storage/` with `nosniff` and a CSP `sandbox` that blocks scripts/objects ([core PR #314](https://github.com/qiick-io/externa-core/pull/314)). Complements the upload extension denylist (SSI/markup) — [File manager — Upload security](https://docs.qiick.io/externa/file-manager#upload-security).

## Minimal `.env`

| Variable                | Required         | Notes                                                      |
| ----------------------- | ---------------- | ---------------------------------------------------------- |
| `APP_KEY`               | **yes**          | `base64:…` from `key:generate` / random 32 bytes           |
| `APP_URL`               | **yes**          | Public URL browsers hit (match proxy / port)               |
| `DB_PASSWORD`           | **yes**          | Shared with Compose `pgsql`                                |
| `DB_*`                  | defaults         | `pgsql` / `externa` / host `pgsql` inside Compose          |
| `INITIAL_SUPER_ADMIN_*` | when seeding     | Email + **strong** password before `RUN_SEED=true`         |
| `RUN_MIGRATIONS`        | sample `true`    | Entrypoint runs `migrate --force` only when `true`         |
| `RUN_SEED`              | sample `false`   | Opt-in `db:seed --force` (roles + super admin)             |
| `REVERB_*`              | sample           | Change app key/secret for anything beyond laptop demos     |
| `TRUSTED_PROXIES`       | behind TLS proxy | `*` or CIDRs — see [Reverse proxy](https://docs.qiick.io/externa/reverse-proxy) |

Full catalog: [Environment variables — Docker Compose knobs](https://docs.qiick.io/externa/environment-variables#docker-compose-knobs).

## First boot (gated)

Entrypoint defaults **`RUN_MIGRATIONS=false`** and **`RUN_SEED=false`** — restarts do not surprise-mutate the database unless Compose/env opts in.

1. First bring-up: keep `RUN_MIGRATIONS=true` (quick/prod samples).
2. Optional admin bootstrap: set strong `INITIAL_SUPER_ADMIN_*`, then `RUN_SEED=true` for **one** start.
3. After login works: set **`RUN_SEED=false`** (and optionally `RUN_MIGRATIONS=false` once schema is stable).
4. Seeders are idempotent on permissions/roles/super-admin email — still treat seed-on-boot as a bootstrap flag, not a forever default.

No automatic destructive wipe. `migrate --force` only applies pending migrations.

## Healthchecks

Compose `app` probes **`GET /health/ready`** (DB + required Redis). Also available:

| Endpoint            | Role                                     |
| ------------------- | ---------------------------------------- |
| `GET /up`           | Laravel liveness                         |
| `GET /health/live`  | Process up                               |
| `GET /health/ready` | Ready for traffic (503 when checks fail) |

Details: [Deployment — Health probes](https://docs.qiick.io/externa/deployment#health-probes).

## Publish / multi-arch path

```bash
# Bake amd64+arm64 (no push)
docker buildx bake -f docker-bake.hcl production
# or
./scripts/docker-buildx.sh production

# Push to GHCR (needs docker login ghcr.io)
VERSION=1.0.0 PUSH=1 ./scripts/docker-buildx.sh production
```

CI: workflow **`docker`** — `compose-config` (includes `compose.quick.yaml`), single-arch `image-build`, `multiarch-bake` on PR/push, **`ghcr-publish`** on `v*` tags (+ `workflow_dispatch`).

Build notes: phpredis **6.3.0** pinned from GitHub tarball (not flaky pecl); npm `prepare` skips Lefthook without `.git` (#94). Details: [Deployment — multi-arch](https://docs.qiick.io/externa/deployment#multi-arch-images-amd64-arm64).

## Build-from-source production

When you need to bake assets yourself (custom Vite Reverb public URL, patches):

```bash
cp .env.docker.prod.example .env
# APP_KEY, secrets, APP_URL, TRUSTED_PROXIES, browser-facing REVERB_*
docker compose -f compose.prod.yaml up --build -d
```

Same healthchecks and gated `RUN_MIGRATIONS` / `RUN_SEED` as quick deploy.

## Follow-through checklist

| Path                | When                                             | Link                                                                                                                                        |
| ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Managed DB / Redis  | Postgres/Redis outside Compose (RDS, Upstash, …) | `compose.prod.yaml` + `compose.prod.managed.yaml` — [Deployment — Managed](https://docs.qiick.io/externa/deployment#managed-db-redis-compose-overlay) · core #15 |
| Reverse proxy / TLS | Public HTTPS + WebSocket Upgrade to Reverb       | [Reverse proxy cookbook](https://docs.qiick.io/externa/reverse-proxy) · core #17                                                                                 |
| Local full stack    | Dev with Vite/Mailpit/MinIO                      | [Installation — Docker](https://docs.qiick.io/externa/installation#docker-compose-local-full-stack)                                                              |
| Bare metal          | No Compose                                       | [Deployment](https://docs.qiick.io/externa/deployment)                                                                                                           |

## Smoke after `up`

1. `curl -fsS http://localhost:8080/health/ready` → `"status":"ok"`.
2. Login with `INITIAL_SUPER_ADMIN_*` (if seeded) or your provisioned admin.
3. Confirm Horizon/scheduler containers are up; trigger a small queued job if useful.

## Related

- Core README Docker sections · samples: `.env.docker.quick.example`, `.env.docker.prod.example`, `.env.docker.prod.managed.example`
- [Upgrade](https://docs.qiick.io/externa/upgrade) · [Backup & restore](https://docs.qiick.io/externa/backup-restore) · [Operations](https://docs.qiick.io/externa/operations)
