Skip to content

Subdomain Provisioning & Custom Domain SSL

How CampusCore provisions the per-deployment subdomain, and how the self-service custom-domain control plane issues and attaches client certificates at runtime.


Overview

Every deployment (when SSL is enabled) gets a subdomain under campuscoreai.com (e.g. vsu-troy-pilot.campuscoreai.com) with an ACM certificate on the ALB. That subdomain is the ALB's default certificate, the always-working bootstrap/fallback URL, and the CNAME target clients point at.

A client's own domain is self-service: an institution admin adds it in Settings > Custom Domain and creates the DNS records it shows them. A background reconciler drives certificate issuance and attaches it to the ALB - no redeploy, no CampusCore involvement.

ssl_mode - the deploy-time capability gate

ssl_mode is a Terraform variable (GitHub Environment variable SSL_MODE). It decides what infrastructure exists; individual domains are runtime state, not config.

ssl_mode Subdomain + default cert + HTTPS listener Reconciler Managed client certs Use
off no no no Throwaway test env. ALB serves HTTP only.
self_managed yes yes (liveness only) no Client terminates their own domain's TLS.
managed yes yes (+ ACM/ELB) yes CampusCore issues + attaches client certs.

In both non-off modes the ALB's port-80 listener redirects to HTTPS with a 301 as its own default action, so plain HTTP never reaches a task. Under off that listener forwards instead, which is what makes the HTTP-only mode work at all.

Runtime control plane

Data model

CustomDomain (apps/main_app/models/custom_domain.py) is a small state machine:

pending_dns --(request cert, ACM ISSUED)--> issued --(attach via SNI)--> attached --(check_live ok)--> live
     |
     +--(ACM validation timed out / failed)--> failed        any --(admin removes)--> removing --> deleted

live --(hourly health check)--> live        the check sets health_error; it never changes status

self_managed skips the certificate steps: pending_dns --(check_live ok)--> live.

Three free-text columns explain a row, and each belongs to exactly one part of that machine. failure_reason says why provisioning gave up and is set only on a failed row. last_error holds the last operational error the reconciler hit and is set only while the row is still transitional - pending_dns, issued, attached or removing. health_error says why the hourly health check found a live domain unhealthy, and is set only on a live row; health_checked_at alongside it says when that check last ran, and is null until the first pass after go-live. Three check constraints refuse every other combination: customdomain_failure_reason_only_when_failed, customdomain_last_error_only_while_transitional and customdomain_health_error_only_while_live. A fourth, customdomain_health_error_needs_a_check_time, refuses a health_error with no health_checked_at: they are two halves of one check, and a row carrying only the first would render as the reason plus "Not checked yet" on the same card. Both writers - CustomDomain.set_status and the reconciler's _transition - reset every one of these columns on every transition, so the constraints are a fence behind the writers rather than the first line of defence.

The API row (DomainRow in workspace_domains_api.py) is a union discriminated on status, one member per status, so the same rule holds on the wire. last_error reaches the client only on a transitional row, failure_reason only on a failed one, and health_error only on a live one. What the operator may do follows from the member rather than from a separate flag: a failed domain is the one that can be retried, and a live domain that is not primary is the one that can be promoted.

The settings page shows every non-removing domain with both required CNAME records as copyable rows: the ACM validation record ("Prove ownership", managed mode only) and the traffic record pointing the domain at the CampusCore subdomain ("Route traffic"). The section stays visible after go-live (collapsed) because the validation record must remain in the client's DNS for certificate renewal.

The reconciler

reconcile_custom_domains (management command) runs as a dedicated scheduled ECS task (infrastructure/app/custom_domain_reconciler.tf, rate(5 minutes)). Each run, per row: request the ACM cert, poll issuance and backfill the DNS validation record, attach the issued cert to the HTTPS listener via AddListenerCertificates, then confirm with check_live before going live. Removal detaches and deletes the cert.

The web/worker tasks do no certificate work. The reconciler's dedicated task role is the only principal permitted to mutate the listener or delete certificates.

The hourly health check

The same command on a second schedule, custom-domain-health-<env> at rate(1 hour), runs reconcile_custom_domains --health. It looks only at live rows: it probes each one with check_live, and in managed mode also describes the certificate and treats a status other than ISSUED, a renewal parked at PENDING_VALIDATION or FAILED, or fewer than thirty days to expiry as a problem. A live row in managed mode with no certificate ARN is a problem in itself - an ssl_mode flip from self_managed leaves rows in that state and nothing backfills them, so it would otherwise be reported healthy while nothing watched its certificate at all. The first problem it finds becomes the row's health_error, and health_checked_at is stamped either way.

The pass never changes a status. A live domain that stops resolving is still the hostname the institution published and still what SSO builds its URLs from, so demoting it over what may be a DNS blip would break sign-in rather than fix anything. For the same reason a failed probe is re-tried once, ten seconds later, before it counts, and a check that could not run at all - ACM unreachable, a crash in the probe - leaves the row exactly as it was.

When a domain flips between healthy and unhealthy the pass posts one Slack event naming the domain and the reason; a domain that stays broken posts once, not hourly. The settings card shows the reason in a red panel and says when the check last ran, so an operator can also see that the monitor itself is alive. Two alarms watch the monitor itself, because a health check nobody checks is worth little. custom-domain-health-stale-<env> fires when three hours pass with no custom_domain.health.pass datapoint, which means the schedule stopped invoking, the task dies before the pass, or a hung pass is being ended by the watchdog. custom-domain-health-blind-<env> fires on custom_domain.health.errors, the count of checks that raised: such a check leaves its row untouched on purpose, so a pass where every check failed writes nothing and would otherwise look exactly like a deployment with no live domains.

Unlike the five-minute schedule, this one is always enabled. The reconciler schedule can switch itself off when idle because an admin adding a domain is an event the worker sees and can switch it back on with; a live domain breaking is exactly the event nobody in the system sees.

Trust follows proof

A domain enters host trust, CSRF trust, and canonical identity only once it is live (ownership proven via ACM DNS validation, or check_live for self-managed):

  • HostTrustMiddleware validates Host against static hosts ∪ live domains (ALLOWED_HOSTS = ["*"]; enforcement consolidated in the middleware, health probes answered upstream by HealthCheckMiddleware).
  • DynamicCsrfViewMiddleware folds live-domain origins into CSRF trust per request (fixing Django's @cached_property staleness that made runtime-added origins silently untrusted).
  • DomainService caches the live-domain set (30s TTL; a domain going live is trusted across all web tasks within the TTL) and resolves the primary live domain as canonical identity for SSO SP/RP URLs.

Security model

Principal Permissions Scoping
Web / worker task role none for custom domains
Reconciler task role acm:RequestCertificate/DescribeCertificate/ListCertificates/AddTagsToCertificate, acm:DeleteCertificate, elasticloadbalancing:AddListenerCertificates/RemoveListenerCertificates/DescribeListenerCertificates/DescribeListeners, cloudwatch:PutMetricData DeleteCertificate conditioned on aws:ResourceTag/ManagedBy = campuscore; listener actions scoped to the HTTPS listener ARN; ACM/ELB policy attached only in managed mode; PutMetricData has no resource ARN, so it is scoped by the cloudwatch:namespace condition

Every certificate is tagged ManagedBy=campuscore at request time. Domain input is validated (syntax, lowercase, no wildcard, infra-suffix deny-list) and capped at 5 per deployment (ALB SNI limit is 25).

Cross-account model (default subdomain)

  • aws (default) — client account, via assume_role. ACM certificate, ALB listener.
  • aws.admin — admin account. Route53 records for campuscoreai.com and the subdomain cert's validation.

Configuration

GitHub Environment Variable Required Description
SSL_MODE No off | self_managed | managed. Defaults to off.
CAMPUSCORE_HOSTED_ZONE_ID When provisioning Route53 hosted zone ID for campuscoreai.com.

The app reads SSL_MODE (capability gate) and HTTPS_LISTENER_ARN (reconciler only). Domains are managed at runtime, not via env.

Files

File Purpose
infrastructure/modules/subdomain/ {env}.campuscoreai.com Route53 + default ACM cert
infrastructure/base/alb.tf HTTPS listener (default cert); runtime certs attached by the reconciler
infrastructure/modules/ecs_cluster/main.tf HTTP listener - redirects to HTTPS or forwards, per https_redirect
infrastructure/base/outputs.tf ssl_mode, https_listener_arn
infrastructure/app/custom_domain_reconciler.tf Reconciler task def + scoped role + both schedules + the health-stale alarm
apps/main_app/models/custom_domain.py CustomDomain state machine
apps/main_app/services/custom_domain_provisioner.py ACM/ELB operations
apps/main_app/management/commands/reconcile_custom_domains.py Reconciler orchestration
apps/main_app/services/domain_service.py Trust/identity + check_live
campus_core/middleware.py HealthCheckMiddleware, HostTrustMiddleware, DynamicCsrfViewMiddleware
web/src/components/settings/DomainSettings.tsx Self-service UI (Settings > Custom Domain)
apps/main_app/apis/workspace_domains_api.py Typed self-service API

Test environments (ssl_mode = off)

No subdomain, cert, HTTPS listener, or reconciler; the ALB serves HTTP only and the custom-domain UI shows "not enabled". No certificate cost.