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¶
- Context processor (
campus_core/context_processors.py:app_config) injects config values into every Django template asapp_*variables - CSS custom properties (
--cc-primary,--cc-secondary) are set in<style>tags in base templates, consumed by CSS classes - 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— readsassistant_nameanduniversity_namefor the fallback system messagemiddleware/fast_path.py— uses config for greeting responses and meta-responsestools/search_knowledge.py/search_knowledge_folder.py— "General Knowledge" labelstools/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¶
resolve-envjob — derives the client name from the branch or inputdeploy-infrajob — runsinfrastructure/base/Terraform in the client's AWS accountdeploy-appjob — builds Docker images, pushes to client's ECR, deploys ECS viainfrastructure/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 accountTF_STATE_BUCKET— S3 bucket for Terraform stateCUSTOM_DOMAIN_WITH_PROTOCOL— the client's domainSLACK_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:
main_app_documentchunk— autovacuum scale factors tightened from 0.2 to 0.05 (migration 0057, applied when the table was still namedmain_app_documentindex). The default 20% dead trigger is too lax for HNSW, where dead heap tuples become dead graph nodes. See Vector Index Observability → Per-table autovacuum tuning.
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:
- GitHub OIDC Identity Provider — allows GitHub Actions to authenticate
- IAM Deploy Role — scoped to the specific repo + environment
- 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.