# Deployment design

Production deployment is **not part of the MVP** (`SPEC.md §42`). This document
records the intended architecture so the prototype does not foreclose it, and so
the infrastructure work is scoped rather than discovered late.

Nothing here has been built or tested. Before any of it carries real data, the
review in [`security-checklist.md`](security-checklist.md) must complete.

---

## What this app needs from a host

Before choosing anywhere, three requirements rule most cheap hosting out. They
are not preferences; each one is load-bearing.

**A running Node process.** 74 of the screens are server-rendered and 76 API
endpoints execute per request. There is no folder of HTML to upload — a static
host produces a blank page. `output: 'standalone'` in `next.config.ts` builds a
self-contained server bundle (`.next/standalone`) for hosts that run Node.

**PostgreSQL, not MySQL.** This is the hard one. 39 query sites use
case-insensitive matching, which Prisma implements only on PostgreSQL, and the
append-only audit trail — what makes a grade change or a payment tamper-evident —
is five PL/pgSQL trigger functions. On MySQL the triggers have no equivalent that
the application layer cannot bypass, so moving database would mean either
rebuilding that guarantee or giving it up. Giving it up removes the property the
system is built around.

**Native binaries matching the server.** The Prisma query engine and the Argon2
password hasher are compiled addons. Build on the same platform as the host —
Linux x64 for almost everything — or let the host build.

### cPanel shared hosting

Will not work, in the general case. Shared cPanel offers MySQL/MariaDB and, at
best, a Passenger-managed Node runtime; PostgreSQL is rarely available and the
trigger-based audit trail cannot follow. Uploading the repository through File
Manager produces a blank page, because there is nothing static to serve.

A cPanel *VPS* with root access is a different matter — there you can install
PostgreSQL and run Node directly, and the notes below apply unchanged.

### Hosts that do work

| Host | App | Database | Notes |
| --- | --- | --- | --- |
| Railway | Node service | Managed PostgreSQL | Both in one project; simplest single-vendor route. |
| Render | Web service | Managed PostgreSQL | Free tier sleeps when idle — a demo may take ~30s to wake. |
| Fly.io | Node machine | Fly Postgres | Closest region to your users; more configuration. |
| Vercel + Neon | Serverless | Neon PostgreSQL | Best Next.js support; note the connection-pooling setting below. |

**A step-by-step for Railway, verified against this codebase, is in
[`deploy-railway.md`](deploy-railway.md).** For the others the sequence is the
same as the release sequence further down:
create the database, set `DATABASE_URL` and `SESSION_SECRET`, run
`pnpm db:migrate:deploy`, then either `pnpm db:seed` for a demonstration or
`pnpm db:clear-demo --apply` for an empty system with one superadmin.

**If you deploy serverless** (Vercel), point `DATABASE_URL` at the *pooled*
connection string and keep the direct one for migrations. A serverless function
opens a connection per invocation and will exhaust a small Postgres instance
otherwise.

### Pointing your own subdomain at it

The deployment host gives you a hostname. Your domain still fronts it:

1. In your DNS (cPanel → Zone Editor, or wherever the domain is managed), add a
   `CNAME` for the subdomain — e.g. `ilearned` — pointing at the host's hostname.
2. Add the custom domain in the host's dashboard so it issues a TLS certificate.
3. Set `APP_URL=https://your.subdomain` before the first deploy — password-reset
   and account-setup links are built from it, and a wrong value produces links
   that go nowhere.

Serving over HTTPS is not optional here: set `SESSION_COOKIE_SECURE=true` once
TLS is terminating, or session cookies travel in the clear.

## Target architecture

```
                          Internet
                             │  HTTPS only
                    ┌────────▼─────────┐
                    │  Reverse proxy   │  TLS termination, HSTS,
                    │  / load balancer │  edge rate limiting
                    └────────┬─────────┘
                             │
             ┌───────────────┴───────────────┐
             │                               │
    ┌────────▼────────┐             ┌────────▼────────┐
    │ Next.js app     │             │ Next.js app     │   stateless,
    │ container       │             │ container       │   horizontally
    └────────┬────────┘             └────────┬────────┘   scalable
             └───────────────┬───────────────┘
                             │
      ┌──────────────────────┼──────────────────────┐
      │                      │                      │
┌─────▼──────────┐  ┌────────▼─────────┐  ┌─────────▼────────┐
│ Managed        │  │ Private object   │  │ Secret manager   │
│ PostgreSQL     │  │ storage          │  │                  │
│ encrypted, PITR│  │ no public policy │  │                  │
└────────────────┘  └──────────────────┘  └──────────────────┘
      │
┌─────▼──────────┐  ┌──────────────────┐
│ Redis          │  │ Centralized      │
│ sessions cache,│  │ logging + alerts │
│ rate limiting, │  │ (audit shipped   │
│ job queue      │  │  off-box)        │
└────────────────┘  └──────────────────┘
```

### Why the app containers are stateless

Sessions live in PostgreSQL, not in process memory, so any container can serve
any request and a rolling deploy does not sign everyone out. The only
process-local state today is the in-memory rate limiter, which is exactly why it
must move to Redis before running more than one instance — see the residual
risks in the security checklist.

---

## Environment

Everything in `.env.example` applies, with these production values:

| Variable | Production value | Why |
| --- | --- | --- |
| `SESSION_COOKIE_SECURE` | `true` | The app refuses to start otherwise on a non-localhost URL. |
| `SESSION_SECRET` | 48+ random bytes from the secret manager | Keys the session-token HMAC. Rotating it invalidates all sessions. |
| `DATABASE_URL` | managed instance, TLS enforced | |
| `STORAGE_DRIVER` | `s3` | Bucket must have no public-read policy. |
| `MAIL_DRIVER` | `smtp` | |
| `JOB_DRIVER` | `redis` | |
| `RATE_LIMIT_DRIVER` | `redis` | Per-process limiting is ineffective behind a balancer. |
| `MALWARE_SCAN_DRIVER` | a real scanner | The default records `UNSCANNED` rather than claiming clean. |
| `CAPTCHA_DRIVER` | Turnstile or reCAPTCHA | Public forms only. |
| `LOG_LEVEL` | `info` | The logger redacts PII at every level. |

Secrets are injected at runtime from the secret manager. `.env` files are for
development only.

---

## Database

**Migrations.** `pnpm db:migrate:deploy` applies pending migrations without
prompting. Run it as a release step before the new containers take traffic —
every migration to date is additive, so old and new code can overlap briefly.

**Never run the seed.** It truncates every table. The script refuses when
`NODE_ENV=production`, and that guard should not be removed.

**Backups.**

| Setting | Target |
| --- | --- |
| Automated snapshots | daily, retained 30 days |
| Point-in-time recovery | 7 days minimum |
| Restore test | quarterly, to a scratch instance, timed |
| RPO | ≤ 5 minutes (PITR) |
| RTO | ≤ 4 hours |

A backup that has never been restored is a hypothesis, not a backup. The
restore test is the deliverable, not the snapshot configuration.

**Object storage** needs its own versioning and lifecycle policy: uploaded
requirements are referenced by `Attachment` rows, so deleting a file without
the row leaves a broken reference, and deleting the row without the file leaks
storage.

---

## Release sequence

1. Build and tag the image.
2. Run `pnpm db:migrate:deploy` against the target database.
3. Deploy to a subset of instances; watch error rate and latency.
4. Roll forward, or roll back the image (the schema stays — migrations are
   additive by policy).

Rolling *back* a migration is deliberately not part of the routine: a
destructive down-migration on a student register is a worse outcome than a
forward fix.

---

## What still needs building

Tracked in `TASKS.md` under Phase 6 and in the security checklist:

- Container image and compose/Kubernetes manifests
- CI pipeline: lint, typecheck, test, build, dependency audit
- Redis-backed rate limiter and job worker
- S3 storage adapter (interface exists; implementation does not)
- MFA enrolment and TOTP verification
- Audit log shipping
- Health and readiness endpoints
- Uptime and error monitoring
