Sample Deployment Configuration
The complete configuration files of the sample deployment. Everything
structural lives in main.tf; only five values change per environment. How
these pieces fit together is explained in
Walkthrough.
main.tf
# Reference composition: the component modules wired together by hand, with no
# orchestration module in front of them. This spells out the wiring so that it
# is visible. See walkthrough.md.
#
# The modules below are consumed from the dist registry as signed OCI module
# packages (see the "Module sources" section). That means `tofu init` here
# needs registry credentials and a published tag -- see the validation section
# of the sample deployment overview.
terraform {
# oci:// module sources are native from OpenTofu 1.10.
required_version = ">= 1.10"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
azapi = { source = "azure/azapi", version = "2.7.0" }
}
# A backend is required to plan or apply against real Azure. It is commented
# out so this directory can be validated without provisioning state storage:
# tofu init -backend=false && tofu validate
# backend "azurerm" {
# resource_group_name = "<state-resource-group>"
# storage_account_name = "<state-storage-account>"
# container_name = "tfstate"
# key = "environment/azure/sample/terraform.tfstate"
# subscription_id = "<state-subscription-id>"
# use_azuread_auth = true
# }
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
storage_use_azuread = true
}
# The cloudflare provider must be declarable (the gateway module pulls it
# into the graph) but is never configured here: the gateway's Cloudflare
# resources are all count-gated on var.cloudflared_enabled = false.
# ---------------------------------------------------------------------------
# Naming
# ---------------------------------------------------------------------------
# Every storage-account-backed module appends this suffix to resource names for
# global uniqueness. It is pinned here so the names below are predictable and
# this file is copy-pasteable. Changing it renames every storage account and
# the vault.
locals {
project_prefix = "abcd"
vnet_cidr = "10.9.0.0/16"
rg_tags = merge(var.rg_tags, { submodule = "resource-group" })
}
# ---------------------------------------------------------------------------
# Module sources
# ---------------------------------------------------------------------------
# Each component is consumed as a signed OCI module package from the dist
# registry: one OCI repository per component, published per release tag, so a
# single tag pins every component on a shared release train.
#
# No `//` subdirectory: the published package is re-rooted at the module root,
# so `modules/azure/compute` is the compute module itself.
#
# locals, not variables: `tofu init` resolves module sources before any
# variable value exists.
locals {
modules_base = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure"
modules_version = "v0.0.1"
}
resource "azurerm_resource_group" "env" {
name = "${var.project_name}-rg"
location = var.location
tags = local.rg_tags
}
# One user-assigned identity shared by every workload that reads object storage.
# Its client_id is what the Kastoria storage tier config calls
# managedIdentityClientId, so it must be threaded into app_config (see below).
resource "azurerm_user_assigned_identity" "app_identity" {
name = "id-app-storage-accessor"
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
}
# ---------------------------------------------------------------------------
# Networking
# ---------------------------------------------------------------------------
# Subnet names are a contract between this file and the modules that index
# created_subnets by key ("compute", "storage", "gateway").
locals {
subnets = {
compute = {
address_prefixes = [cidrsubnet(local.vnet_cidr, 5, 0)]
service_endpoints = ["Microsoft.ContainerRegistry", "Microsoft.KeyVault", "Microsoft.Storage"]
delegation_config = {
name = "container-app-delegation"
service_delegation = {
name = "Microsoft.App/environments"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
storage = {
address_prefixes = [cidrsubnet(local.vnet_cidr, 8, 8)]
service_endpoints = ["Microsoft.Storage"]
}
gateway = {
address_prefixes = [cidrsubnet(local.vnet_cidr, 8, 10)]
service_endpoints = []
}
}
}
module "networking" {
source = "${local.modules_base}/networking?tag=${local.modules_version}"
base_name = var.project_name
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
address_space = [local.vnet_cidr]
subnets = local.subnets
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
# The purge protection, soft delete retention, and SKU below correspond to a
# "dev"-profile key vault; raise them for production environments.
module "secrets" {
source = "${local.modules_base}/secrets?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
allowed_subnet_ids = [module.networking.created_subnets["compute"].id]
allowed_ips = var.allowed_ips
purge_protection_enabled = false
soft_delete_retention_days = 7
sku_name = "standard"
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Object storage
# ---------------------------------------------------------------------------
# Outer key = one storage account; inner key = one blob container plus a table
# of the same name with '-' stripped.
locals {
object_shards = {
"sn01" = { "hot-nodes-01" = {} }
"sb01" = { "hot-blocks-01" = {} }
"sb02" = { "hot-blocks-02" = {} }
"sb03" = { "hot-blocks-03" = {} }
}
# The table mirrors the container with '-' stripped so app_config can name
# the tables the module will create.
storage_table_names = {
for shard_key, containers in local.object_shards :
shard_key => [for container in keys(containers) : replace(container, "-", "")]
}
# storage_accounts is marked sensitive (it carries access keys); the storage
# tier config needs only the names, so unwrap it before it reaches a
# non-sensitive expression.
object_account_names = {
for shard_key, sa in nonsensitive(module.object_store.storage_accounts) :
shard_key => sa.name
}
}
module "object_store" {
source = "${local.modules_base}/objectstore?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
allowed_subnet_ids = [
module.networking.created_subnets["storage"].id,
module.networking.created_subnets["compute"].id
]
shards = local.object_shards
user_managed_identity_principal_id = azurerm_user_assigned_identity.app_identity.principal_id
allowed_ips = var.allowed_ips
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Event notification queue
# ---------------------------------------------------------------------------
# A dedicated, keyless storage account holding the queue evtnotify publishes
# to. The module creates its own publisher identity (umi-<base_name>-queue-publisher)
# and grants it send; the storage accessor identity has no queue access. Each
# queue_reader_principal_ids entry is granted Storage Queue Data Message Processor.
module "queue" {
source = "${local.modules_base}/queue?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
# Only the compute subnet publishes; nothing else in the VNet reads this queue.
allowed_subnet_ids = [
module.networking.created_subnets["compute"].id
]
reader_principal_ids = var.queue_reader_principal_ids
# Operator ranges plus the reader's egress. Seeds the initial rule set only:
# the module ignores later ip_rules changes.
allowed_ips = concat(var.allowed_ips, var.queue_reader_ips)
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Compute
# ---------------------------------------------------------------------------
# apps and jobs each carry a flat `tags` map, and merge() is repeated per entry
# because the two maps are indexed by different keys.
locals {
# Apps and jobs that talk to object storage get the shared accessor identity.
storage_app_names = ["kastoria", "smartlaunchapi"]
storage_job_names = ["stdyproc", "evtnotify"]
# Jobs that publish Notification Events get the queue publisher identity.
# Independent of storage_job_names: evtnotify is in both, because it reads the
# Changelog from object storage, so it carries both identities.
queue_job_names = ["evtnotify"]
storage_identity = {
id = azurerm_user_assigned_identity.app_identity.id
client_id = azurerm_user_assigned_identity.app_identity.client_id
}
queue_publisher = {
id = module.queue.publisher_id
client_id = module.queue.publisher_client_id
}
apps = {
ohif = {
container_config = {
name = "ohif"
image = "${var.acr_login_server}/kastoria-ohif:stable"
cpu = 0.5
memory = "1Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 8080
}
env_vars = {
DICOMWEB_URL = "https://sample.example.org"
}
revision_mode = "Single"
otel_exporter = "http://otel-gateway:4317"
tags = merge(local.rg_tags, {
app = "OHIF"
submodule = "compute"
})
}
# The Kastoria API. Reads its JWT verifying key from the environment's vault
# under a different name than the env var that receives it, and allows the
# OHIF origin through CORS.
kastoria = {
container_config = {
name = "kastoria-health"
image = "${var.acr_login_server}/kastoria-health:stable"
cpu = 2
memory = "4Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 3000
transport = "http"
cors_app_name = "ohif"
}
revision_mode = "Single"
key_vault_secrets = {
NO_JWT_VERIFYING_KEY = "jwtVerifyingKey"
}
otel_exporter = "http://otel-gateway:4317"
tags = merge(local.rg_tags, {
app = "KASTORIA-HEALTH"
submodule = "compute"
})
}
smartlaunchapi = {
container_config = {
name = "kastoria-smart-launch-api"
image = "${var.acr_login_server}/kastoria-smart-launch-api:stable"
cpu = 0.5
memory = "1Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 4000
transport = "http"
}
revision_mode = "Single"
key_vault_secrets = {
NO_JWT_SIGNING_KEY = "jwtSigningKey"
}
otel_exporter = "http://otel-gateway:4317"
env_vars = {
OHIF_VIEWER_URL = "https://sample.example.org"
REDIRECT_HOST = "https://sample.example.org"
}
tags = merge(local.rg_tags, {
app = "KASTORIA-HEALTH"
submodule = "compute"
})
}
# internal-only ingress: reachable from inside the environment, not from the
# internet.
consapi = {
container_config = {
name = "kastoria-consapi"
image = "${var.acr_login_server}/kastoria-consoleapi:stable"
cpu = 2
memory = "4Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = false
target_port = 3005
transport = "http"
}
revision_mode = "Single"
otel_exporter = "http://otel-gateway:4317"
env_vars = {
OHIF_VIEWER_URL = "https://sample.example.org"
}
tags = merge(local.rg_tags, {
app = "KASTORIA-CONSAPI"
submodule = "compute"
})
}
consui = {
container_config = {
name = "kastoria-consui"
image = "${var.acr_login_server}/kastoria-consoleui:stable"
cpu = 0.5
memory = "1Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 8081
transport = "http"
}
env_vars = {
VITE_BASE_PATH = "/console/"
}
revision_mode = "Single"
tags = merge(local.rg_tags, {
app = "KASTORIA-CONSUI"
submodule = "compute"
})
}
}
jobs = {
stdyproc = {
container_config = {
name = "kastoria-studyprocessor"
image = "${var.acr_login_server}/kastoria-studyprocessor:stable"
cpu = 2
memory = "4Gi"
args = ["process", "DICOMStudy"]
}
otel_exporter = "http://otel-gateway:4317"
replica_timeout = 3600
trigger_schedule = "*/2 * * * *"
# Derived from replica_timeout minus a cold-start allowance.
env_vars = {
STUDY_PROCESSOR_RUN_BUDGET_MS = "3480000"
}
tags = merge(local.rg_tags, {
app = "STUDYPROCESSOR"
submodule = "compute"
})
}
# Same cadence and timeout as stdyproc. A schedule that fires more often
# than a Run can finish is safe: the Run Lease makes the overlapping
# Execution decline and exit 0. There is no run budget to derive; a Run
# ends when it has caught up with the Changelog.
evtnotify = {
container_config = {
name = "kastoria-eventnotification"
image = "${var.acr_login_server}/kastoria-eventnotification:stable"
cpu = 1
memory = "2Gi"
args = ["publish"]
}
otel_exporter = "http://otel-gateway:4317"
replica_timeout = 3600
trigger_schedule = "*/2 * * * *"
tags = merge(local.rg_tags, {
app = "EVENTNOTIFICATION"
submodule = "compute"
})
}
}
apps_final = {
for k, v in local.apps : k => merge(v,
contains(local.storage_app_names, k) ? { user_managed_storage_app = local.storage_identity } : {}
)
}
jobs_final = {
for k, v in local.jobs : k => merge(v,
contains(local.storage_job_names, k) ? { user_managed_storage_app = local.storage_identity } : {},
contains(local.queue_job_names, k) ? { user_managed_queue_app = local.queue_publisher } : {}
)
}
# The storage tier document the API is configured with. Account names come
# from the objectstore outputs, the table names follow the module's naming
# rule, and managedIdentityClientId is the accessor identity created above.
# Container and table names are author-chosen, so they stay literal.
storage_tiers = jsonencode([{
name = "hot"
blockStore = [{
type = "azure-hybrid"
configuration = {
shards = [
for shard_key in ["sb01", "sb02", "sb03"] : {
accountName = local.object_account_names[shard_key]
containerName = keys(local.object_shards[shard_key])[0]
tableName = local.storage_table_names[shard_key][0]
managedIdentityClientId = local.storage_identity.client_id
credentialType = "managed-identity"
endpoint = "https://${local.object_account_names[shard_key]}.blob.core.windows.net"
prefix = ""
}
]
}
}]
namedRoot = [{
type = "azure-hybrid"
configuration = {
accountName = local.object_account_names["sn01"]
containerName = keys(local.object_shards["sn01"])[0]
tableName = local.storage_table_names["sn01"][0]
managedIdentityClientId = local.storage_identity.client_id
credentialType = "managed-identity"
endpoint = "https://${local.object_account_names["sn01"]}.blob.core.windows.net"
prefix = ""
}
}]
changelog = {
type = "key-value"
}
}])
# Rendered as the queueWriter section of the configuration document. The
# identity is the queue module's publisher, which evtnotify carries through
# user_managed_queue_app; no connection string, because the account has no keys.
queue_writer = jsonencode({
type = "azure-storage-queue"
configuration = {
accountName = module.queue.storage_account_name
managedIdentityClientId = local.queue_publisher.client_id
queueName = module.queue.queue_name
messageEncoding = "text"
}
})
app_config = {
organization = "Example Org"
environment_name = "Sample"
purpose = "sample"
system_storage = jsonencode([])
storage_tiers = local.storage_tiers
queue_writer = local.queue_writer
}
}
module "compute" {
source = "${local.modules_base}/compute?tag=${local.modules_version}"
# Microsoft.App must be registered before the container app environment
# exists. On a fresh subscription, add an
# azurerm_resource_provider_registration resource and declare the depends_on.
base_name = var.project_name
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
tenant_id = data.azurerm_client_config.current.tenant_id
subnet_id = module.networking.created_subnets["compute"].id
gateway_subnet_id = module.networking.created_subnets["gateway"].id
acr_login_server = var.acr_login_server
apps = local.apps_final
jobs = local.jobs_final
# Leave at its default {} because no app or job declares share_mounts — see
# the file store appendix in walkthrough.md before re-enabling mounts.
storage_access_keys = {}
key_vault_uri = module.secrets.vault_uri
key_vault_reader_identity_id = module.secrets.identity_id
app_config = local.app_config
app_secrets = {}
tags = local.rg_tags
}
data "azurerm_client_config" "current" {}
# ---------------------------------------------------------------------------
# Gateway
# ---------------------------------------------------------------------------
module "gateway" {
source = "${local.modules_base}/gateway?tag=${local.modules_version}"
base_name = var.project_name
resource_group_name = azurerm_resource_group.env.name
container_app_environment_id = module.compute.container_app_environment_id
# Required even though every consumer of it is disabled: var.domain feeds the
# upstream address locals, which reach the mTLS proxy's environment. The real
# default domain is only known after the environment exists, so the gateway
# necessarily reads it from compute.
domain = module.compute.container_app_environment_default_domain
# Required input; unused because proxy_enabled = false.
proxy_image = "${var.acr_login_server}/kastoria-proxy:stable"
acr_login_server = var.acr_login_server
registry_identity_id = module.compute.identity_id
# The reverse proxy app is replaced here by the mTLS proxy below, which lives
# in the same module and is gated on mtls_ca independently of proxy_enabled.
proxy_enabled = false
# Produces {NAME}_ADDRESS env vars on the mTLS proxy. With proxy_enabled =
# false nothing in this deployment consumes them; they are kept to show the
# shape, and must be pruned if you re-enable the proxy with a different app
# set.
upstream_services = {
kastoria_health = { address = "kastoria" }
kastoria_smartlaunch = { address = "smartlaunchapi" }
kastoria_consoleapi = { address = "consapi" }
kastoria_consoleui = { address = "consui" }
ohif_viewer = { address = "ohif" }
}
# cloudflare/cloudflared:*
# Required at plan time regardless of cloudflared_enabled: the variable has no
# default and validate fails on "required variable not set" before any count
# is evaluated.
cloudflared_enabled = false
cloudflared_image = "cloudflare/cloudflared:2026.5.2"
# mTLS proxy: publishes an internet-facing ingress that accepts only clients
# presenting a certificate chained to the CA below. The CA's PFX blob and
# password, plus kastoriaMTLSClientRoles, must already exist in this vault.
mtls_ca = {
name = "gateway-mtls-ca"
certificate_name = "callerCACert"
password_secret = "callerCACertPassword"
}
mtls_proxy_image = "${var.acr_login_server}/kastoria-proxy-mtls:stable"
mtls_proxy_external_enabled = true
mtls_proxy_allowed_ip_ranges = ["203.0.113.0/24"]
key_vault_id = module.secrets.vault_id
key_vault_name = module.secrets.vault_name
# The identity the mTLS proxy uses to resolve its Key Vault-backed secret at
# runtime; it must be Secrets User on the vault.
user_assigned_identity_id = module.secrets.identity_id
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Telemetry
# ---------------------------------------------------------------------------
# enable_grafana = false deploys the OTel collector and reads no Grafana
# credentials, so the grafana provider is neither configured nor required.
module "telemetry" {
source = "${local.modules_base}/telemetry?tag=${local.modules_version}"
container_app_environment_id = module.compute.container_app_environment_id
resource_group_name = azurerm_resource_group.env.name
subscription_id = var.subscription_id
acr_login_server = var.acr_login_server
registry_identity_id = module.compute.identity_id
enable_grafana = false
create_subscription_role_assignment = false
}
# ---------------------------------------------------------------------------
# Container registry access
# ---------------------------------------------------------------------------
# The ACR lives in this subscription, so a single provider suffices. When it
# lives elsewhere, add a provider alias and look the registry up with a data
# source:
#
# provider "azurerm" {
# alias = "infra"
# features {}
# subscription_id = "<registry-subscription-id>"
# }
#
# data "azurerm_container_registry" "acr" {
# provider = azurerm.infra
# name = "<registry-name>"
# resource_group_name = "<registry-resource-group>"
# }
#
# then scope the assignment below to data.azurerm_container_registry.acr.id.
resource "azurerm_role_assignment" "acr_pull" {
scope = var.acr_resource_id
role_definition_name = "AcrPull"
principal_id = module.compute.identity_principal_id
}
variables.tf
variable "project_name" {
description = "Name of the project used for resource naming"
type = string
}
variable "location" {
description = "Azure region to deploy into"
type = string
}
variable "subscription_id" {
description = "The environment's subscription id"
type = string
}
variable "acr_login_server" {
description = "The login server URL of the ACR holding our app containers (e.g. myacr.azurecr.io)"
type = string
}
variable "acr_resource_id" {
description = "Resource ID of the ACR that app images are pulled from; granted AcrPull on the container app environment identity"
type = string
}
variable "allowed_ips" {
description = "IP addresses to whitelist on the Key Vault and storage accounts"
type = list(string)
default = []
}
variable "queue_reader_principal_ids" {
description = "Entra principal IDs of the queue readers, granted Storage Queue Data Message Processor"
type = list(string)
default = []
}
variable "queue_reader_ips" {
description = "Egress ranges of queue readers outside the VNet; seeds the queue account's initial firewall rules only"
type = list(string)
default = []
}
variable "rg_tags" {
description = "Tags to apply to the resource group (propagated from there to every module)"
type = map(string)
default = {
owner = "example"
env = "sample"
envtype = "DevTest"
}
}
sample.auto.tfvars
Five values — plus the tags — change per environment; everything structural
lives in main.tf:
project_name = "sample"
location = "eastus2"
subscription_id = "00000000-0000-0000-0000-000000000000"
acr_login_server = "acrexample0000.azurecr.io"
acr_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-infra-rg/providers/Microsoft.ContainerRegistry/registries/acrexample0000"
rg_tags = {
owner = "example"
env = "sample"
envtype = "DevTest"
}
project_name is the naming base for everything: sample-rg, appenv-sample,
app-sample-kastoria, job-sample-stdyproc, job-sample-evtnotify,
kv-sample-abcd, saqsampleabcd, umi-sample-queue-publisher.