🏫 School Platform — Tenancy, Security, Backup & DR

The production design for serving many schools from one domain with per-school isolation (your option E, upgradeable to F), a NestJS backend, an hourly→daily backup lifecycle, and a tested disaster-recovery plan.

ArchitectureOption E now (per-school silos + control plane) — F's aggregation gate added when cross-school analytics is needed
Tenancy routingOne domain, no subdomains — the external auth token proves identity only; the active school travels as an X-School-Id header, membership-checked on every request
BackendNestJS: guards for auth & roles, middleware for tenant resolution, one connection pool per school
BackupsHourly (kept 10 days) → daily (kept 30 days) → monthly (12 months) · RPO ≤ 1 h, drill-tested restores

1 Architecture: E today, F when analytics needs it

Between your shortlisted options E and F: they are not rivals — F is E plus one extra component (a governed aggregation gate). So the decision is about sequencing, not choosing.

Start with E — per-school silos + control plane

  • One database per school — own schema, own credentials, own backup line. A connection to school A physically cannot read school B.Isolation is structural, not a WHERE clause.
  • Control plane — auth, school registry (catalog), routing, feature flags. Small, boring, its own database.
  • One shared app fleet — the same NestJS pods serve every school; the verified identity plus the validated X-School-Id header decide which school database the request touches.

Add F's aggregation gate later

  • Trigger to build it: the first real need for cross-school insight (benchmarks, platform dashboards, per-feature usage).
  • Aggregates only, one way — a scheduled job computes school-level aggregates (counts, averages) into a separate analytics store. No row-level data ever leaves a silo.
  • Governance built in: minimum cohort size (e.g. suppress any aggregate covering < 5 schools), audit log of every aggregate query, no ad-hoc row egress path.
Decision: ship E; keep every silo's schema identical (same migrations everywhere) so the F gate is a pure add-on, not a refactor.

2 One domain, no subdomains — how UI → API knows the school

Everything lives on app.yourschools.com. The auth service is external — we don't control its token claims — so its token proves only who the user is. Which school is active travels as an X-School-Id header, and the API validates that header against the membership table on every single request. The header is a hint; our control plane is the authority.

1Login (external)Redirect to the auth service's hosted login. It returns its JWT — identity only: sub, email.
2Token verificationAPI verifies via the service's JWKS: signature, issuer, audience, expiry. No custom claims needed.
3Membership lookupControl plane maps sub → schools + role in each. OUR data — the token knows nothing about schools.
4School picker1 school → auto-selected. 2+ → picker screen (see §3). Choice becomes the X-School-Id header.
5Every API callUI sends token + X-School-Id. TenantGuard re-checks membership (cached ~60 s) — non-members get 403.
6Silo connectionCatalog maps school_id → DB host + secret; a cached pool for that school serves the query.
Three sources, three jobs
// 1. From the AUTH SERVICE token (we don't control its claims):
{ "sub": "auth0|8123", "email": "priya@dps.edu", "exp": ... }   // WHO

// 2. From OUR control plane (queried per request, cached 60 s):
memberships:  auth0|8123 → [ { school: "sch_dps_pune",   role: "school_admin" },
                             { school: "sch_svm_mumbai", role: "teacher" } ]   // MAY + AS WHAT

// 3. From the CLIENT (untrusted until checked):
X-School-Id: sch_dps_pune          // WHICH — TenantGuard validates it EVERY request
Iron rules
  • The X-School-Id header is untrusted input — TenantGuard checks (sub, school) against the membership table on every request; a non-member gets 403 before any handler runs. Trusting the header without this check would be the cross-tenant bug.
  • Roles come from our membership table, never from the token — the auth service only proves identity; authorization is entirely ours.
  • Bonus over JWT claims: revoking someone's access = delete the membership row — effective within the 60 s cache, no waiting for a token to expire.
  • Every DB access goes through the tenant-scoped connection — there is no "global" pool that can see all schools from request code.

3 Users who belong to two schools

A teacher can work at two schools; a director can own five. The rule: one active school per session, chosen explicitly before any dashboard loads.

The flow
  • After login, if memberships.length > 1 the UI routes to /choose-school — a card per school (name, logo, the user's role there).
  • Selection sets the active school: saved as last-choice via POST /me/active-school, and from then on every request carries X-School-Id with that value — which the server membership-checks each time.
  • Only then does the app fetch dashboard data. There is no "no school selected" state after this point.
  • A school switcher stays in the top-nav; switching changes the header value and fully reloads dashboard state (no data from school A ever remains mounted while school B is active). A wrong or stale header value can't leak anything — the per-request check 403s it.
Details that prevent pain
  • Remember the last choice per user (control plane), preselect it — but still show the picker on first login of the day for multi-school users.Fast for the 95% case, explicit for auditability.
  • Roles are per membership — the same person can be school_admin at school A and plain teacher at school B. The token carries the role of the active school only.
  • Deep links: a link opened while the "wrong" school is active shows a one-click "Switch to DPS Pune to view this" interstitial — never silently switches.

4 NestJS blueprint

NestJS fits this design well: guards for authn/authz, middleware for tenant resolution, and dependency injection for the per-school connection. The one hard problem is pool-per-school — solved with a connection manager, LRU-capped.

Module layout
src/
├── auth/            # JWKS verify of external IdP token
│   ├── idp-jwt.strategy.ts   # issuer + audience checks
│   └── roles.guard.ts        # @Roles(...) — role from OUR memberships
├── tenancy/
│   ├── tenant.middleware.ts  # JWT → school_id → connection
│   ├── catalog.service.ts    # control-plane lookup (cached)
│   └── connection.manager.ts # pool per school, LRU-capped
├── control-plane/   # schools registry, memberships,
│   │                #   feature flags, audit log
├── students/        # feature modules — all tenant-scoped
├── teachers/
├── analytics/       # later: F's aggregation gate
└── common/          # validation pipes, rate limits, filters
The tenant-scoped connection (core trick)
// tenant.guard.ts
const { sub } = await verifyIdpJwt(req);   // JWKS of the external auth service
const schoolId = req.headers['x-school-id'];
const member = await this.memberships
    .check(sub, schoolId);                 // control plane, cached 60 s
if (!member) throw new ForbiddenException();
req.tenant = { schoolId, role: member.role,
  conn: await this.connections.forSchool(schoolId) };

// connection.manager.ts
async forSchool(id: string) {
  if (this.pools.has(id)) return this.pools.get(id);
  const cfg = await this.catalog.dbConfig(id);
  //   ↑ host + creds from Secrets Manager, cached 5 min
  const pool = createPool({ ...cfg, max: 5 });
  this.pools.set(id, pool);       // LRU-evict past ~200
  return pool;
}
  • Small pools × many schools — 5 conns/school; PgBouncer in front once schools × pools approaches Postgres limits.
  • Migrations for all silos — one command loops the catalog and applies migrations per school; a new school gets them at onboarding.

5 API security

  • AuthN: delegated to the external auth service — we verify its JWT via JWKS (signature, issuer, audience, expiry) and cache the JWKS keys. Short-lived tokens + its refresh flow are the auth service's job.
  • AuthZ (entirely ours): two layers — role guard (@Roles('school_admin'), role read from our membership table) and tenant guard (validated X-School-Id membership check). Both run on every route.
  • Validation: global ValidationPipe with whitelist: true, forbidNonWhitelisted: true — unknown fields are rejected, not ignored.
  • Rate limiting: @nestjs/throttler — tight on /auth/* (brute-force), generous per-user elsewhere; plus AWS WAF on the ALB (SQLi/XSS rulesets, IP throttles).
  • Headers & CORS: helmet defaults; CORS locked to the one domain — a nice side-benefit of the no-subdomain decision.
  • Secrets: nothing in env files or images — pulled from AWS Secrets Manager at boot (and per-school DB creds at connect time).
  • Audit log: control-plane table: who, school, action, entity, timestamp, IP — written for every mutation and every auth event, immutable (no UPDATE/DELETE grants).
  • Dependencies: ECR scan-on-push (already on) + npm audit/pip-audit jobs in CI.

6 Database security

  • Network: databases in private subnets only; security group admits port 5432 from the EKS node/pod SG only. No public endpoint, ever.
  • Encryption: at rest with KMS (per-instance keys; premium schools can get their own CMK), in transit with enforced TLS (rejectUnauthorized on the client).
  • Per-school credentials: each silo has its own DB user that can access only its own database — no superuser in the app path. Secrets Manager rotation every 30–90 days.
  • Least-privilege grants: app user gets DML only (SELECT/INSERT/UPDATE/DELETE); DDL runs only through the migration job's separate role.
  • Human access: no shared psql passwords — SSM Session Manager + IAM auth for break-glass, every session logged.
  • Audit & detection: pgAudit on DDL + role changes; CloudTrail on RDS API calls; alarms on failed-login spikes and unusual egress volume.
  • Isolation as the last line: even if a bug ships, a school-A connection cannot express a query that touches school B — the blast radius of any single mistake is one school.

7 Backup policy — your retention rules, implemented

Requirement: hourly backups always available for recent history; after 10 days keep only dailies and drop the hourlies. Implemented with per-school dumps to S3 + lifecycle rules, plus RDS point-in-time recovery underneath.

TierTakenKeptMechanismRestores
PITRcontinuous (WAL)10 daysRDS automated backupsany second in the last 10 days — beats even hourly granularity
Hourlyevery hour, per school10 days, then auto-deletedk8s CronJob: pg_dumps3://backups/hourly/<school>/ · S3 lifecycle expires at 10 done school, one hour granularity, fast
Daily02:00 IST, per school30 dayssame job → s3://backups/daily/<school>/one school, day granularity
Monthly1st of month12 months (Glacier after 60 d)promotion of that day's dailycompliance / long look-back
S3 lifecycle (Terraform-managed)
rule "expire-hourly" { prefix = "hourly/"  expiration { days = 10 } }
rule "expire-daily"  { prefix = "daily/"   expiration { days = 30 } }
rule "monthly-cold"  { prefix = "monthly/"
  transition { days = 60  storage_class = "GLACIER_IR" }
  expiration { days = 365 } }
Bucket: versioned, encrypted (KMS), Object Lock optional for tamper-proofing, replicated to the DR region (§8).
Non-negotiables
  • Per-school files — restoring school #42 never touches the other silos (the whole point of option E).
  • Backup monitoring: the CronJob writes a success marker per school; a daily check alerts on any school with no fresh backup — silent backup failure is the classic DR killer.
  • Restore drills: monthly, restore one random school's dump into a scratch instance and run a row-count sanity check. A backup that was never restored is a hope, not a backup.

8 Disaster-recovery plan

Targets: RPO ≤ 1 hour (per your backup rule; effectively minutes via PITR) and RTO tiered by scenario. Everything is restorable from code + backups because the entire infrastructure is Terraform.

ScenarioWho fixes itRTOHow
Pod / node crashautomaticseconds–minutesKubernetes self-healing + HPA; multi-AZ node group
Availability-zone outageautomatic~1–2 minRDS Multi-AZ failover; pods reschedule to surviving AZ; ALB spans AZs
Bad deployrunbookminuteskubectl rollout undo — previous image tag is one command away (SHA-tagged images)
Data corruption / bad migration (one school)runbook< 1 hPITR to just-before, or restore that school's hourly dump into a fresh DB, repoint its catalog entry
Ransom/tamper of primary datarunbookhoursrestore from versioned, cross-region-replicated S3 (Object Lock protects history)
Full region outagerunbook< 4 hsee the region runbook ↓
Region-loss runbook (drilled twice a year)
  • 1. Declare — one person is incident commander; status page updated first.
  • 2. Provisionterraform apply against the DR region (same code, different aws_region var). ~20 min for VPC + EKS + ECR.
  • 3. Images — ECR cross-region replication means images are already there.
  • 4. Data — restore each school from the replicated S3 backups (parallel jobs, catalog-driven; priority order: largest schools first).
  • 5. Cutover — Route 53 flips the one domain to the new ALB (low TTL kept at 60 s). Single domain = single DNS change.
  • 6. Verify — smoke suite per school (login, dashboard, one write), then close the incident with a written timeline.
Why this works: the three replicated things — Terraform code (git), images (ECR replication), data (S3 cross-region) — are the entire system. Everything else is derived.

9 Per-school admin panel & feature control

Two admin surfaces, strictly separated: a school admin panel for each school's own staff, and a platform panel for you. Feature flags live in the control plane so features can be turned on per school without deploys.

Roles (per membership)
RoleScopeCan
platform_adminyou / your teamonboard schools, set plans & feature flags, view aggregates (F-gate only), never reads school rows in normal operation
school_adminone schoolmanage that school's users & roles, branding, toggle features within their plan, view school audit log
teacher / staffone schoolday-to-day features per school-admin's settings
Feature flags (control plane)
school_features
  school_id      "sch_dps_pune"
  feature_key    "analytics" | "attendance"
                 | "fee_module" | "parent_portal"
  enabled        true
  source         "plan" | "school_admin" | "platform"
  updated_by / updated_at        # audited
  • API: a FeatureGuard (@Feature('fee_module')) returns 404 when off — features that are off don't exist, they aren't just hidden.
  • UI reads GET /me/features once per session; flags cached 60 s server-side.
  • Plan defines the ceiling; school admins toggle freely below it; you override anything from the platform panel.

10 Build order

  • 1. Control plane + auth integration (NestJS): schools registry, memberships keyed by the auth service's sub, JWKS token verification, active-school API.
  • 2. Tenancy plumbing: TenantMiddleware, connection manager, catalog-driven migrations; two demo schools end-to-end.
  • 3. School picker UI + school switcher + deep-link interstitial.
  • 4. Backups: per-school CronJob + S3 lifecycle (10 d hourly / 30 d daily / 12 mo monthly) + backup monitoring alert.
  • 5. Security hardening: WAF, throttler, audit log, Secrets Manager rotation, pgAudit.
  • 6. Admin panels: school admin first (users + feature toggles), then the platform panel (onboarding = the automation from the multi-tenancy diagram).
  • 7. First DR drill — restore one school + region-runbook tabletop. Only after this call the platform production-ready.
  • 8. Later: F's aggregation gate when cross-school analytics is requested.

11 Architecture diagram, DevOps & cost

The whole system in one picture, the pipeline that ships it, and what it costs per month at three sizes — pilot, production and scale.

Architecture at a glance 👨‍🏫 Users teachers · admins · staff Route 53 (DNS) app.yourschools.com — one domain External auth service hosted login → JWT (JWKS) CI/CD — GITHUB ACTIONS git push / pull request lint · unit tests · audit docker build — SHA tag push to ECR (scan) sandbox deploy + smoke approve → prod rollout AWS AP-SOUTH-1 (MUMBAI) — VPC · MULTI-AZ AWS WAF + ALB public subnets · TLS (ACM) Secrets Manager + KMS per-school DB creds · rotation EKS CLUSTER — PRIVATE SUBNETS NestJS pods ×N (HPA) TenantGuard · pool per school Backup CronJobs pg_dump — hourly · daily · monthly Control-plane DB registry · memberships · flags RDS Postgres silos one database per school (A…N) S3 backups 10 d / 30 d / 12 mo DR REGION — AP-SOUTHEAST-1 ECR + S3 replicas cross-region, automatic terraform apply rebuild in < 4 h (§8) One request path: user → Route 53 → WAF/ALB → NestJS pods → membership check against the control plane → that school's silo (§2). Identity comes from the external auth service, verified via JWKS. CronJobs read the silos and write per-school dumps to S3 (§7). In a region loss, Route 53 flips the same domain to the DR region's ALB (§8).

Compute options compared — why EKS

OptionOps loadPool-per-school (§4)?Pilot base / moVerdict
EKS — managed Kubernetes medium–high ✔ long-lived pods hold the pools ~$175 chosen team, repo, Terraform and the §8 DR runbook are already k8s-shaped; portable off AWS
ECS Fargate — AWS containers, no nodes to manage low ✔ tasks are long-lived ~$110 closest rival the greenfield pick for a team without k8s skills; no $73 control-plane fee, but AWS-only and our infra code would need a rewrite
Lambda — serverless functions very low ✘ needs RDS Proxy + §4 redesign; NestJS cold starts ~$40 cheapest at pilot wins on spiky traffic — ours is steady school-hours load, where always-on is cheaper; still right for cron-shaped work later (EventBridge)
App Runner — container PaaS very low ~ long-lived, but scale-to-zero reintroduces cold starts ~$60 too cramped clunky VPC model, no cron jobs — we'd bolt Lambda on anyway; fine for one small service, not a platform
Elastic Beanstalk — legacy PaaS on EC2 medium ~$90 skip maintenance-mode energy, awkward with Terraform; nothing it does that ECS/EKS don't do better
Plain EC2 — VMs + Docker Compose high (patching, SSH deploys) ~$60 demo only no self-healing, no rolling deploys; the §8 runbook becomes slow and manual
Lightsail — fixed-price bundles low ~$40 skip weak IAM/VPC integration — can't meet the §6 private-subnet + KMS posture
Outside AWS — Render / Fly / Railway · Nomad low varies skip PaaS options conflict with the VPC/KMS/WAF design; Nomad's ecosystem is too small to bet on
Rule of thumb: greenfield on AWS with no Kubernetes experience → ECS Fargate; existing k8s investment or multi-cloud ambitions → EKS. We are squarely the second case. The decision is per-workload, not all-or-nothing: the API fleet stays on EKS, and the cron-shaped work (backups, freshness checks) can move to EventBridge + Lambda later without touching the core design.

DevOps approach

Everything is code
  • Terraform owns all infrastructure — VPC, EKS, RDS, S3 lifecycle, WAF, Route 53. State in S3 with DynamoDB locking; the AWS console is read-only.
  • Kubernetes manifests (Helm) live in the same repo — reviewed and versioned like any code.
  • School onboarding is a pipeline job, not a checklist: create DB + user + secret, run migrations, insert the catalog row.
  • Same modules for production, sandbox and the DR region — only tfvars differ. This is what makes the §8 runbook a variable change, not a rebuild-from-memory.
Environments
  • Two cloud environments only. Local dev is docker-compose on the laptop — free, and not a deployed environment.
  • sandbox — prod-shaped but scaled down: single-AZ RDS, spot nodes, two demo schools. Auto-deployed on every merge; also where restore drills and demos run.
  • production — multi-AZ everything; changes arrive only through the pipeline's manual gate.
  • DR — no standing environment: code (git) + replicated images (ECR) + replicated backups (S3) are the whole system, so standing cost is near zero.
1Push / PRlint, unit tests, npm audit run in CI — red blocks the merge.
2BuildDocker image tagged with the git SHA — never latest; rollback needs exact tags.
3PublishPush to ECR; scan-on-push blocks critical CVEs.
4SandboxAuto-deploy; migrations loop the catalog per school; smoke suite runs.
5ApproveOne-click manual gate — the only human step between merge and prod.
6ProdRolling deploy; rollback = kubectl rollout undo (§8).
Observability & alerts
  • Structured JSON logs with school_id + request id on every line → CloudWatch Logs; one query answers "what happened to school X at 10:04".
  • Metrics: p95 latency, 5xx rate, pod restarts, HPA saturation; per-silo RDS CPU / storage / connections.
  • Alerts: 5xx spike, p95 breach, backup-freshness per school (§7), RDS storage > 80%, certificate expiry, failed-login spikes.
  • Dashboards: one platform overview + a per-school drill-down keyed by school_id.

What it costs (monthly, ap-south-1)

ComponentPilot — ~5 schoolsProduction — ~50 schoolsScale — ~200 schools
EKS control plane$73$73$73
Worker nodes (EC2)2 × t3.medium — $673 × t3.large — $1906 × m6g.large — $420
RDS Postgresdb.t4g.micro, 1-AZ — $15db.t4g.medium multi-AZ + 100 GB — $1452 × r6g.large multi-AZ + 500 GB — $850
ALB + WAF$25$45$80
NAT gateway$36$40$70
S3 backups (+ Glacier)$3$15$50
Secrets Manager$3$25$85
CloudWatch · Route 53 · ECR$10$27$60
Sandbox environment$80$120
Total ≈$230 / mo (~₹19k)$640 / mo (~₹54k)$1,810 / mo (~₹1.5L)
Per school ≈$46$13$9
Cost levers (biggest first)
  • Database-per-school ≠ instance-per-school — many silo databases share one RDS instance while keeping option E's isolation (separate DB + separate credentials). Promote heavy schools to their own instance only when they earn it.
  • Savings Plans / reserved instances once load is steady — 30–40% off EC2 + RDS.
  • Graviton (t4g / m6g / r6g) — already assumed in the table, ~20% cheaper than x86.
  • Spot nodes for sandbox and the backup CronJobs.
  • VPC endpoints for S3 / ECR — cuts NAT data-processing charges.
  • The §7 lifecycle rules are a cost control too — hourlies die at 10 days, Glacier after 60.
Cost guardrails
  • AWS Budgets — alerts at 80% and 100% of the monthly target; anomaly detection on.
  • Cost-allocation tags on everything (env, school_id where possible) — Cost Explorer then shows cost per school.
  • Weekly cost review is part of ops, like uptime — the per-school number is the one to watch: it should keep falling as schools grow.
Estimates, not quotes: on-demand ap-south-1 prices as of Aug 2026 (~₹84/$), excluding data transfer, domain and support plan. Re-price in the AWS Pricing Calculator before committing.

12 Compute options compared — why EKS

Every AWS way to run the NestJS fleet, judged against this design: the pool-per-school connection manager (§4) needs long-lived processes, the security posture (§6) needs real VPC/KMS integration, and the DR runbook (§8) assumes declarative redeploys. Costs are rough pilot-scale on-demand figures per month.

OptionOps loadPool-per-school (§4)?Pilot / moVerdict
EKS — managed Kubernetesmedium–high✔ long-lived pods hold the pools~$175chosen team, repo, Terraform and the §8 DR runbook are already k8s-shaped; portable off AWS
ECS Fargate — AWS containers, no nodes to managelow✔ tasks are long-lived~$110closest rival the greenfield pick for a team without k8s skills; no $73 control-plane fee, but AWS-only and our infra code would need a rewrite
Lambda — serverless functionsvery low✘ needs RDS Proxy + a §4 redesign; NestJS cold starts~$40cheapest at pilot wins on spiky traffic — ours is steady school-hours load, where always-on is cheaper; still right for cron-shaped work later (EventBridge)
App Runner — container PaaSvery low~ long-lived, but scale-to-zero reintroduces cold starts~$60too cramped clunky VPC model, no cron jobs — we'd bolt Lambda on anyway; fine for one small service, not a platform
Elastic Beanstalk — legacy PaaS on EC2medium~$90skip maintenance-mode energy, awkward with Terraform; nothing it does that ECS/EKS don't do better
Plain EC2 — VMs + Docker Composehigh (patching, SSH deploys)~$60demo only no self-healing, no rolling deploys; the §8 runbook becomes slow and manual
Lightsail — fixed-price bundleslow~$40skip weak IAM/VPC integration — can't meet the §6 private-subnet + KMS posture
Outside AWS — Render / Fly / Railway · Nomadlowvariesskip PaaS options conflict with the VPC/KMS/WAF design; Nomad's ecosystem is too small to bet on
Reading the table: Lambda's ~$40 looks tempting until the workload shape matters — steady school-hours traffic favors always-on compute, and the §4 connection manager assumes processes that live longer than a request. ECS Fargate is the honest runner-up; EKS wins on what already exists (skills, Terraform, runbooks), not on elegance. Revisit trigger: if cluster upkeep ever consumes >10% of team time, trial ECS Fargate for the API tier before adding more k8s surface.