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_versionpins 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. Writingmodules/azure/compute//azurefinds nothing. - The
azure/element is the cloud, not a path. Customer entitlement is granted overmodules/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:
azurerm_resource_group.env+azurerm_user_assigned_identity.app_identitymodule.networking— needs the resource groupmodule.secrets,module.object_store,module.queue— need subnet IDs from networkingmodule.compute— needs subnets, the vault URI and reader identity, the accessor identity, the storage and queue account names, and the queue publisher identitymodule.gateway,module.telemetry— needcompute's container app environment ID
Two edges are easy to miss because they are the only reason a module exists in the graph:
gateway.domainreadscompute.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 iscomputeinput, readsobject_store.storage_accountsand the accessor identity'sclient_id. So storage accounts are an input to compute, not merely a thing apps reach at runtime.app_config.queue_writerreadsqueue.storage_account_name,queue.queue_nameandqueue.publisher_client_idthe same way, and theevtnotifyjob readsqueue.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:
- Create the Azure Key Vault (apply
secretsmodule) including your IP Address inallowed_ips - Give yourself the
Key Vault Secrets Officerrole - Add the following secrets to the Key Vault
- Apply the
gatewaymodule
| 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.