MealMarkOS 2026
MealMarkOS
mealmarkos.com — a multi-tenant platform, not a logo swap on a shared database.
DukeDine started as a nutrition tracker for one school — scrape NetNutrition, log meals, ship to real students. By spring 2026 it had 100+ active Duke users and a scraper we’d reverse-engineered from CBORD’s undocumented HTML API.
Then I noticed what we’d actually built.
The scraper pointed at CBORD’s nn-prod backend. Every other CBORD school runs the same API with different unit IDs. Schools not on CBORD cluster around a handful of menu providers — Nutrislice, Dine On Campus — with simpler JSON surfaces we could adapter-wrap. The single-campus app was sitting on a multi-campus data engine.
That reframed the product: not “DukeDine for Duke,” but MealMarkOS — a platform where each university gets its own branded subdomain, its own isolated data plane, and a dining adapter matched to its provider, on top of a shared wellness engine. Duke became duke.mealmarkos.com, the first live tenant, not the whole product.
The pitch in one frame — replace the stack, keep the brand.
This is how I designed the multi-tenancy.
Schema-per-tenant, not database-per-tenant
The first hard call was isolation shape. Database-per-tenant gives the cleanest boundary — separate connection pools, backups, credentials, blast radius. I rejected it anyway. On a student-team budget, every migration, pool, backup policy, and secret multiplies by N schools. Ten pilot universities means ten of everything operational, before you’ve sold one contract.
Schema-per-tenant on one Aurora Postgres cluster was the compromise I could actually run:
platform.*— control plane: tenant registry, config audit log, user→tenant map, platform admins, connector jobstenant_<subdomain>.*— one schema per school, bootstrapped from modular SQL bundlespublic.*— shared reference catalogs (exercise library, allergens)
Each school gets real isolation at the Postgres schema level without operating N databases. Migrations run once against the cluster; tenant schemas apply modularly so a school only gets the feature bundles it opts into (core, iron, strava, coach, signals, etc.).
The tradeoff is honest: you’re trusting application code to never leak search_path. I accepted that because the alternative was operational bankruptcy, and because I could enforce tenancy at a single middleware chokepoint instead of sprinkling tenant filters across hundreds of queries.
Control plane vs. data plane
Splitting platform from tenant schemas wasn’t just naming — it was how procurement and demos would work.
The tenant registry row carries what varies per school: subdomain, schema name, status, feature flags, theme JSONB, adapter type and config, billing tier (pilot / standard / enterprise), institutional email domains for signup gating. When a dining director asks “who changed our settings on March 3rd,” the answer lives in platform.config_audit_log, not in a grep through application logs.
Background work has no HTTP Host header to resolve from. Scrape runs, Strava syncs, webhook deliveries look up tenancy through platform.user_tenant_map via getTenantClientForUser(user_id). Same isolation guarantee as the request path, different entry point.
Onboarding a new school is one CLI transaction — provisionTenant.js: validate subdomain, create schema from the modular bundles, register the tenant, write the audit entry. Not a fork. Not a manual runbook. Configuration, not code.
What procurement cares about — isolation and brand — mapped to what the control plane stores.
How a request stays in its lane
The canonical API path on a tenant subdomain:
- Request hits
duke.mealmarkos.com→ ALB routes by host header to the backend service (wildcard ACM cert on*.mealmarkos.com, plus per-tenant custom domains when needed). attachTenantDbmiddleware resolves the tenant — explicitX-Tenant-Idwins, then subdomain againstPLATFORM_BASE_DOMAIN, then custom-domain lookup — and pins a Postgres client to that tenant’s schema withSET search_path.- Route handlers call
getDb(req); queries are additionally scoped byreq.user.idfrom the Supabase JWT verified upstream. No Postgres RLS — explicit per-request scoping is the boundary we chose over policy magic. - Every query runs in the resolved tenant schema unless it’s intentionally hitting
platform.*orpublic.*.
I rejected “one shared schema + RLS policies” for the same reason I rejected DB-per-tenant: at our scale and team size, debugging a leaked row because a policy was wrong felt harder than debugging middleware that forgot to attach. One chokepoint, one place to audit.
Showcase mode as a framework guarantee
Selling to institutions means demos with real product density and zero data risk. Showcase mode is an access_mode on the tenant row: read-only end to end.
Enforcement isn’t per-route trust. The MCP tool invoker blocks any tool with mutates: true at a single chokepoint. The web UI wraps write CTAs in <LockedFeature>. A prospect can click through a fully populated Duke-shaped tenant without touching student data or leaving audit ambiguity about who wrote what.
That only works because tenancy and mutation policy are framework-level — not “remember to check showcase in this handler.”
Adapter-as-configuration
Multi-tenancy isn’t just data isolation — it’s different upstream systems per tenant. Duke runs NetNutrition (CBORD HTML we already scraped). Other pilots run Nutrislice or Dine On Campus JSON APIs.
All three conform to one ScrapeAdapter interface. Adapter choice and config live on the tenant row. Onboarding a Nutrislice school is adapter configuration plus provisioning, not a codebase fork. Per-restaurant selector overrides are validated so they can’t override the school-level base URL — a small detail that prevents one misconfigured scraper from hitting the wrong campus’s data.
Same shared engine modules — dining adapters, logging, Iron Logic, connectors — provisioned per tenant schema.
From one EC2 box to a tenant-routed cluster
The deployment had to match the data model. Legacy DukeDine still ran on a single EC2 instance during transition; the platform moved to ECS Fargate — four services (backend API, web SPA, marketing landing, scrape worker) behind an ALB with host-header routing, Terraform-managed in deploy/ecs/, GitHub OIDC keyless deploys to ECR, Secrets Manager rotating the DB password every seven days.
Duke’s 100+ users migrated in as tenant_duke, so the platform launched with a live pilot tenant rather than an empty shell. As of June 2026 we’re piloting at 10+ universities with active student users (provisioned at runtime beyond the two tenants visible in seed migrations). No signed paying customers yet — pre-revenue, Colab-grant funded — but the architecture is built for N schools, not for Duke.
What it taught me
Three things I’d carry to the next multi-tenant system:
The tenancy decision is a budget decision. DB-per-tenant isn’t “more correct” — it’s more expensive to operate. Schema-per-tenant with a hard middleware boundary was the shape we could afford to run while still selling isolation to procurement.
Control plane tables are product features. Audit logs and tenant registry aren’t infra trivia — they’re what a university asks about before signup.
Provision in one transaction or don’t provision. If onboarding a school takes a runbook, you’ll onboard three schools and stop.
MealMarkOS is what happened when we realized the engine underneath was already built for everyone else’s dining hall too.