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¶
- 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¶
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¶
resolve-envjob - derives the client name from the branch or input, and its institution fromcampuscore_app/institutions.tomldeploy-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 (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:
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.