# Deploying

How the app goes onto a server and stays there (plan CORE §3.17, S19; TuteDesk's release, TuteLoop's notes). How it
is built: [reference/deployment.md](reference/deployment.md); readiness and the deploy steps:
[reference/operations.md](reference/operations.md); backups: [BACKUP.md](BACKUP.md). The journeys: the manual's
[Deployment](manual/s19-deployment/00-contents.md).

**`develop` only** (the house rules): a push deploys only where the app's own PROJECT.md says so.

**The step-by-step manual for the cPanel account** (`api.tutelora.com` and `id.tutelora.com` on one folder) is HTML:
`reji/docs/reference/deployment.html`, built from `src/deployment.html` by `php reji/docs/reference/build-framed.php`. It follows this page.

**Atrium has no server yet.** It runs on this Mac under agents, is staged on cPanel next, and gets a VPS later (the Architect, 7 November 2026). The steps below are the foundation's, for that VPS; the cPanel differences are under [Staging on cPanel](#staging-on-cpanel).

## A new server

What it needs:

| | |
|---|---|
| PHP | 8.4 or later (the house runs 8.5), with `pdo_pgsql`, `intl`, `sodium`, `mbstring`, `zip` (imports), `dom` and `gd` (PDFs) |
| PostgreSQL | 18, with `pg_trgm` (search); the client tools of the same major version (backups). A server that runs the test suite (CI) also needs `max_locks_per_transaction` of 256 or more (`reference/conventions.md`, Running it); production doesn't |
| Node | for the build (`npm ci`, `npm run build`) |
| ClamAV | `clamd` running (the file vault's scanner) |
| rclone | when backups go off the server |
| Redis | for the queue; bound to `127.0.0.1`, with a password |
| A process supervisor | for the queue's workers (Supervisor: [`deploy/server/supervisor.conf`](../../deploy/server/supervisor.conf)) |
| cron | one line, for the scheduler |

**Three hosts, one app.** All three names (`HOST_ID`, `HOST_API`, `HOST_INK`) point at the same site and the same `public/` folder, each with a certificate. `APP_URL` is the `id` address, since mail links there. A request on any other name gets a 404, except `/up`.

**A fourth, for files, once its DNS exists** (plan SEC, SEC-7). `HOST_FILES` names a host that serves uploaded files by a signed address and nothing else, with no session: point it at the same site, with its own certificate, then set the value. Until then leave it empty: files are served from the host that asked, sandboxed, and PDFs download rather than open.

**Products' e-mail** (plan NU, NU5). `NOTIFY_MAIL_CHAIN` lists the mailers products' e-mail tries in order (`ses_smtp,smtp` in production: Amazon SES through its SMTP interface, `SES_SMTP_HOST`, `SES_SMTP_USERNAME`, `SES_SMTP_PASSWORD`, then the provider Atrium's own mail uses). `NOTIFY_MAIL_LIMITS` sets each one's monthly quota (`ses_smtp:62000`), `NOTIFY_MAIL_RESET_DAYS` the day a provider's month starts if not the 1st. Each product sends from `no-reply@notify.<its domain>`: verify that subdomain in SES with SPF, DKIM and DMARC, and make an SES configuration set named after the product's code (`campus`), before a product sends.

**Vouchers** (plan TE). Set `VOUCHER_KEY` (a long random string, such as `php -r 'echo bin2hex(random_bytes(32));'`) before the first code is issued, and never change it: every code is found by it, and a new key loses them all. Unset, the app's own key serves, and then `APP_KEY` must never be rotated.

**Short links** (plan TL). Point `tutel.ink`'s DNS at the same site, with its own certificate, and set `HOST_INK=tutel.ink`. It answers short links and the page for one that doesn't work, and nothing else: no sign-in, no API, no cookie. Before a link is sent for real.

**Bounces and complaints** (plan NU, NU6). In SES, give each product's configuration set an event destination for bounces and complaints to one SNS topic, subscribe `https://api.<domain>/v1/notify/report/ses` to it over HTTPS (Atrium confirms the subscription itself), and put the topic's ARN in `NOTIFY_SES_TOPICS`. A report from any other topic is refused.

**The browser's side** (plan SEC). The production example sets `SESSION_ENCRYPT=true` (`app:check` fails without it) and `CSP_MODE=report` for the first deployment: the Content Security Policy then only reports what it would block, as `csp.violation` lines in the log, each with the directive, the blocked origin and the page's path. Read them for a week of real use, fix any that are Atrium's own, then set `CSP_MODE=enforce` (`app:check` warns until then). Behind a load balancer, set `TRUSTED_PROXIES` to its addresses (or `*` behind the host's own proxy) so the client's address and https are read from its headers; empty believes none.

Point the load balancer or uptime check at `/up`: it answers 200 only if the database and the queue answer. Logs are one JSON object a line, in `storage/logs/app.json-<date>.log`.

**The database.** The app's own role, not a superuser; the extension made once by a superuser:

```sql
CREATE ROLE atrium LOGIN PASSWORD '…';
CREATE DATABASE atriumdb OWNER atrium;
\c atriumdb
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```

**The environment.** Start from [`deploy/production.env.example`](../../deploy/production.env.example): paste it into
the server's environment and fill in each `<…>`. Secrets are typed into the server only, each with a copy in the
password manager, never into git or chat:

| Secret | Made with | Without it |
|---|---|---|
| `APP_KEY` | `php artisan key:generate --show` | sessions, sealed columns, two-factor secrets |
| `FILE_VAULT_KEY` | `php artisan vault:key` | no file opens again |
| `BACKUP_PUBLIC_KEY` | `php artisan backup:key` (its private half goes in the password manager only) | no backups |
| `DB_PASSWORD`, `MAIL_PASSWORD` | the provider | |

**The first run, in order:**

1. The first release (below), which migrates, syncs the lists, roles and notifications, and caches.
2. The Service account: `php artisan service:create <e-mail> --first="…" --last="…"` makes its person, its account and
   its identity (written into `.env` as `SERVICE_IDENTITY`: run `php artisan optimize` after it, since step 1 cached the config), and shows a generated password once. Sign in with it and set up two-factor
   ([reference/service.md](reference/service.md)).
3. The cron line: `* * * * * cd <the app>/current && php artisan schedule:run >> /dev/null 2>&1`.
4. The workers, under the supervisor: fill in `deploy/server/supervisor.conf`, which runs one program per pool with workers (`default`, `bulk`).
5. Backups: `BACKUP_PATH`, `BACKUP_PUBLIC_KEY`, `BACKUP_REMOTE`, then `php artisan backup:run` once ([BACKUP.md](BACKUP.md)).
6. The products' sign-in (plan CL, CL3): `php artisan passport:keys` writes a key pair under `storage/` (`oauth-private.key`,
   `oauth-public.key`), which serve as they are where `storage/` is kept between releases; or put their contents in
   `PASSPORT_PRIVATE_KEY` and `PASSPORT_PUBLIC_KEY`. Corrected 9 November 2026: this said `passport:keys --show`, which Passport doesn't have. A new pair changes the key
   ID, and products fetch the new public key from `/oauth/jwks` on their own. Then, for each product server,
   `php artisan server:sign-in <srv_…> --callback=/auth/callback` shows its client ID and secret once, for that product's
   `.env`. Turnstile's `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` go in too, or the bot check is the puzzle alone; and
   Google's and Microsoft's (`GOOGLE_CLIENT_ID`/`_SECRET`, `MICROSOFT_CLIENT_ID`/`_SECRET`, each with `https://<id>/auth/<provider>/callback`
   as its return address), or they aren't offered.
   Paying (plan TB, TB2): `BILLING_GATEWAY=paystack` and `PAYSTACK_SECRET_KEY` (test keys first), and in Paystack's dashboard the
   webhook `https://<HOST_API>/hook/paystack`. Without them payments use the development stand-in, which refuses to run in
   production; every product stays complimentary until its billing is switched on (TB5), so nothing is charged either way. Staging runs
   on Paystack's test keys, never the stand-in: there any billing person could mark their own payment paid (SECURITY_REVIEW_TABULA.md).
   Refunds (TB7) need nothing more: Paystack sends its `refund.*` events to the same webhook, and `billing:check` asks about refunds
   too. After the deploy that brings them, `lookups:sync`, `roles:sync` and `notifications:sync` add the two lists, `refund.manage` and
   the three e-mails. Credit notes (TB8) are numbered `CN-…` (`BILLING_CREDIT_PREFIX`, optional) and cleared through the same E-VAT
   driver as invoices. Chargebacks (TB9) arrive by the same webhook (`charge.dispute.*`) and `billing:check`; with only 16 hours to
   answer at Paystack, the queue and the scheduler must be running, and someone must read the Service's mail.
   Invoices (plan TB, TB4): the seller's `BILLING_SELLER_TIN` and `BILLING_SELLER_VAT` once registered, printed on every invoice;
   `BILLING_EVAT=gra` with `GRA_EVAT_BASE_URL` and `GRA_EVAT_KEY` once the GRA gives E-VAT access. Until then invoices stay receipts,
   are sent again every hour (`billing:clear`), and the Service is told of any still waiting after five tries; the `log` stand-in never
   clears in production. A withholding certificate is a file: the vault (`FILE_VAULT_KEY`) must be set for schools to send one.
7. `php artisan app:check`: every line passing, or a look you understand. The operations page shows the same.
8. The first workspace: signed in as the Service, Workspaces → New workspace, with its name and who runs it. The workers
   (step 4) send them the invitation, so have a real mailer by now: with `MAIL_MAILER=log` the mail reaches `storage/logs`
   only while `LOG_LEVEL=debug`. They choose a password, set up two-factor and the first-run wizard takes it from there
   ([reference/setup.md](reference/setup.md)).

Nothing is seeded in production: the seeder refuses it. A workspace comes from the Service's form, or from self sign-up
if the platform has switched it on (`sign_up.open`, off as shipped; [reference/service.md](reference/service.md)).

## A release

A zero-downtime deployer (Forge, Envoyer, Deployer) builds each release in its own folder and switches a `current`
link to it. Between making the release and switching to it, it runs
[`deploy/server/release.sh`](../../deploy/server/release.sh):

1. `composer install --no-dev --optimize-autoloader`, `npm ci`, `npm run build`;
2. `php artisan backup:run --kind=predeploy`: the database as it was, before this release changes it (it says so and
   carries on while backups aren't set up);
3. `php artisan app:deploy`: the steps in `config/operations.php` `deploy`, in order, stopping at the first that fails
   (`migrate --force`, `lookups:sync`, `lookups:check`, `roles:sync`, `notifications:sync`, `notifications:check`,
   `mail:partitions`, `brand:check`, `optimize`).

Any failure stops the deployment and the previous release stays live. After the switch: `php artisan queue:restart`
(the workers then run the new code), then `php artisan app:check`.

**Shared between releases:** `.env`, `storage/` (the vault, private files, logs) and the backups folder, which is
outside the release altogether.

## Staging on cPanel

`develop` on the real names, before Atrium has a VPS: `api.tutelora.com` is the cPanel sub-domain holding the app, and `id.tutelora.com` points at the same folder (the Architect, 9 November 2026; this said `*.tutelora.dev`). `tutel.ink` joins when its DNS is ready. Step by step: `reference/deployment.html`. What differs from the steps above: [`deploy/staging-cpanel.env.example`](../../deploy/staging-cpanel.env.example) over the production file.

| | On cPanel |
|---|---|
| Hosts | All three names as domains on one account, pointing at the same `public/` folder, each with a certificate |
| PostgreSQL | **Confirm before anything else:** 15 or later (a migration uses `SET NULL (column)`) with `pg_trgm`, which needs the host to make the extension. Nothing else here can stand in for it |
| Queue | No Redis or supervisor: `QUEUE_CONNECTION=database` and `QUEUE_VIA_SCHEDULER=true`, so the scheduler starts a worker each minute for what's waiting |
| Cron | One line: `* * * * * cd <the app>/current && php artisan schedule:run >> /dev/null 2>&1`. Name the PHP binary if `php` is the wrong version (`/usr/local/bin/ea-php85 artisan …`) |
| Timeouts | Without `pcntl` a worker can't stop a job that runs too long, and the host may kill long imports. `retry_after` still stops a second worker taking one |
| Scanner | No ClamAV: `FILE_VAULT_SCANNER=fake`, so uploads aren't scanned. Staging only; production refuses it |
| Build | If the account has no Node, run `npm ci && npm run build` first and upload `public/build` |

**What it can't show:** separate workers per pool, Redis, and anything under real load. Those wait for the VPS.

## On a Mac: the dev agents

An app's server, its queue workers (one per running pool) and its scheduler run as launchd agents (the house rules), rendered from
[`deploy/launchd/`](../../deploy/launchd) with the app's short name, its port, its folder and the home folder:

```bash
php artisan app:agents 8030            # prints them
php artisan app:agents 8030 --write    # writes them to ~/Library/LaunchAgents (an existing one is left alone)
mkdir -p ~/Library/Logs/tutelora
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.tutelora.tutecampus-8030.plist
```

The port is the first of the app's block in the registry; the house rules' dev-server table gains the agents. The
starter pack itself has none: it is started on demand (`launch.json`, port 8020).

## Commands, by when

| When | Command | What it does |
|---|---|---|
| Once | `php artisan key:generate --show`, `vault:key`, `backup:key` | the secrets above, printed once |
| Once | `php artisan service:identity <e-mail> --set` | names the Service account ([reference/service.md](reference/service.md)) |
| Once per product server | `php artisan server:sign-in <srv_…>` | its sign-in client; `--new-secret` replaces the secret, `--callback=` the return path ([reference/accounts.md](reference/accounts.md)) |
| Each release | `php artisan app:deploy` | the deploy steps, in order |
| Each release | `php artisan app:check` | the readiness checks; fails on a failing one |
| By the scheduler | `backup:run`, `backup:prune`, `logs:purge --due`, `app:check --record`, `notifications:digest`, … | `routes/console.php` |
| When asked | `backup:list`, `backup:restore <name>` | [BACKUP.md](BACKUP.md) |
| When the key changes | `vault:rewrap` | moves every file onto the new vault key ([reference/files.md](reference/files.md)) |
| A new app | `app:rename <Short>` (in the starter pack's copy) | the starter pack's runbook, `runbooks/new-app.md` |
| A starter pack release | `core:manifest` (in the starter pack) | the starter pack's runbook, `runbooks/release.md` |
| Any time, in an app | `core:status` | what differs from the release it is on |

## Keeping this honest

A change that adds a step every release needs adds it to `config/operations.php` `deploy` (or to `release.sh`, when it
must run before), and a line here. A new secret gets a row in the table above and in `production.env.example`.
