Environment Configuration¶
How environment variables are declared, validated, and wired through to deployments. Read this before adding or changing any env var.
The Settings model is the single source of truth¶
Every environment variable is declared once as a typed field on the Settings model in campus_core/settings_envs.py (pydantic-settings) - the single source of truth. settings.py instantiates config = Settings() and sources its Django globals from it; validation (and fail-fast on missing required vars) happens at import. To add or change an env var, edit the Settings model, then read it as config.<NAME> in settings.py - don't reintroduce ad-hoc os.environ/env(...) reads.
Required vs optional vs cloud-required¶
A field's requirement is its default: no default = required everywhere; a default = optional. For a var that has a safe local default but must be set in the cloud (e.g. REDIS_URL, or TRUSTED_PROXY_HOPS - the trusted X-Forwarded-For suffix the client IP is read from, 1 behind the ALB and 0 locally), tag it cloud_required=True via the deploy(...) metadata so it's enforced at cloud boot and in the deploy-contract check.
Required means present, non-empty, and usable¶
A required variable must be present, non-empty after stripping, and usable, or the process does not start.
Settings enforces that at construction: SECRET_KEY, OPENAI_API_KEY and GEMINI_API_KEY carry min_length=1, and APP_FERNET_KEY is validated by building a cipher from it, since a malformed key fails exactly as an empty one does.
The model also sets str_strip_whitespace=True, which is load-bearing rather than tidy.
min_length counts characters, so without stripping a lone newline passes as a valid one-character secret, and a secret store that round-trips a trailing newline produces exactly that.
The reason the bar is boot rather than first use is that an empty string satisfies str.
Without these constraints the process comes up healthy and fails later: provider calls return a connection-shaped error the pipeline retries five times, and a connector credential write fails at first use.
A boot failure raises ImproperlyConfigured from _build_config in settings.py, naming the field and never the value.
The value is a credential and the error reaches the container log and CloudWatch, so the detail is assembled from the field path and the message alone.
S3 and SQS endpoints¶
Code reads the S3 server from one Django setting, settings.S3_SERVER, and never from the raw S3_* fields.
It is either LocalS3Server, the dev stack's or CI's own S3 server with its endpoint and keys, or AwsS3, real S3 reached with the task role.
Settings.s3_server in campus_core/settings_envs.py builds it, and the S3 clients in apps/main_app/clients/aws.py, the CSP and prepare_local_upload_bucket read it.
The env vars stay flat, because the deploy-contract check reconciles one variable per field:
S3_ENDPOINT_URL- where server-side calls go; empty means real S3.S3_PRESIGNED_ENDPOINT_URL- the host a presigned URL names, which the browser must reach; empty means the same asS3_ENDPOINT_URL.S3_ACCESS_KEYandS3_SECRET_KEY- the local server's keys.SQS_ENDPOINT_URL- the local SQS server (ElasticMQ); empty means real SQS.
Settings refuses to build, naming the fields and never a value, when:
S3_ENDPOINT_URLorSQS_ENDPOINT_URLis set whileIS_CLOUD_ENVis true;S3_ENDPOINT_URLis set and either key is empty;- a key or
S3_PRESIGNED_ENDPOINT_URLis set andS3_ENDPOINT_URLis empty.
Terraform sets none of these, so every cloud environment resolves to AwsS3 and real SQS.
docker-compose.yaml holds the local values, and CI's validate job reads the S3 keys, the bucket and the S3 server's published port from that file rather than keeping its own copy.
The deploy-contract check¶
scripts/check_deploy_contract.py (run by gate.sh, pre-commit, and CI) reconciles the model against the ECS task definition (infrastructure/app/ Terraform), .env_sample, and EventBridge schedules - so adding a var without wiring the pipeline fails the PR.
Where values live¶
The .env file lives in campuscore_app/. Docker Compose overrides DB vars via environment directives. In a cloud deployment the plain values are task-definition environment entries (infrastructure/app/container_env.tf) and the secrets are SSM SecureString parameters the ECS agent fetches at task boot (infrastructure/app/container_secrets.tf). See .env_sample for required variables.
Per-deployment switches¶
Some settings turn a feature on for one deployment, and their Terraform variable is fed from a GitHub Environment variable of the same name (registry).
AGENT_REASONING_SUMMARY_ENABLED(default off) asks the agent model for reasoning summaries, and the live chat line shows their titles. OpenAI can require a verified organization before it returns summaries, and each deployment can run on its own key. Turn it on only after one chat request with that deployment's key succeeds with it on.
INSTITUTION is the exception: no GitHub Environment variable feeds it, and nobody sets it by hand.
The deploy workflow resolves it from the environment's entry in campuscore_app/institutions.toml, and the same value picks the connector packages the image keeps and the registry loads.
An empty value, as in a local stack, the test suite and CI, loads every institution's packages, and a value the file does not name stops the container at boot.
Retired settings¶
AGENT_MODEL is retired: the agent's model and reasoning effort are rows set in the Models tool (model-cost-tracking.md).
Settings ignores keys it does not declare, so an .env that still sets it boots, and the value is ignored.
Terraform-only knobs are a different chain¶
Not every per-environment value is a Settings field. Infrastructure knobs the container never reads (database sizing, the read replica, schedule crons) are GitHub Environment variables that deploy-aws.yml passes to Terraform as TF_VAR_* values - the GitHub Environment Variables Reference is their registry, and RDS Configuration documents the database ones.