Skip to content

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.