Skip to content

Sample Deployment Walkthrough

The sample deployment configuration builds a complete Kastoria stack from the individual components — networking, secrets, objectstore, queue, compute, gateway, telemetry — composed directly, so that every wire is visible. This page walks through what that composition involves: where the module packages come from, the build order, the wiring you own yourself, the prerequisites, and the gotchas.

For the per-edge output→input tables, see Module Dependencies.

Where the modules come from

The sample pulls every component from the Distribution Registry as a signed OCI module package:

locals {
  modules_base    = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure"
  modules_version = "v0.0.1"
}

module "compute" {
  source = "${local.modules_base}/compute?tag=${local.modules_version}"
  # ...
}

Three consequences of this form:

  • The tag is a git tag. All components publish together on one release train, so a single modules_version pins the whole composition. There are no independent per-module versions to reconcile.
  • No // subdirectory suffix. Each published package is one module, re-rooted at its module root. Writing modules/azure/compute//azure finds nothing.
  • The azure/ element is the cloud, not a path. Customer entitlement is granted over modules/azure/* as a namespace.

Two things to know about the address itself, before tofu init ever runs:

Module source interpolation is resolved at init, so it has to be locals or variables. Nothing in a source string may reference state or a provider, but locals and variables do resolve — init evaluates them itself, and prompts interactively (which in CI is a hang or a hard failure) if a variable has no value available. Locals are used in the sample because nothing downstream overrides them; a variable with a default works too (?tag=${var.module_version}) and additionally picks up *.auto.tfvars and -var at init, which is what you want if the pin should be overridable per environment.

The point of interpolating at all is that changing the release for a whole composition stays a one-token edit instead of a search-and-replace across every module block.

The credential is a registry login, not an Azure login. OpenTofu reads ~/.docker/config.json, so docker login acrmerkalisdist0c66.azurecr.io with your scoped token is the prerequisite; see Registry — Authenticate. az login does not help here, and CI needs the same credential written before its tofu init step. Signature verification is not part of init either; that is notation verify acrmerkalisdist0c66.azurecr.io/modules/azure/compute:v0.0.1, run separately.

Tags are mutable, so where the supply chain matters pin ?digest=<sha256:...> instead of ?tag= — one or the other, never both in one address.

Upgrading is a one-line change to modules_version plus tofu init -upgrade, which is what re-downloads the package: a plain init reuses whatever is already in .terraform/modules.

If you keep a local checkout of the module sources, you can point each source at a relative path instead — "<path>/modules/compute/azure" — and every wire described below is unchanged: only the addresses differ.

Published packages are as of their tag, which may predate the interfaces described here and on the module pages. At the time of writing, compute inputs key_vault_uri and key_vault_reader_identity_id, gateway inputs proxy_enabled and the mtls_* group, telemetry's enable_grafana flag, the queue module, and compute's app_config.queue_writer are newer than the v0.0.1 tag. If tofu validate reports unsupported arguments, check which release introduced them before treating the sample's wiring as wrong.

The whole call site

Everything structural lives in main.tf; only these five values change per environment (sample.auto.tfvars):

project_name = "sample"
location     = "eastus2"

subscription_id = "00000000-0000-0000-0000-000000000000"

acr_login_server = "acrexample0000.azurecr.io"
acr_resource_id  = "/subscriptions/.../registries/acrexample0000"

project_name is the naming base for everything: sample-rg, appenv-sample, umi-sample, app-sample-kastoria, job-sample-stdyproc, job-sample-evtnotify, kv-sample-abcd, saqsampleabcd.

The module release pin is not among those five: it is not environment-specific, so it sits in main.tf. Nor does it relate to acr_login_server / acr_resource_id — those point at the ACR holding the app containers, which the environment pulls at runtime with AcrPull; the module registry is build-time only, and authenticates with a scoped token rather than with Azure.

Build order

Terraform/OpenTofu derives order from references, so you do not declare it. Spelled out, the dependency levels are:

  1. azurerm_resource_group.env + azurerm_user_assigned_identity.app_identity
  2. module.networking — needs the resource group
  3. module.secrets, module.object_store, module.queue — need subnet IDs from networking
  4. module.compute — needs subnets, the vault URI and reader identity, the accessor identity, the storage and queue account names, and the queue publisher identity
  5. module.gateway, module.telemetry — need compute's container app environment ID

Two edges are easy to miss because they are the only reason a module exists in the graph:

  • gateway.domain reads compute.container_app_environment_default_domain. The real domain is only known after the environment is created, so the gateway cannot be ordered before compute even when you would prefer to hardcode a domain and deploy them in parallel.
  • app_config.storage_tiers, which is compute input, reads object_store.storage_accounts and the accessor identity's client_id. So storage accounts are an input to compute, not merely a thing apps reach at runtime.
  • app_config.queue_writer reads queue.storage_account_name, queue.queue_name and queue.publisher_client_id the same way, and the evtnotify job reads queue.publisher_id, so the queue module must be applied before compute.

The wiring you perform

Four small transformations must happen between your environment values and the module inputs. Composed directly, they are yours to write — all four appear in the sample configuration.

1. The naming suffix

secrets, filestore and objectstore all take a required suffix. The suffix can be generated with a random_id:

resource "random_id" "project_prefix" {
  keepers = { project_name = var.project_name }
  byte_length = 2
}

locals {
  project_prefix_hex = var.project_prefix != null ? var.project_prefix : random_id.project_prefix.hex
}

The sample pins it instead:

locals {
  project_prefix = "abcd"
}

Storage account names are then substr("${replace(lower("sa${base_name}${suffix}"), "/[^a-z0-9]/", "")}${shard_key}", 0, 24) — so sasample + abcd + sb01 gives sasampleabcdsb01.

Pinning matters more than it looks: because the storage tier config below names those accounts, a random suffix that changes forces a matching change in app_config. Hardcoding account names as literals alongside a generated suffix is how drift starts.

2. Storage identity injection

Every managedIdentityClientId in the storage tier document must be the client ID of the identity that objectstore was given permission for. Interpolate the value at the point of authoring:

managedIdentityClientId = azurerm_user_assigned_identity.app_identity.client_id

Account names come from the module rather than from prose. storage_accounts is sensitive because it carries access keys, so unwrap it before using it in a non-sensitive expression:

object_account_names = {
  for shard_key, sa in nonsensitive(module.object_store.storage_accounts) :
  shard_key => sa.name
}

Table names are not exported in a directly usable shape, but the rule is fixed: the table mirrors the container with - stripped.

storage_table_names = {
  for shard_key, containers in local.object_shards :
  shard_key => [for container in keys(containers) : replace(container, "-", "")]
}

module.object_store.tables also exposes the created table names if you would rather read them back than recompute the rule.

The queue writer follows the same rule with a different identity. The queue module creates its own publisher identity and grants only that one send access, so the writer's managedIdentityClientId is the publisher's client ID — not the storage accessor's — and the evtnotify job must carry the publisher, which is why evtnotify is in queue_job_names:

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"
  }
})

There is no connection string: the queue account is created with shared_access_key_enabled = false, so it has no keys to build one from. See Wiring the Queue Writer.

3. Per-app tag merge

compute demands a tags map on every entry of apps and jobs. Prepend the resource group tags, repeating the merge per entry since apps and jobs are indexed by different keys:

apps = {
  kastoria = {
    # ...
    tags = merge(local.rg_tags, { app = "KASTORIA-HEALTH", submodule = "compute" })
  }
}

4. Mount identity derivation

If you attach file store shares, the mount wiring is a fifth transformation — see the appendix. The sample has no file store, so it is the one step the configuration does not demonstrate.

The identity is yours to create

The storage-accessor user-assigned identity (id-app-storage-accessor) is not owned by any component module — the environment creates it and hands the principal ID to the storage modules and the client ID to the apps. Composing directly, you must remember it exists and that it feeds four places:

Consumer Input
objectstore user_managed_identity_principal_id → Blob + Table data RBAC
filestore user_managed_identity_principal_id → file share RBAC
compute per-app user_managed_storage_app = { id, client_id }
app_config.storage_tiers managedIdentityClientId per shard

Which apps and jobs get the identity is a contains() over two name lists:

apps_final = {
  for k, v in local.apps : k => merge(v,
    contains(local.storage_app_names, k) ? { user_managed_storage_app = local.storage_identity } : {}
  )
}

The queue publisher identity (umi-sample-queue-publisher) is the opposite case: the queue module creates it, so you do not, but you still decide which workloads carry it. It feeds two places:

Consumer Input
compute per-job user_managed_queue_app = { id, client_id } from publisher_id / publisher_client_id
app_config.queue_writer managedIdentityClientId from publisher_client_id

queue_job_names selects the jobs independently of storage_job_names. evtnotify is in both lists and carries both identities: AZURE_CLIENT_ID (set by user_managed_storage_app) selects the storage identity for the storage tiers, and the explicit managedIdentityClientId selects the publisher for the queue. Because the storage accessor has no queue role, a workload in storage_*_names alone cannot publish.

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 } : {}
  )
}

Prerequisites: Key Vault secrets for the mTLS proxy

The mTLS proxy reads its trust material out of the environment's Key Vault, so these secrets must exist before you plan/apply the gateway module. Create them in the following way:

  1. Create the Azure Key Vault (apply secrets module) including your IP Address in allowed_ips
  2. Give yourself the Key Vault Secrets Officer role
  3. Add the following secrets to the Key Vault
  4. Apply the gateway module
Secret name Read by Renamable?
callerCACert data.azurerm_key_vault_secret.mtls_ca_certificate yes — via mtls_ca.certificate_name
callerCACertPassword data.azurerm_key_vault_secret.mtls_ca_password yes — via mtls_ca.password_secret
kastoriaMTLSClientRoles ACA secret injection into the proxy app no — hardcoded literal

Do not model these as azurerm_key_vault_secret resources in the same configuration: the gateway module reads them with data. blocks at plan time while resources create at apply time, so a self-bootstrapping configuration cannot plan on its first run.

Three gateway inputs also become effectively required once mtls_ca is set: key_vault_id (enforced by a precondition), key_vault_name (not enforced — without it the secret URI fails with "Invalid template interpolation value"), and user_assigned_identity_id (not enforced, needed for runtime resolution).

Gotchas that cost you a debugging session

cloudflared_image is required even with the tunnel off. It has no default and tofu validate fails with "required variable not set" before any count is evaluated — the value is never used when cloudflared_enabled = false, but it must be supplied.

proxy_enabled gates only the reverse proxy. mtls_ca != null && mtls_proxy_image != null gates the mTLS proxy independently, so an mTLS-only gateway is a legal configuration — which is what the sample deploys. var.domain is still required: it flows through the upstream address locals into the mTLS proxy's environment even though the addressed apps' internal FQDNs are only consumed by the proxy that is not being created.

enable_grafana defaults to true, which makes seven optional inputs mandatory. The telemetry module validates this with a readable error, and it also selects a different collector config, so it is not merely an additive flag. With the flag off you configure no grafana provider block at all — although the provider is still pulled into tofu init by the module's required_providers, so the plugin must be downloadable.

webapi_app_name is a verbatim string, not a lookup. compute interpolates it straight into API_BACKEND_URL = "http://<value>" and API_HOST_NAME = "<value>". It does not index var.apps, so a typo or a missing app produces a silently wrong environment variable rather than an error. Contrast ingress_config.cors_app_name, which does index a computed map and hard-fails at plan time if the key is absent. The sample omits webapi_app_name entirely.

dicomweb_app_name is accepted and ignored. It is declared in the compute module's variables and referenced nowhere in the module.

storage_access_keys is an all-or-nothing override, not a merge. The compute module uses the map exclusively when it has any entry, and then indexes it directly with each derived mount ID. A partial map fails the plan with Invalid index. When nothing declares share_mounts, the empty default is safe.

gateway_subnet_id is required and unread. compute declares it with no default but never references it. You must still create a gateway subnet and pass its ID.

nonsensitive() is required to reuse storage outputs. Both objectstore.storage_accounts and filestore.file_shares are marked sensitive, so using an account name in a jsonencoded config needs an explicit unwrap.

A pinned project_prefix does not decouple you from object_store. It fixes the name formula, but the tier config still reads storage_accounts for the rendered names. Hardcoding account names as literals alongside a pinned prefix would work, but is drift-prone.

The deploying principal needs permission to create queues. Creating a queue is a management action (Microsoft.Storage/storageAccounts/queueServices/queues/write), not a data action. Subscription Owner covers it; a deployer that is not an Owner needs at least Storage Queue Data Contributor. Grant the role alongside your other deployer roles, not in the sample.

The first apply that creates the queue account can fail. The account is created with its firewall set to deny, and the queue is created through the account's own endpoint. If the IP running OpenTofu is not in allowed_ips — typically a CI runner whitelisted per account — azurerm_storage_queue.events fails with a 403 after the account is created. Admit the runner IP on the new account and apply again; no rollback is needed. See CI Firewall Registration.

queue_reader_ips seeds the queue firewall once. The queue module ignores later ip_rules changes, so a reader range added to the variable after the first apply has no effect and no plan diff. Add it with az storage account network-rule add instead; see Onboarding a Reader.

Running the sample

With a checkout of the sample configuration:

docker login acrmerkalisdist0c66.azurecr.io   # scoped token; OpenTofu reads this file
tofu init -backend=false    # backend block is commented out on purpose
tofu fmt -check
tofu validate

tofu plan needs a real subscription, an ACR containing kastoria-ohif, kastoria-health, kastoria-smart-launch-api, kastoria-consoleapi, kastoria-consoleui, kastoria-studyprocessor, kastoria-eventnotification, kastoria-proxy and kastoria-proxy-mtls, and the Key Vault secrets listed above.

Appendix: file store mounts

The sample has no module.file_store. This is the code that adds one; it cannot be pasted in alone — share_mounts and storage_access_keys must arrive together or the plan fails.

module "file_store" {
  source = "${local.modules_base}/filestore?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 = { "s00" = { quota_gb = 100 } }

  user_managed_identity_principal_id = azurerm_user_assigned_identity.app_identity.principal_id
  allowed_ips = var.allowed_ips
  tags        = local.rg_tags
}

locals {
  # Which apps consume the shares created by `module.file_store`.
  mount_app_names = ["kastoria", "smartlaunchapi"]

  # Non-sensitive metadata drives for_each; the sensitive access keys travel in
  # a separate map so they never taint a for_each key.
  file_mounts = {
    for k, v in module.file_store.file_shares_config : k => merge(v, { mount = ".storage" })
  }

  # Must match the compute module's mount-ID derivation exactly, or the mount
  # IDs will not line up with the keys compute looks up.
  file_mount_ids = {
    for k, v in local.file_mounts : k =>
    substr("st-${substr(md5("${v.storage_account}-${v.name}"), 0, 6)}-${v.storage_account}", 0, 32)
  }

  file_access_keys = {
    for k, v in module.file_store.file_shares : local.file_mount_ids[k] => v.access_key
  }

  apps_with_mounts = {
    for k, v in local.apps_final : k => merge(v,
      contains(local.mount_app_names, k) ? { share_mounts = local.file_mounts } : {}
    )
  }
}

Then pass storage_access_keys = local.file_access_keys to module.compute instead of {}, and make apps = local.apps_with_mounts. Add depends_on = [module.file_store] to module.compute if you hit a race on the environment storage resource — the mount metadata reference usually covers it.