Skip to content

Multi-Client Architecture

CampusCore uses a BYOC (Bring Your Own Cloud) deployment model: each university runs in their own AWS account from a single shared codebase. This document covers the key systems that make this work.

Overview

Shared codebase (GitHub)
    ├── deploy/vsu    → GitHub Environment "vsu_pilot"  → VSU's AWS account
    ├── deploy/howard → GitHub Environment "howard"     → Howard's AWS account
    └── deploy/...    → GitHub Environment "..."        → ...'s AWS account

Each deployment is fully isolated: separate database, separate S3 buckets, separate ECS cluster. The only shared resource is the GitHub repository and CI/CD pipeline.

Dynamic Configuration (AppConfig)

All institution-specific configuration lives in the AppConfig database model, a singleton per instance.

Key Fields

Field Purpose
institution_name Full name (e.g., "Howard University")
institution_abbreviation Short form (e.g., "HU")
assistant_name AI assistant name (e.g., "Bison")
institution_logo Uploaded logo image (served as institution_logo_url in API payloads)
welcome_message Chat welcome message; blank means the default greeting, addressed to each user by name
primary_color / secondary_color Brand colors (hex)
is_configured Whether the setup wizard has been completed

How Config Reaches the UI

  1. Context processor (campus_core/context_processors.py:app_config) injects config values into every Django template as app_* variables
  2. CSS custom properties (--cc-primary, --cc-secondary) are set in <style> tags in base templates, consumed by CSS classes
  3. Agent prompt is built at runtime by prompts/prompt_builder.py, reading from AppConfig

Caching

Each process holds its own copy of the active AppConfig row, kept fresh by a shared stamp - a small token in django.core.cache (Redis in every cloud deployment). Any instance-level save or delete replaces the stamp once its transaction commits - AppConfig.save() and delete() register the hook themselves, so no writer can forget. A process whose recorded stamp no longer matches the shared one re-reads the row on its next AppConfigService.get_config() call. A setup-wizard save or branding change in one gunicorn worker is therefore visible to every worker on its next request, with no restart. Locally without REDIS_URL the cache backend is per-process LocMem, so the freshness guarantee degrades to per-process semantics - fine for a single-process dev server. Queryset writes (AppConfig.objects.filter(...).update(...)) bypass the hook; a writer using them must call AppConfigService.refresh_cache() itself.

Setup Wizard

On first deployment, AppConfig.is_configured is False. The SetupRequiredMiddleware enforces, in evaluation order:

  • Allowed paths (checked before the config is even read): /cc_admin, /accounts/, /api/htmx/settings/, /static/, /health_check, /readiness, /setup/, and /api/bootstrap - the last stays open because reporting setup state is its job.
  • JSON surfaces (/api/, /_allauth/) → 503 with a typed {"error": {"code": "setup_required"}} body; a fetch() cannot usefully consume HTML or a redirect, so the SPA reads the code and routes itself.
  • Staff users → redirected to /setup/ (the wizard)
  • Non-staff users → see a "workspace being configured" holding page

After the admin completes the wizard, is_configured is set to True and the middleware becomes a no-op - in every worker process, via the caching mechanism above.

Agent Prompt Templating

The agent system prompt (prompts/agent_system_prompt.py) uses Python str.format() placeholders:

  • {assistant_name} — the AI's name
  • {institution_name} — the institution's full name
  • {abbreviation} — short form
  • {institution_website} — institution URL

prompts/prompt_builder.py:build_agent_system_prompt() reads AppConfig and formats the template. This is called at the start of each chat session.

CI/CD Pipeline

Trigger Patterns

Trigger Environment
Push to deploy/howard howard
Push to deploy/vsu vsu_pilot
workflow_dispatch with client=howard howard

How It Works

  1. resolve-env job — derives the client name from the branch or input
  2. deploy-infra job — runs infrastructure/base/ Terraform in the client's AWS account
  3. deploy-app job — builds Docker images, pushes to client's ECR, deploys ECS via infrastructure/app/

Each job uses environment: ${{ needs.resolve-env.outputs.client }}, which loads the corresponding GitHub Environment's secrets and variables.

Per-Client Configuration

All client-specific values are stored as GitHub Environment secrets/variables:

  • AWS_ROLE_ARN — IAM role in the client's account
  • TF_STATE_BUCKET — S3 bucket for Terraform state
  • CUSTOM_DOMAIN_WITH_PROTOCOL — the client's domain
  • SLACK_CHANNEL_WORKFLOW_RUNS — channel ID for that tenant's workflow notifications (variable, not secret)
  • ENABLE_INDEX_MAINTENANCE_SCHEDULE — toggle for the daily auto-rebuild EventBridge schedule (on by default, per-environment opt-out)
  • All SAML, API key, and database secrets

See GitHub Environment Variables Reference for the complete list.

Cross-Tenant Shared Resources

A few values are deliberately repo-level, not per-tenant:

Resource Why repo-level
SLACK_BOT_TOKEN One Slack app in the CampusCore workspace; same bot identity posts to every tenant's channel. Override at env level only when a client requires their own isolated bot.
ADMIN_AWS_ROLE_ARN Our admin account's role — same for every client deploy.
Sentry org integration One Sentry organization holds all tenant projects.

Infrastructure Layout

infrastructure/ is split into three layers:

infrastructure/
├── base/      — RDS, S3, SQS, ALB, SGs, ECR, ECS cluster.
│                Per-client state. Composed from modules/.
├── app/       — ECS task definition + service + EventBridge schedules.
│                Per-client state. Currently flat (not modularized) except
│                where it consumes modules/scheduled_ecs_task.
└── modules/   — Reusable Terraform modules (rds, ecs_iam, networking,
                 scheduled_ecs_task, …). Cross-tenant.

base/ and app/ are two separate Terraform applies driven by the deploy-infra and deploy-app jobs respectively. They communicate via the base layer's remote state outputs (local.base.ecs_cluster_id, etc.). Adding a new piece of app infrastructure goes in app/; only put it in modules/ if there are at least two real call sites.

Adding a new scheduled task

The modules/scheduled_ecs_task/ module wraps EventBridge Scheduler + the IAM role + the ECS RunTask command override. Adding a new daily cron task (say, a weekly metrics rollup) is ~15 lines in app/:

module "weekly_metrics_rollup" {
  source = "../modules/scheduled_ecs_task"
  count  = var.enable_weekly_metrics ? 1 : 0

  name                = "campuscore-${local.env_sanitized}-weekly-metrics"
  schedule_expression = "cron(0 7 ? * SUN *)"
  cluster_arn         = local.base.ecs_cluster_id
  task_definition_arn = aws_ecs_task_definition.campuscore.arn
  execution_role_arn  = local.base.ecs_task_execution_role_arn
  task_role_arn       = local.base.ecs_task_role_arn
  subnet_ids          = local.base.subnet_ids
  security_group_ids  = [local.base.ecs_tasks_sg_id]
  container_name      = "campuscore-web"
  command             = ["python", "manage.py", "my_weekly_rollup"]
}

The deploy role's scheduler:* permission (in infrastructure/deploy-roles/deploy-role.yaml) covers any number of schedules — no IAM change needed per new task.

Database-side per-tenant tuning

Where Postgres autovacuum defaults don't fit our access pattern, we override them per table with ALTER TABLE ... SET (...) in a migration rather than changing the cluster-wide settings (which would affect every table in every client's RDS). Currently:

When adding a similar tuning, put it in a migration so every client environment converges to the same setting on the next deploy. Don't apply manually via psql.

Terraform state hygiene

.terraform/, *.tfstate, *.tfplan, and the standard set are in .gitignore. .terraform.lock.hcl is NOT gitignored — it pins provider versions for reproducibility and must be committed. If you're new to the repo and run terraform init for the first time, the cache lands in infrastructure/<layer>/.terraform/ and is excluded automatically.

Bootstrap Process

infrastructure/bootstrap/main.tf is a small Terraform module run once per new client that creates:

  1. GitHub OIDC Identity Provider — allows GitHub Actions to authenticate
  2. IAM Deploy Role — scoped to the specific repo + environment
  3. S3 Bucket — for Terraform state with versioning and encryption

The IAM Deploy Role's policy lives in infrastructure/deploy-roles/deploy-role.yaml (a CloudFormation template). When we add a Terraform resource that needs new AWS permissions (e.g., the EventBridge Scheduler work needed scheduler:*), the CloudFormation template is updated and the deploy-infra job's "Sync client deploy role (CloudFormation)" step propagates the change to every client account on the next deploy.

See the Onboarding Guide for the full step-by-step process.