Skip to content

Architecture

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

Architecture

How the module is put together and how traffic moves through what it deploys.

Repository layout

Path Contents
main.tf Root module. Wires the ten submodules together and passes values between them
variables.tf 79 root input variables
outputs.tf 6 root outputs
versions.tf Terraform and provider constraints, backend declaration, provider blocks
modules/ The submodules, listed below
schemas/ Database schema and migration SQL, plus PowerShell and Python migration runners
scripts/ Health-check and status scripts (check.ps1, status.ps1), maintenance-job deploy scripts, database and secret helpers
pipelines/ A README describing the deploy pipeline stages. The pipeline YAML files it names are not in the repo; you supply your own
terraform.tfvars.example, backend.tf.example Templates for the two gitignored config files

Submodules

Module Conditional on What it owns
database always PostgreSQL Flexible Server, database, firewall rules, max_connections
storage always Storage account and the osquery-ingest queue
monitoring always Log Analytics workspace, Application Insights
identity always User-assigned managed identity and its role assignments
key_vault enable_key_vault (default true) Key Vault, its role assignments and its secrets
auth always Entra ID app registration, service principal, app roles; optional API registration
containers always Container Apps environment, dashboard app, API app, optional ACR
maintenance always Scheduled Container App Job for database cleanup
functions enable_functions Flex Consumption Function App and its storage
networking enable_custom_domain and a non-empty custom_domain_name Front Door profile, endpoint, origin, route, custom domain

The key_vault, functions and networking modules are instantiated with count, so they are addressed as module.key_vault[0], module.functions[0] and module.networking[0] in state.

Request flow

Browsers reach the dashboard container app, either directly on its Container Apps FQDN or through Front Door when a custom domain is enabled. The dashboard does two different things with the API:

  • Server-side calls go to http://reportmate-functions-api — the plain internal DNS name inside the Container Apps environment. Traffic stays inside the environment and carries the API_INTERNAL_SECRET header.
  • Browser-side calls go to the API's public HTTPS FQDN, which is why the API's ingress declares a CORS block listing the dashboard origin and, when configured, the custom domain.

Agents post telemetry straight to the API's external ingress. That ingress is external on purpose and the module carries a warning comment about it: if it were internal, the dashboard would keep working over internal DNS while every agent silently 404'd.

The API writes to Postgres. The dashboard picks up changes by polling the API; the module provisions no real-time messaging service, and the API skips event broadcasting because its EVENTS_CONNECTION setting is left unset.

Front Door, when enabled, has a single origin group pointing at the dashboard only. There is no route to the API through Front Door; the comment in modules/networking/main.tf says that was removed deliberately so that every request enters through the dashboard, which adds the internal auth header.

Container configuration

Both container apps use the same user-assigned managed identity for registry pulls and, on the dashboard, for Key Vault secret references.

Dashboard API
Container app name frontend_container_name api_container_name
Port 3000 8000
CPU / memory 0.5 / 1Gi (hardcoded) 2.0 / 4Gi (hardcoded)
Replicas 1–5 (hardcoded) api_min_replicas–api_max_replicas
Ingress external external (required)
Probes startup, liveness, readiness on / liveness /api/v1/health/live, readiness /api/v1/health/ready
Scale rule platform default declared HTTP concurrency rule, set to api_db_pool_max

Sensitive values on both apps are Container Apps secrets referenced by secret_name, not plain env entries: the database URL, the client passphrase and the internal API secret on both apps, plus the kiosk tokens on the dashboard. The code comments are explicit that this is so a routine az containerapp show does not print credentials.

The dashboard additionally pulls NEXTAUTH_SECRET and AZURE_AD_CLIENT_SECRET from Key Vault by reference, using the managed identity — but only when both key_vault_uri and auth_secrets are non-null, i.e. when enable_key_vault is true.

Container environment

What the containers module sets on each container app. "Secret" means the value is a Container Apps secret referenced by name; "Key Vault" means a Container Apps secret resolved from the vault through the managed identity, present only when enable_key_vault is true.

Dashboard (frontend_container_name):

Variable Value
NODE_ENV production
PORT, HOSTNAME 3000, 0.0.0.0
DATABASE_URL Secret db-url
API_BASE_URL http://<api_container_name>, the internal address
NEXT_PUBLIC_API_BASE_URL https://<API ingress FQDN>
API_INTERNAL_SECRET Secret api-internal-secret, from api_internal_secret
REPORTMATE_PASSPHRASE Secret client-passphrase, from client_passphrases
KIOSK_TOKENS Secret kiosk-tokens, from kiosk_tokens
HOST, VERCEL_URL, NEXT_PUBLIC_VERCEL_URL The site hostname: custom_domain_name when the custom domain is enabled, otherwise <frontend_container_name>.<environment default domain>
NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_URL, NEXTAUTH_URL, NEXTAUTH_URL_INTERNAL https:// plus the site hostname
AUTH_TRUST_HOST true
NEXTAUTH_SECRET Key Vault nextauth-secret
AZURE_AD_CLIENT_SECRET Key Vault auth-client-secret
AZURE_AD_CLIENT_ID, NEXT_PUBLIC_AZURE_AD_CLIENT_ID azure_ad_client_id
AZURE_AD_TENANT_ID azure_ad_tenant_id
AUTH_PROVIDERS, DEFAULT_AUTH_PROVIDER azure-ad
ALLOWED_DOMAINS allowed_domains
REQUIRE_EMAIL_VERIFICATION false
NEXT_PUBLIC_AUTO_SSO false
CONTAINER_IMAGE_TAG, NEXT_PUBLIC_VERSION frontend_image_tag
BUILD_ID, NEXT_PUBLIC_BUILD_ID Last seven characters of frontend_image_tag
BUILD_TIME, NEXT_PUBLIC_BUILD_TIME timestamp() at plan time
AZURE_CLIENT_ID The managed identity's client ID

Because BUILD_TIME and NEXT_PUBLIC_BUILD_TIME come from timestamp(), every plan shows an in-place update to the dashboard container app.

API (api_container_name):

Variable Value
DATABASE_URL Secret db-url: postgresql://<db_username>:<db_password>@<server FQDN>:5432/<db_name>?sslmode=require
REPORTMATE_PASSPHRASE Secret client-passphrase, from client_passphrases
API_INTERNAL_SECRET Secret api-internal-secret
APPLICATIONINSIGHTS_CONNECTION_STRING The Application Insights connection string
ENVIRONMENT production
ENABLE_OIDC_AUTH enable_oidc_api
OIDC_ISSUERS, OIDC_AUDIENCE From the auth module; the audience is empty when OIDC is off
DB_POOL_MAX, DB_POOL_MIN api_db_pool_max, api_db_pool_min

The API's CORS block allows https://<frontend_container_name>.<environment default domain> and, when the custom domain is enabled, https://<custom_domain_name>, with credentials, methods GET, POST, PUT, DELETE and OPTIONS, and a one-hour preflight cache.

The connection budget

Three variables are coupled, and the coupling is enforced rather than documented-and-hoped:

api_db_pool_max * api_max_replicas <= db_max_connections - db_connection_reserve

That is a precondition on the API container app. Each replica holds its own pool, so a fully scaled-out API must still fit inside the Postgres server's max_connections with a reserve left for superuser, autovacuum and migrations. Breaking the relationship fails the plan instead of failing under load. db_connection_reserve (default 15) is a containers module variable and is not exposed at the root.

The API's HTTP scale rule sets concurrent_requests to api_db_pool_max for the same reason: a replica is full when every pooled connection is in use, and that is the point at which a second replica earns its cost.

What Terraform deliberately does not own

Several lifecycle blocks hand ownership of specific attributes to something else. These are load-bearing; removing them causes churn on every apply.

Resource Ignored Owner instead
Both container apps template[0].container[0].image The deploy pipeline, which pushes immutable tags with az containerapp update
Both container apps container_app_environment_id Azure's API cache returns inconsistent resource-group casing
PostgreSQL server storage_mb, zone Auto-grow moves storage without Terraform; a plan would otherwise try to shrink it
Maintenance job secret, tags Azure generates the registry credential secret when the registry is attached
Both Entra applications identifier_uris A null_resource running az ad app update, so there is a single writer

Authentication

Two distinct Entra ID surfaces:

  • Interactive sign-in. The auth module creates the reportmate_web application, its service principal and client secret, five app roles (user, admin, faculty, staff, student), Microsoft Graph delegated permissions (User.Read, profile, email, openid), and optional claims including groups emitted as roles. Groups in authorized_groups get the user role; principals in admin_principal_ids get the admin role. In prod, app_role_assignment_required is true, so only assigned principals can sign in; admin consent is auto-granted only in dev.
  • API bearer auth, gated behind enable_oidc_api and off by default. It creates a second application representing the API as a protected resource, with ReportMate.Read, ReportMate.Ingest and ReportMate.Admin app roles, an access_as_user delegated scope, and the Azure CLI pre-authorized on that scope. The issuer and audience it produces are fed to the API container as OIDC_ISSUERS and OIDC_AUDIENCE, and the API ignores bearer tokens until ENABLE_OIDC_AUTH is true.

Clone this wiki locally