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-sandbox    → GitHub Environment "vsu-sandbox"    → VSU's sandbox AWS account
    ├── deploy/vsu-troy-pilot → GitHub Environment "vsu-troy-pilot" → VSU's pilot AWS account
    └── deploy/<env>          → GitHub Environment "<env>"          → that deployment'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/vsu-sandbox vsu-sandbox
Push to deploy/vsu-troy-pilot vsu-troy-pilot
workflow_dispatch with client=vsu-sandbox vsu-sandbox

The branch suffix after deploy/, or the client input, is the GitHub Environment name exactly.

How It Works

  1. resolve-env job - derives the client name from the branch or input, and its institution from campuscore_app/institutions.toml
  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.

Environment, institution and connectors

An environment and an institution are different things. An environment is one deployment: one GitHub Environment, one deploy branch and one AWS account. An institution owns one or more environments, such as VSU's vsu-sandbox and vsu-troy-pilot.

campuscore_app/institutions.toml maps each environment to its institution and lists the connector packages each institution carries on top of the common ones. The resolve-env job reads the institution once, with campus_core/institutions.py, and fails before any build or apply when the file does not map the environment. The institution then becomes the INSTITUTION build argument of both app images, whose build deletes every connector package the institution does not carry, and TF_VAR_institution, which reaches every container as INSTITUTION. A new environment therefore needs its line in that file on main before its first deploy. Which connectors a deployment carries explains the file and the gates it drives.

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.worker.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-worker"
  command             = ["python", "manage.py", "my_weekly_rollup"]
}

The task runs on the worker task definition because the worker entrypoint runs the command it is given, while the web entrypoint ignores a command override and would start a web server instead.

The command it runs subclasses ScheduledJobCommand (apps/main_app/management/scheduled_job.py) rather than Django's BaseCommand, and sets failure_consequence to one sentence saying what the operator loses while the job stays broken. Nothing reads a scheduled task's exit code, so that base class is the only thing that turns a failure into a message anyone sees; it refuses to run a job that has not stated its consequence.

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.