Repository navigation
Architecture
How the module is put together and how traffic moves through what it deploys.
| 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 |
| 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.
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 theAPI_INTERNAL_SECRETheader. - 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.
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.
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.
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.
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 |
Two distinct Entra ID surfaces:
-
Interactive sign-in. The
authmodule creates thereportmate_webapplication, 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 includinggroupsemitted as roles. Groups inauthorized_groupsget the user role; principals inadmin_principal_idsget the admin role. Inprod,app_role_assignment_requiredis true, so only assigned principals can sign in; admin consent is auto-granted only indev. -
API bearer auth, gated behind
enable_oidc_apiand off by default. It creates a second application representing the API as a protected resource, withReportMate.Read,ReportMate.IngestandReportMate.Adminapp roles, anaccess_as_userdelegated scope, and the Azure CLI pre-authorized on that scope. The issuer and audience it produces are fed to the API container asOIDC_ISSUERSandOIDC_AUDIENCE, and the API ignores bearer tokens untilENABLE_OIDC_AUTHis true.
MIT licensed — reportmate.app