> For the complete documentation index, see [llms.txt](https://klassdocs.gitbook.io/klassapp-documentation/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://klassdocs.gitbook.io/klassapp-documentation/docs/ops/production-deploy-checklist.md).

# Production Deploy Checklist

> **Status**: ready-to-use runbook, re-checked 2026-10-13 against `main` (`e85c2fc4`). Production runs Laravel Cloud, push-to-deploy **off** — merging to `main` does **not** ship; a deploy is an explicit release you start and watch.
>
> No secrets in this file. Every credential lives in Doppler (the Cloud API token named in the private repo KlassApp-Foundation/internal or the Cloud dashboard for the Cloud API; backup password held by the owner). Environment-variable names and intended values are listed; values are never printed to logs or PRs.
>
> Standing rules that bind every step: no raw production data writes (rule #1); backup + verify before any production data write (rule #2); never deploy from an arbitrary checkout (rule #15); merging to `main` is not shipping (rule #24).

***

## 1. Pre-deploy

1. **CI green on the tip you are about to ship.** All three checks (`test-guard`, `check`, `scan`) must be SUCCESS on the exact `main` SHA. `gh api repos/KlassApp-Foundation/KlassApp/commits/<sha>/check-runs` — do not ship a SHA that has never had a green run.
2. **Staging first, same SHA.** Staging auto-deploys every merge (push-to-deploy on). Confirm the latest staging deployment is `deployment.succeeded` on the same commit you intend to ship, and run the smoke battery below on staging before pointing the same release at production.
3. **Migrations reviewed.** `php artisan migrate:status` on production must show no *pending* migrations before the deploy (verified 2026-10-05: 0 pending). The deploy itself will run the migrations main has added since production's current SHA — see §2 for the classified list. If anything in §2 looks wrong for the current production data, stop and review before deploying.
4. **Database snapshot.** Take one immediately before the deploy (run a `backup:run` on production, or use the Cloud-provided snapshot if enabled). Record its identifier. The nightly spatie backup (02:00, Hetzner object storage, gzip) is the safety net, but a deploy-day snapshot minimises the rollback window.
5. **Backup integrity.** Confirm you can open the latest backup **only with its password** (never store the password in the repo, Doppler holds it or the owner does). A backup you cannot open is not a backup.
6. **Backup alerts armed.** `BACKUP_WHATSAPP_PHONE` must be set on production so a failed or stale backup alerts out (WhatsApp + email) instead of failing silently.
7. **`.env` backed up separately** (the spatie backup deliberately excludes it).
8. **Environment variables reviewed** against the table in §4 — especially any marked "must NOT copy from staging".

## 2. Migrations production will run (classified)

Re-verified 2026-10-06 (night shift) against the pre-deploy tip of `main` with production on `dabfb80c`: **12 new migrations will run; none are destructive** (no drops of tables/columns, no row deletions). The list below is final for this deploy — re-run `migrate:status` on production at deploy time and stop if the count differs. Two migrations change existing data — flagged below.

Re-checked 2026-10-13 (`14667e61..e85c2fc4`): the 12-migration list below is unchanged, and **two structure migrations were added after `14667e61`**. Neither drops a table or column and neither deletes rows.

**The database snapshot is the rollback for the data migrations.** A code revert cannot undo a data migration; the §1.4 snapshot is the pre-deploy state to restore for both flagged rows if their effect on production data is ever wrong: `map_school_student_size_to_new_buckets` (one-way wording map; `down()` is intentionally empty) and `backfill_existing_schools_toshi_safe_defaults` (the existing-schools Toshi reset; also reversible via its own capture — see below).

**New migrations with foreign keys are checked on MySQL before deploying.** The 2026-10-06 proof: the user\_preferences migration was rolled back alone on staging, re-applied on the clean create path (MySQL, succeeded), and re-run to a no-op. Any future migration that adds a foreign key must get the same check before a production deploy (see docs/proposals/2026-10-06-mysql-migration-guard.md).

There is **no gate backfill migration**: the verified-email sign-in gate does its backfill job in code (`EmailVerificationGate` exempts accounts created before the cutoff), so deploying it writes nothing.

| Migration                                                                    | Kind                                        | Notes                                                                                                                                                                                                                                                                                                                                                                                                     |
| ---------------------------------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `2026_09_26_013906_create_teacher_invites_table`                             | structure                                   | new table + FKs                                                                                                                                                                                                                                                                                                                                                                                           |
| `2026_09_28_010000_create_demo_requests_table`                               | structure                                   | new table                                                                                                                                                                                                                                                                                                                                                                                                 |
| `2026_09_28_020000_create_co_admin_invites_table`                            | structure                                   | new table + FKs                                                                                                                                                                                                                                                                                                                                                                                           |
| `2026_09_29_000000_add_ugandan_admission_fields_to_admissions_table`         | structure                                   | adds nullable columns                                                                                                                                                                                                                                                                                                                                                                                     |
| `2026_09_29_000002_make_legacy_admission_columns_nullable`                   | structure                                   | loosens legacy columns to nullable (no values changed)                                                                                                                                                                                                                                                                                                                                                    |
| `2026_09_30_000001_add_attempts_and_locked_until_to_authentications`         | structure                                   | sign-up code lockout columns                                                                                                                                                                                                                                                                                                                                                                              |
| `2026_09_30_150000_map_school_student_size_to_new_buckets`                   | **data**                                    | rewrites legacy `schools.student_size` labels to the new buckets (one-way; `down()` empty)                                                                                                                                                                                                                                                                                                                |
| `2026_10_01_210214_add_toshi_mode_to_schools_table`                          | **data**                                    | adds `toshi_mode`; backfills `assistant`/`preview` per existing `toshi_enabled`                                                                                                                                                                                                                                                                                                                           |
| `2026_10_01_221239_change_schools_toshi_mode_default_to_onboarding`          | structure                                   | default change only                                                                                                                                                                                                                                                                                                                                                                                       |
| `2026_10_02_183000_backfill_existing_schools_toshi_safe_defaults`            | **data — the existing-schools Toshi reset** | flips every school currently in `assistant` back to `preview` + `toshi_enabled=0` (AI is opt-in per school, AGENTS rule #33); before flipping it captures school ids plus old `toshi_enabled`/`toshi_mode` only: to `storage/logs/toshi-backfill-restore-<date>.json` (feeds `down()`) **and** to the application log via `Log::info` (Cloud log stream), so the capture survives on a deployed container |
| `2026_10_03_023019_add_onboarding_finish_and_skipped_steps_to_schools_table` | structure                                   | adds nullable columns                                                                                                                                                                                                                                                                                                                                                                                     |
| `2026_10_06_000001_create_user_preferences_table`                            | structure                                   | creates the per-user preference store behind the dashboard v2 setup-banner dismissal (INT UNSIGNED FK to users.id)                                                                                                                                                                                                                                                                                        |
| `2026_10_10_190000_add_academic_term_to_report_generations`                  | structure                                   | adds nullable `report_generations.academic_term_id` when the column is missing. `down()` drops that column only                                                                                                                                                                                                                                                                                           |
| `2026_10_10_191500_ensure_schools_state_id`                                  | structure                                   | adds nullable `schools.state_id` when the column is missing. `down()` does not drop it (the original schools table owns the column)                                                                                                                                                                                                                                                                       |

Also on the path: `2026_09_22_010000_add_is_demo_to_schools_and_seed_demo_data` was **modified** since production's SHA (demo seeding stripped, schema-only) — it will **not** re-run (Laravel tracks by filename); the change only affects fresh installs. No action needed.

**At deploy time, watch the log stream** for `[toshi backfill] capture before reset` (per-school ids and old values) and the summary line `[toshi backfill] flipped N school(s): assistant=A, flag-only=B`. If N is larger than expected, the captured values (log stream or the restore file) allow an exact reversal.

## 3. Deploy

**Build-command parity (preflight):** staging's build command, read from the latest succeeded staging deployment, is:

`composer install --no-dev && npm ci --include=dev --no-audit --no-fund && npm run build`

The `npm ci --include=dev` step is required. Without it, a platform dependency-cache miss fails the build with `vite: not found` (seen on staging 2026-10-09, when the command was `composer install --no-dev && npm run build`).

Production's build command, read 2026-10-13, is still the old one:

`composer install --no-dev && npm run build`

Do not change it from an agent session. Before the production deploy, set production's build command to the staging command above. Until that value matches, do not start the production deploy.

1. Deploy from the dedicated main worktree at the reviewed SHA (rule #15).
2. Trigger the release with the empty-body deploy POST to the production environment (token: see the private repo KlassApp-Foundation/internal or the Cloud dashboard; pattern in `knowledge.md` → "Triggering a real deployment"). The POST deploys the environment's latest commit — confirm the worktree matches `origin/main` first.
3. Poll `GET /deployments/{id}` until `deployment.succeeded` (2-minute cadence). A failed deploy: read the deployment logs before anything else; do not re-trigger blind.
4. Verify the live release serves the SHA you shipped (probe a version marker or the deployment's `commit_hash`).
5. Watch the first minutes of the log stream: the toshi-backfill line (§2), no new exceptions, scheduler/queue alive.

## 4. Environment variables (names + intended values only)

| Variable                           | Production value                                                                 | Why                                                                                                                                                                                                                                                                                                 |
| ---------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DASHBOARD_V2_ENABLED`             | unset today (code default `false`). Set to `true` when shipping the v2 dashboard | Staging is `true` (checked 2026-10-13: `config('dashboard.v2_enabled')` is true). Unset production keeps the old dashboard.                                                                                                                                                                         |
| `TOSHI_DEFAULT_MODE`               | unset today, which is the code default `preview`. Leave it `preview`             | New schools start with the Coming-soon panel. `assistant` is not a valid default and falls back to `preview` (AGENTS rule #33). Staging is also unset and resolves to `preview`. Regression test: `NoSchoolGetsAiByDefaultTest`.                                                                    |
| `TOSHI_ONBOARDING_V2`              | unset / `false`                                                                  | Toshi v2 onboarding chat is a staging-only feature until soft-launch sign-off.                                                                                                                                                                                                                      |
| `ROBOTS_NOINDEX`                   | unset / `false`                                                                  | Only staging should noindex. `true` in production would de-index the live site.                                                                                                                                                                                                                     |
| `EMAIL_VERIFICATION_CUTOFF`        | **unset (blank)**                                                                | Blank keeps the built-in default (2026-10-05 00:00 UTC) — behaviour identical to the merged gate. Set it **only** if Rasta explicitly decides to move the boundary; value would be an ISO-8601 UTC instant. Must NOT be copied from staging (staging leaves it unset too — blank everywhere today). |
| `BACKUP_WHATSAPP_PHONE`            | set (E.164 number)                                                               | Backup failure alerts must reach a human.                                                                                                                                                                                                                                                           |
| `LEADS_EMAIL` / `DEMO_BOOKING_URL` | set as configured                                                                | Talk-to-sales lead capture destination.                                                                                                                                                                                                                                                             |
| `WHATSAPP_VERIFY_SIGNATURE`        | `true`                                                                           | Reject unverified WhatsApp webhooks.                                                                                                                                                                                                                                                                |
| `FILESYSTEM_DISK`                  | cloud object storage                                                             | Production uploads go to the object bucket, never `local`. **Do not copy the staging value.**                                                                                                                                                                                                       |
| `DEMO_PROVISIONING_ENABLED`        | `false` except during a provision                                                | `demo:provision` refuses to run unless this is `true`. Set it back to `false` afterwards. See `docs/ops/PRODUCTION-DEMO-RUNBOOK.md`.                                                                                                                                                                |
| `SIGNUP_ENABLED`                   | `true` (close with `false`)                                                      | `false` shows the early access page at `/register`; nothing can create a new school or school admin. Login stays open.                                                                                                                                                                              |
| `WHATSAPP_REPLIES_ENABLED`         | `true` (kill switch `false`)                                                     | `false` stops every outbound WhatsApp message, including replies.                                                                                                                                                                                                                                   |
| `PRIVACY_NOTICE_URL`               | the public privacy notice address                                                | Linked from the demo welcome line and the early access consent box. Empty means no link.                                                                                                                                                                                                            |

`demo:refresh` is a staging-only command for the two walkthrough schools (Demo Junior and Demo Senior). Dry-run first (`php artisan demo:refresh 38 --dry-run` and the same for 39), then run it without `--dry-run` when the demo data should return to the seeded fee mix and walkthrough state. It refuses a school that is not `is_demo`. **Never run `demo:refresh` or `demo:set-password` on production.** Production demo setup is `demo:provision` and `demo:reset`, in `docs/ops/PRODUCTION-DEMO-RUNBOOK.md`.

No other new environment variables were introduced between the 2026-10-08 review and `e85c2fc4`. If in doubt, deploy with the values above and change nothing else.

General rule: **staging's non-listed env values are not production's.** Copy individual variables deliberately, one at a time, never a bulk staging→production env copy. Never read, print or paste secret values — if a value must change, tell Rasta the exact variable name, environment and intended value.

## 5. Post-deploy smoke tests

Run in order; stop at the first failure and assess for rollback (§6):

1. **Sign-up with the emailed code** — register a fresh account at the live site; the 6-digit code email arrives; entering it completes sign-up.
2. **Login** — the new account (and an existing pre-gate account) signs in to the dashboard.
3. **Manual setup wizard on a phone (375) and a laptop (1280)** — wizard opens, steps save, checklist reflects progress.
4. **Add a student** — appears in the roster.
5. **Add a teacher** — invite flows / record appears.
6. **Report card PDF** — generate/download succeeds (file opens).
7. **Profile photo upload and display** — upload succeeds; image renders from object storage (no broken URL).
8. **Password reset email** — request arrives; link works.
9. **Talk to sales form** — submit; the lead lands (`demo_requests` row / `LEADS_EMAIL` receives it).
10. **Log stream** — Cloud log stream is receiving application entries.
11. **Next backup opens only with its password** — wait for (or trigger) the post-deploy backup; verify it decrypts and does not open without the password.
12. **Toshi panel shows Coming soon** — a school admin sees the preview (`TOSHI_DEFAULT_MODE=preview`) and no chat UI.
13. **Admission approval (repaired in #988)** — on a throwaway test school, create one admission and approve it; confirm the student user (and KLS number) exists, then remove the school with the purge command (dry run first).
14. **MVP end-to-end path (added 2026-10-08)** — on ONE fresh throwaway `is_test` school, at both 375 and 1280, in order:
    1. sign up with the emailed code and finish the manual setup wizard;
    2. invite a class teacher, assign a subject teacher, create a stream, and assign both to the class;
    3. add 5 students with parent contacts — the KLS number is visible on the student list, the class roster and the student overview page;
    4. the invited teacher accepts the invite, logs in, and takes attendance;
    5. marks for one exam entered BOTH by form and by spreadsheet import (template → preview → confirm);
    6. generate a report card for one student — the PDF downloads and opens, and shows the student name and the marks;
    7. the bursar records a fee payment, sees the balance (collected this term / outstanding), and on Students with a balance uses Send reminder. The confirmation names how many parents would be messaged. Because this school is `is_test`, the result says the reminder was skipped and nothing is sent;
    8. the parent (created through the normal student-add flow) logs in on the web and sees the child's grades and fees. Purge the school with `test:purge-schools` afterwards (dry run first).
15. **WhatsApp report card (production-only)** — send a real report card to the test WhatsApp number on production and confirm it arrives. Sending cannot be exercised on staging (mail/WhatsApp stay sandboxed there), so this is the one part of the MVP path that only exists as a production smoke item.

Clean up any account created for the smoke tests per standing rule #3 (flag inactive / purge command — never a raw delete).

## 6. Rollback

Two levers, in this order:

1. **Revert PR (code).** Open a revert of the offending merge on GitHub, let CI go green, merge, and run the deploy POST again. This is the primary route — fast, safe, no data loss. Prefer it whenever the failure is a code/behaviour regression rather than a data problem.
2. **Snapshot restore (data).** Only if data written since the §1.4 snapshot is itself the problem (e.g. a migration corrupted records in a way code-revert cannot undo). Restoring rolls **all** data back to the snapshot point — real schools' post-snapshot activity is lost. Requires the owner's explicit go-ahead before executing; re-deploy the previous release SHA alongside it if the code must also move back.

A code revert does **not** undo a data migration. If a released data migration must be undone, use that migration's `down()` deliberately (e.g. the Toshi backfill's restore log) rather than a database-side reverse.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://klassdocs.gitbook.io/klassapp-documentation/docs/ops/production-deploy-checklist.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
