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 university-specific configuration lives in the AppConfig database model, a singleton per instance.

Key Fields

Field Purpose
university_name Full name (e.g., "Howard University")
university_abbreviation Short form (e.g., "HU")
assistant_name AI assistant name (e.g., "Bison")
university_logo_url URL to the university logo
welcome_message Chat welcome message
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

AppConfigService maintains a module-level cache (_cache) that is invalidated on model save. This means config changes take effect immediately without server restart.

Setup Wizard

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

  • Staff users → redirected to /setup/ (the wizard)
  • Non-staff users → see a "workspace being configured" holding page
  • Allowed paths (no redirect): /admin/, /accounts/, /static/, /health_check, /setup/

After the admin completes the wizard, is_configured is set to True and the middleware becomes a no-op.

Agent Prompt Templating

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

  • {assistant_name} — the AI's name
  • {university_name} — the university's full name
  • {abbreviation} — short form
  • {university_website} — university 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.

Agent internals that also use config

  • agent_core.py — reads assistant_name and university_name for the fallback system message
  • middleware/fast_path.py — uses config for greeting responses and meta-responses
  • tools/search_knowledge.py / search_knowledge_folder.py — "General Knowledge" labels
  • tools/reasoning_tools.py — generic examples (no university-specific references)

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 (default off)
  • 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.