DEV Community

Cover image for Infrastructure as Code: Terraform for Your New Stack
Martin Oehlert
Martin Oehlert

Posted on AI-assisted

Infrastructure as Code: Terraform for Your New Stack

The first terraform apply against a fresh subscription created the Container Apps environment, created the app, and then the revision failed to pull its image. The second apply, with nothing changed, succeeded. The AcrPull role assignment existed both times; what was missing on the first run was an edge in Terraform's dependency graph, because the container app references the identity and the registry login server and has no reason to reference the grant. Terraform orders what you reference, Azure requires things you would never reference, and that gap is where most of the production failures in a Container Apps stack live.

Part 6 described that architecture as a chain of hops and asked what each one trusts. Written down as Terraform it is fifteen resources in the root module before a single module block runs, and four modules under it holding the VNet, the private endpoints, the environment, the apps, the Dapr components, the identities and their grants.

Every HCL block below is lifted out of a tree that terraform validates on Terraform 1.16.3 and hashicorp/azurerm 5.6.0, published 2026-09-17, and the whole tree is in the companion repo. The pair is pinned in one place:

terraform {
  required_version = ">= 1.16.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "5.6.0"
    }
  }

  # Partial configuration. The rest comes from envs/<env>.backend.hcl at init time:
  #   terraform init -reconfigure -backend-config=envs/prod.backend.hcl
  backend "azurerm" {}
}

provider "azurerm" {
  features {}

  subscription_id = var.subscription_id

  # v5 defaults this to "none". On a fresh subscription the first apply then fails on an
  # unregistered Microsoft.App, and the error reads like a permissions problem.
  resource_provider_registrations = "legacy"
}
Enter fullscreen mode Exit fullscreen mode

One thing about that validation matters before you trust your own: terraform init without -upgrade happily resolved an azurerm 4.x build out of the local plugin cache with a 5.x constraint sitting in required_providers, and the three v4-shaped errors that followed read as though the configuration was wrong. terraform version -json prints provider_selections; check it before you start editing code that was fine.

Four modules, drawn where the lifetime changes

A module boundary is a lifetime boundary, not a service boundary. Here is the root module wiring the four of them together, with the arguments that only carry data trimmed out:

module "networking" {
  source = "./modules/networking"

  address_space = var.address_space

  # 7 newbits on a /16 gives a /23. /27 is the legal minimum for workload profiles, but the
  # subnet cannot be resized afterwards and a /27 caps at 9 Dedicated nodes.
  aca_subnet_newbits = 7

  tags = local.required_tags
}

module "container_apps" {
  source = "./modules/container-apps"

  log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id
  infrastructure_subnet_id   = module.networking.container_apps_subnet_id
  internal_only              = true

  container_registry_login_server = azurerm_container_registry.this.login_server

  app_identities = {
    (local.orders_app_id) = {
      id        = module.identity.identity_ids[local.orders_app_id]
      client_id = module.identity.client_ids[local.orders_app_id]
    }
  }

  tags = local.required_tags

  depends_on = [module.identity, module.networking]
}

module "dapr_components" {
  source = "./modules/dapr-components"

  container_app_environment_id = module.container_apps.environment_id
  dapr_identity_client_id      = module.identity.client_ids[local.orders_app_id]

  state_store_scopes = [module.container_apps.dapr_app_ids[local.orders_app_id]]
  pubsub_scopes      = [module.container_apps.dapr_app_ids[local.orders_app_id]]
}
Enter fullscreen mode Exit fullscreen mode

The VNet outlives the architecture: you will still be running that address space after the apps inside it have been rewritten twice. The environment is near-permanent for reasons the next section is entirely about. The apps change every time somebody merges. Cut along those rates and each module has one reason to be applied. Cut by Azure service instead and you get a cosmos module that has to be planned every time an image tag moves.

What crosses each boundary is an output feeding a var, and that reference is the only thing Terraform needs to order the two. module.networking exports the delegated subnet ID; module.container_apps exports the environment ID, its default domain, its static IP, the app FQDNs and the Dapr app ids; module.identity exports identity IDs, client IDs and principal IDs as three separate maps, because they are not interchangeable and a swap fails at runtime with nothing visible at plan time. module.dapr_components exports nothing anyone consumes, which is a fine thing for a leaf module to do.

Module dependency graph: solid edges are outputs wired into the next module's variables, dashed edges are explicit depends_on for the AcrPull grant and the NSG association

The two dashed edges are the ones Terraform cannot draw, and the depends_on on module.container_apps is where you write them down by hand. module.identity is there for the failed image pull in the first paragraph of this article. module.networking is there because the environment references the subnet and never the NSG association, so without the edge the environment can come up against an unprotected subnet. The sample puts both on the module call rather than on the resource, since the identities and the apps live in different modules; inside a single module the same fix is depends_on = [azurerm_role_assignment.acr_pull] on the azurerm_container_app resource itself. The identity chain gets taken apart properly further down.

Three patterns would fail a module review at a stricter shop, and they are worth naming because most Terraform repos contain all three. A module wrapping a single resource with a rename is a rename. A resource you configure once and never vary does not need a variable, let alone a module. And a passthrough module, where every variable maps one-to-one onto a provider argument, has added a file and taken away the provider documentation. dapr-components survives that test by a narrow margin: it hard-codes the component types and versions, it refuses an empty scopes list, and it hides the azureClientId plumbing. A generic "any Dapr component" module would do none of those things and would be worse than writing the resource directly.

The environment: every interesting argument is a one-way door

The resource that decides the entire network posture of this stack has no required network argument at all. Omit infrastructure_subnet_id and you get a public environment outside your VNet, and it plans clean:

locals {
  missing_identities = setsubtract(keys(var.apps), keys(var.app_identities))
}

resource "azurerm_container_app_environment" "this" {
  name                = "cae-${var.workload}-${var.environment}"
  location            = var.location
  resource_group_name = var.resource_group_name

  logs_destination           = "log-analytics"
  log_analytics_workspace_id = var.log_analytics_workspace_id

  infrastructure_subnet_id           = var.infrastructure_subnet_id
  infrastructure_resource_group_name = "rg-${var.workload}-${var.environment}-aca-infra"
  internal_load_balancer_enabled     = var.internal_only
  zone_redundancy_enabled            = var.environment == "prod"

  dapr_application_insights_connection_string = var.dapr_application_insights_connection_string

  workload_profile {
    name                  = "Consumption"
    workload_profile_type = "Consumption"
  }

  dynamic "workload_profile" {
    for_each = var.dedicated_workload_profiles

    content {
      name                  = workload_profile.key
      workload_profile_type = workload_profile.value.profile_type
      minimum_count         = workload_profile.value.minimum_count
      maximum_count         = workload_profile.value.maximum_count
    }
  }

  tags = var.tags

  lifecycle {
    precondition {
      condition     = length(local.missing_identities) == 0
      error_message = "Every app needs an identity: ${join(", ", local.missing_identities)} are in apps but not in app_identities."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Nearly every argument above forces replacement when it changes: infrastructure_subnet_id, infrastructure_resource_group_name, internal_load_balancer_enabled, zone_redundancy_enabled, dapr_application_insights_connection_string, the workload_profile blocks, and the size of the subnet the first one points at. Replacing a Container Apps environment takes every container app and every Dapr component inside it along, so those are decisions you make once, on day one, with the same weight you would give a database engine choice.

workload_profile is the trap, because nothing about it looks final. It is an optional repeatable block with optional arguments, the shape Terraform uses everywhere for things you tune later. The provider note is explicit: "Environments created without an initial Workload Profile cannot have them added at a later time and must be recreated. Similarly, an environment created with Profiles must always have at least one defined Profile, removing all profiles will force a recreation of the resource." That is why the static Consumption block sits above the dynamic one rather than inside it. A Consumption profile must be named Consumption, there can only be one, and keeping it out of the map means an operator who empties dedicated_workload_profiles removes a dedicated profile instead of destroying the environment.

logs_destination = "log-analytics" is there because v5 stopped computing it. A v4 configuration that set only log_analytics_workspace_id and got log analytics by default now drops to Streaming Only on upgrade, with no error, and you find out when you go looking for a query result that is not there.

The dedicated_workload_profiles variable is where the environment's rules get written down, and the third validation on it is a different kind of rule from the first two:

variable "dedicated_workload_profiles" {
  type = map(object({
    profile_type  = string
    minimum_count = optional(number, 0)
    maximum_count = optional(number, 3)
  }))
  default     = {}
  nullable    = false
  description = "Dedicated profiles keyed by profile name. A Consumption profile is always added, because removing the last profile recreates the environment."

  validation {
    condition = alltrue([
      for name, profile in var.dedicated_workload_profiles :
      can(regex("^(D4|D8|D16|D32|E4|E8|E16|E32)$", profile.profile_type))
    ])
    error_message = "profile_type must be one of D4, D8, D16, D32, E4, E8, E16, E32."
  }

  validation {
    condition = alltrue([
      for name, profile in var.dedicated_workload_profiles :
      profile.maximum_count >= profile.minimum_count
    ])
    error_message = "maximum_count must be greater than or equal to minimum_count."
  }

  validation {
    condition     = !contains(keys(var.dedicated_workload_profiles), "Consumption")
    error_message = "The Consumption profile is added by the module; do not declare it here."
  }
}
Enter fullscreen mode Exit fullscreen mode

The first two encode Azure rules, and Azure would have rejected the apply anyway; all the validation buys you is a readable error twenty minutes earlier. The third encodes a rule Azure has never heard of. It exists because this module chose to add the Consumption profile itself, and a caller who declares one would get a duplicate name from a design decision they had no way to see. Validation blocks are most useful for exactly that: the constraints your module invented, which no provider schema and no API will ever check for you.

The app: one variable is the whole interface

Callers of the container-apps module never touch a resource. They fill in one map, and the key of that map is the Dapr app id:

variable "apps" {
  type = map(object({
    image                 = string
    target_port           = number
    cpu                   = optional(number, 0.5)
    memory                = optional(string, "1Gi")
    min_replicas          = optional(number, 1)
    max_replicas          = optional(number, 10)
    workload_profile_name = optional(string, "Consumption")
    key_vault_secrets     = optional(map(string), {})

    ingress = optional(object({
      external_enabled  = optional(bool, false)
      allowed_ip_ranges = optional(map(string), {})
    }))

    service_bus_scale_rule = optional(object({
      queue_name    = string
      namespace     = string
      message_count = optional(number, 5)
    }))
  }))
  nullable    = false
  description = "Apps keyed by Dapr app id. The map key becomes the dapr app_id and the container app name suffix, so a Dapr component can never be scoped to a string that does not exist."

  validation {
    condition     = alltrue([for id, app in var.apps : can(regex("^[a-z][a-z0-9-]{1,30}[a-z0-9]$", id))])
    error_message = "App keys become Dapr app ids and container app names; use lower-case alphanumeric and hyphens, 3-32 chars."
  }

  validation {
    condition     = alltrue([for id, app in var.apps : app.min_replicas <= app.max_replicas])
    error_message = "min_replicas must be less than or equal to max_replicas."
  }

  validation {
    condition     = alltrue([for id, app in var.apps : contains([0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], app.cpu)])
    error_message = "cpu must be one of the allocations the Consumption profile accepts: 0.25 through 2.0 in 0.25 steps."
  }

  # KEDA's namespace metadata is the bare name. Dapr's namespaceName, two modules over, is the
  # FQDN. Same concept, two formats, and neither one fails at plan time.
  validation {
    condition = alltrue([
      for id, app in var.apps :
      app.service_bus_scale_rule == null || !strcontains(try(app.service_bus_scale_rule.namespace, ""), ".")
    ])
    error_message = "The Service Bus scale rule namespace is the bare namespace name, not the FQDN. Drop the .servicebus.windows.net suffix."
  }
}
Enter fullscreen mode Exit fullscreen mode

Keying on the Dapr app id rather than on the container app name is the one design decision in this module that changes what can go wrong later. A Dapr component's scopes list matches on app ids, the app id is otherwise just a string somebody typed twice, and the next section is about what happens when the two copies drift. Making it the map key means it is typed once, in tfvars, and everything else is derived.

Of the four validations, the character-set rule is the one that pays for itself. Azure enforces a name format on the container app and Dapr enforces its own on the app id, and the map key has to satisfy both, so checking it at the interface beats reading two different rejections from two different services. The CPU one covers an allocation set the provider does not check at all. The last one is the namespaceName versus namespace split: Dapr's Service Bus component wants the fully qualified namespace and KEDA's scaler wants the bare name, the two live two modules apart in the same stack, and neither one fails at plan time if you supply the other format.

Cross-variable validation is why the module needs Terraform 1.9 as a floor. The networking module's subnet check reads tonumber(split("/", var.address_space)[1]) + var.aca_subnet_newbits <= 27, and a validation block that references a second variable was an error before 1.9.

Then the resource, which is mostly each.value plumbing plus four things worth stopping on:

resource "azurerm_container_app" "this" {
  for_each = var.apps

  name                         = "ca-${each.key}-${var.environment}"
  container_app_environment_id = azurerm_container_app_environment.this.id
  resource_group_name          = var.resource_group_name
  revision_mode                = "Single"
  workload_profile_name        = each.value.workload_profile_name
  max_inactive_revisions       = 3
  tags                         = var.tags

  identity {
    type         = "UserAssigned"
    identity_ids = [var.app_identities[each.key].id]
  }

  registry {
    server   = var.container_registry_login_server
    identity = var.app_identities[each.key].id
  }

  # A Key Vault reference, not a copy of the value into state. The identity here has to be the
  # same one the AcrPull grant went to, or the revision fails to start on secret resolution.
  dynamic "secret" {
    for_each = each.value.key_vault_secrets

    content {
      name                = secret.key
      identity            = var.app_identities[each.key].id
      key_vault_secret_id = secret.value
    }
  }

  # app_id is the string Dapr component scopes match on. It is deliberately the map key, so the
  # dapr-components module can read it back off this resource instead of guessing a convention.
  dapr {
    app_id       = each.key
    app_port     = each.value.target_port
    app_protocol = "http"
  }

  dynamic "ingress" {
    for_each = each.value.ingress == null ? [] : [each.value.ingress]

    content {
      external_enabled = ingress.value.external_enabled
      target_port      = each.value.target_port
      transport        = "auto"

      traffic_weight {
        latest_revision = true
        percentage      = 100
      }
    }
  }

  template {
    min_replicas                     = each.value.min_replicas
    max_replicas                     = each.value.max_replicas
    polling_interval_in_seconds      = 30
    cooldown_period_in_seconds       = 300
    termination_grace_period_seconds = 30

    container {
      name   = each.key
      image  = each.value.image
      cpu    = each.value.cpu
      memory = each.value.memory

      # The client ID, not the principal ID. Both are GUIDs on the same identity and swapping
      # them fails at runtime with nothing to see at plan time.
      env {
        name  = "AZURE_CLIENT_ID"
        value = var.app_identities[each.key].client_id
      }

      readiness_probe {
        transport = "HTTP"
        port      = each.value.target_port
        path      = "/healthz/ready"
      }
    }

    dynamic "http_scale_rule" {
      for_each = each.value.ingress == null ? [] : [1]

      content {
        name                = "http-rps"
        concurrent_requests = "50"
      }
    }

    # identity_id on custom_scale_rule landed in azurerm 4.69.0. Below that floor a Service Bus
    # scale rule needs a connection string secret instead of the workload identity.
    dynamic "custom_scale_rule" {
      for_each = each.value.service_bus_scale_rule == null ? [] : [each.value.service_bus_scale_rule]

      content {
        name             = "sb-queue-depth"
        custom_rule_type = "azure-servicebus"
        identity_id      = var.app_identities[each.key].id

        metadata = {
          queueName    = custom_scale_rule.value.queue_name
          namespace    = custom_scale_rule.value.namespace
          messageCount = tostring(custom_scale_rule.value.message_count)
        }
      }
    }
  }

  lifecycle {
    precondition {
      condition     = each.value.ingress != null || each.value.service_bus_scale_rule != null
      error_message = "App ${each.key} has neither ingress nor a queue scale rule, so nothing can ever wake it."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The precondition catches what no validation block can reach, because it depends on two optional attributes of the same object rather than on one variable. An app with no ingress and no scale rule is legal HCL, legal Azure, and applies green. It then sits at zero replicas forever, because nothing can wake it: no ingress means no HTTP traffic, and no queue rule means no backlog signal. The failure is silence, which is the worst shape a failure can take in infrastructure code.

AZURE_CLIENT_ID carries the client ID. The role assignments that give this identity its Cosmos, Service Bus and Key Vault access take the principal ID. Both are GUIDs hanging off the same azurerm_user_assigned_identity, they look identical in a plan diff, and swapping them produces a clean apply and a 401 at runtime. That is the reason the identity module exports client_ids and principal_ids as two separately named maps instead of one convenient map of identity objects.

Three more things in that resource only start mattering after the first apply.

Revisions. revision_mode = "Single" here pairs with a traffic_weight block that just says latest_revision = true at 100 percent. Switch to Multiple and that block becomes a list you own: percentages have to sum to exactly 100, nothing is defaulted for you, and traffic_weight has no meaning at all in Single mode. max_inactive_revisions = 3 is the knob almost nobody sets. Container Apps keeps inactive revisions around, they show up in every listing and every diff, and three is enough history to roll back to while staying readable.

Scaling. What concurrent_requests = "50" actually counts is the thing Part 6 settled with arithmetic, against a Microsoft page that contradicts itself two paragraphs apart; this article is about the packaging, so take the number from there.

The packaging detail is custom_scale_rule.identity_id, which landed in azurerm 4.69.0. Below that floor a Service Bus scale rule has to authenticate with a connection string, which means a shared access key in a secret, which means the one credential the rest of this architecture went out of its way not to have. Copy that argument name out of the registry page and you will also get Error: Unsupported argument: the rendered docs spell it ìdentity_id, with a Latin i-grave, and in most editors it looks exactly like identity_id.

polling_interval_in_seconds and cooldown_period_in_seconds are template arguments rather than scale-rule ones, and 4.51.0 itself had neither; ~> 4.51 floats up to 4.81.0, which has both. They decide how quickly a scaled-to-zero worker notices a message and how long it lingers afterwards, so set them rather than inherit 30 and 300 by accident.

Secrets. The secret block takes either a literal value, which puts the secret in state in cleartext, or a key_vault_secret_id, which does not. The reference form needs an identity alongside it, and it has to be an identity that actually holds a secrets-read grant on the vault, or the revision fails to start on secret resolution rather than on the image pull. The sample passes versionless_id so that rotating the secret in Key Vault does not require a Terraform run.

Dapr components: scopes is the security boundary, and both ways of getting it wrong are silent

Get scopes wrong in either direction and terraform apply is green. Microsoft states the rule plainly: component scopes "correspond to the Dapr application ID of a container app, not the container app name", and "by default, all Dapr-enabled container apps in the same environment load the full set of deployed components" (dapr-components). Those are the two failure shapes.

Put the container app name in the list, or the Terraform resource name, or the app id with the environment suffix somebody added to the naming convention last quarter, and the component matches nothing. It is deployed, it is visible in the portal, it loads into no sidecar, and the first POST /v1.0/state/statestore comes back 500 at runtime. Leave scopes out entirely and the component loads into every Dapr-enabled app in the environment, including the ones with no business holding a connection to the orders database. The security argument for the second case is obvious; the cost argument is not. Dapr's Cosmos reference warns that Cosmos enforces a metadata request rate limit shared across the whole account and that new connections eat a large slice of it, then recommends scoping components to specific applications. Every sidecar in the environment opens its connection at startup whether it will ever read state or not.

The fix inside one module is to make the scope an attribute reference, scopes = [azurerm_container_app.orders.dapr[0].app_id], so the two strings cannot drift. Across module boundaries the reference has to survive the trip, which is why the container-apps module reads its own resource back rather than reusing the input map:

output "dapr_app_ids" {
  value       = { for id, app in azurerm_container_app.this : id => app.dapr[0].app_id }
  description = "Dapr app ids read back off the container app resource. This is what the scopes list on a Dapr component expects; the container app name is not."
}
Enter fullscreen mode Exit fullscreen mode

keys(var.apps) would have produced the same strings today and would have gone on producing them after someone added a prefix inside the module. Reading the attribute makes it an edge in the graph as well as a value.

On the receiving side the components module refuses to accept the omission at all:

variable "state_store_scopes" {
  type        = list(string)
  description = "Dapr app ids allowed to load the state store. Not container app names, and not Terraform resource names."

  validation {
    condition     = length(var.state_store_scopes) > 0
    error_message = "Set scopes explicitly; an unscoped component is loaded by every Dapr-enabled app in the environment."
  }
}
Enter fullscreen mode Exit fullscreen mode

There is no default. An unscoped component is a decision, and a module that lets you make it by forgetting to type something is a module that will make it for you.

The three components themselves carry no credentials:

# init_timeout is documented as an ISO 8601 string with the example "5s". Real ISO 8601 durations
# look like PT30S, and PT30S is not what the provider accepts. The Go-style form is correct.
resource "azurerm_container_app_environment_dapr_component" "state_cosmos" {
  name                         = "statestore"
  container_app_environment_id = var.container_app_environment_id
  component_type               = "state.azure.cosmosdb"
  version                      = "v1"
  init_timeout                 = "30s"
  ignore_errors                = false
  scopes                       = var.state_store_scopes

  metadata {
    name  = "url"
    value = var.cosmos_endpoint
  }

  metadata {
    name  = "database"
    value = var.cosmos_database
  }

  metadata {
    name  = "collection"
    value = var.cosmos_container
  }

  metadata {
    name  = "azureClientId"
    value = var.dapr_identity_client_id
  }

  metadata {
    name  = "actorStateStore"
    value = "true"
  }
}

# v2, not the v1 in the provider's own registry example. v1 strips the state key prefix as though
# keyPrefix were always none, and there is no migration path from v1 to v2.
resource "azurerm_container_app_environment_dapr_component" "state_blob" {
  count = var.blob_state_store == null ? 0 : 1

  name                         = "checkpoints"
  container_app_environment_id = var.container_app_environment_id
  component_type               = "state.azure.blobstorage"
  version                      = "v2"
  scopes                       = var.state_store_scopes

  metadata {
    name  = "azureClientId"
    value = var.dapr_identity_client_id
  }
}

resource "azurerm_container_app_environment_dapr_component" "pubsub_servicebus" {
  name                         = "orders-pubsub"
  container_app_environment_id = var.container_app_environment_id
  component_type               = "pubsub.azure.servicebus.topics"
  version                      = "v1"
  scopes                       = var.pubsub_scopes

  metadata {
    name  = "namespaceName"
    value = var.service_bus_namespace_fqdn
  }

  metadata {
    name  = "azureClientId"
    value = var.dapr_identity_client_id
  }

  metadata {
    name  = "consumerID"
    value = "{appID}"
  }
}
Enter fullscreen mode Exit fullscreen mode

No masterKey, no connectionString, no accountKey, and no secret block backing any of them. The whole authentication story is azureClientId plus the client ID of a user-assigned identity that holds data-plane role assignments, which is the point at which this stops being a Terraform question and becomes the role assignment question further down the page.

Two lines in that sample disagree with the documentation on purpose. The blob component is version = "v2" where the provider's own registry example writes v1; Dapr's reference says users "should always use v2 by default" and that there is no migration path from v1 to v2, because v1 strips the state key prefix as though keyPrefix were always none. Copy the registry example into a new module and you have inherited a legacy component you cannot upgrade in place. And init_timeout is documented as "an ISO8601 formatted string. e.g. 5s, 2h, 1m", which is not what ISO 8601 durations look like. Write PT30S and it fails. 30s is correct and the doc text is wrong about the name of the format it is describing.

The subnet you cannot resize

infrastructure_subnet_id has been an input everywhere above. The networking module is where it gets made, carved out of one address space alongside two other prefixes, each with a different job:

locals {
  aca_prefix  = cidrsubnet(var.address_space, var.aca_subnet_newbits, 0)
  pe_prefix   = cidrsubnet(var.address_space, 8, 8)
  apim_prefix = cidrsubnet(var.address_space, 8, 9)
}

resource "azurerm_virtual_network" "this" {
  name                = "vnet-${var.workload}-${var.environment}"
  location            = var.location
  resource_group_name = var.resource_group_name
  address_space       = [var.address_space]
  tags                = var.tags
}

# Size is immutable once the environment exists. Changing this prefix is a rebuild of the
# environment and of every app and Dapr component in it.
resource "azurerm_subnet" "container_apps" {
  name                 = "snet-aca-infra"
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [local.aca_prefix]

  delegation {
    name = "aca-environment"

    service_delegation {
      name    = "Microsoft.App/environments"
      actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
    }
  }
}

resource "azurerm_subnet" "private_endpoints" {
  name                              = "snet-pe"
  resource_group_name               = var.resource_group_name
  virtual_network_name              = azurerm_virtual_network.this.name
  address_prefixes                  = [local.pe_prefix]
  private_endpoint_network_policies = "Disabled"
}

resource "azurerm_subnet" "apim" {
  name                 = "snet-apim"
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [local.apim_prefix]

  delegation {
    name = "apim"

    service_delegation {
      name = "Microsoft.ApiManagement/service"
    }
  }

  # v5 shape. v4 wrote service_endpoints = ["Microsoft.Storage", "Microsoft.EventHub"].
  service_endpoint {
    service = "Microsoft.Storage"
  }

  service_endpoint {
    service = "Microsoft.EventHub"
  }
}
Enter fullscreen mode Exit fullscreen mode

aca_subnet_newbits = 7 against a /16 produces a /23, and that number is the only one in this article you cannot revise later. Learn's rule for a workload profiles environment is that "the minimum subnet size required for virtual network integration is /27", and a few lines further down on the same page, "you can't modify subnet sizes after you create a Container Apps environment" (custom-virtual-networks#subnet). An undersized subnet is not a resize. It is a new environment, a new set of apps, new Dapr components, and a cutover.

/27 is legal and it caps hard:

Container Apps subnet sizing: available IPs per CIDR prefix and the node ceiling it imposes on Dedicated profiles against the replica ceiling on Consumption

Nine Dedicated nodes is a small production environment on a good day, and a rollout in revision_mode = "Single" runs the old and new revisions side by side, so the real ceiling during a deploy is roughly half the column. A /23 out of a 10.0.0.0/16 costs you address space you were never going to allocate to anything else.

Do not compute the size yourself from a reserved-IP count, because Microsoft states that count three ways. The body text on custom-virtual-networks#subnet says Container Apps "automatically reserves 12 IP addresses". The footnote under the sizing table on that same page says 14, "which includes 5 IP addresses that the subnet reserves". The workload profiles CLI page says 11, and concludes that a /27 has 21 available addresses, which contradicts the 18 in the table. Nobody has reconciled these. The sizing table is the source to trust, because it is the only one that resolves to node and replica limits rather than to an IP count, and its arithmetic agrees with itself. Read the replica column, double it for the rollout, and pick the row above that.

The /21 figure still circulating comes from the azurerm 4.51 docs and is stale by a factor of 64. The /23 minimum that sits next to it belongs to the legacy Consumption-only environment, and that is also where the delegation rule inverts rather than relaxes: a workload profiles subnet must be delegated to Microsoft.App/environments, and a Consumption-only subnet "must not be delegated to any services, including Microsoft.App/environments". Copying a Consumption-era module into a workload profiles environment gets both halves wrong at once. The actions list under service_delegation is the one Azure fills in for that delegation anyway; write it or leave it out, you cannot change it.

azurerm_virtual_network also accepts an inline subnet block, and a VNet that uses the inline form and standalone azurerm_subnet resources at the same time fights itself on every plan. Pick one, and pick the standalone resources.

The NSG rules everyone pastes are the Consumption rules

# Workload profiles rules, not the Consumption-only set. The Consumption environment needs
# an outbound allow to AzureCloud on 443 and to 1.1.1.1/1.0.0.1 on 53; a workload profiles
# environment reaches its control plane over the same 443 rules the app already needs.
resource "azurerm_network_security_group" "container_apps" {
  name                = "nsg-aca-${var.workload}-${var.environment}"
  location            = var.location
  resource_group_name = var.resource_group_name
  tags                = var.tags

  security_rule {
    name                       = "allow-apim-to-edge-proxy"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_ranges    = ["443", "31443"]
    source_address_prefix      = local.apim_prefix
    destination_address_prefix = local.aca_prefix
  }

  security_rule {
    name                       = "allow-lb-health-probe"
    priority                   = 110
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "30000-32767"
    source_address_prefix      = "AzureLoadBalancer"
    destination_address_prefix = local.aca_prefix
  }

  # An NSG is evaluated on both NICs, so the catch-all deny below would otherwise override the
  # default AllowVnetInBound at priority 65000 and break pod to pod traffic inside the subnet.
  security_rule {
    name                       = "allow-intra-subnet-inbound"
    priority                   = 120
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = local.aca_prefix
    destination_address_prefix = local.aca_prefix
  }

  security_rule {
    name                       = "deny-other-inbound"
    priority                   = 4000
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-entra"
    priority                   = 130
    direction                  = "Outbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = local.aca_prefix
    destination_address_prefix = "AzureActiveDirectory"
  }

  # Never deny 168.63.129.16. The environment stops working, and nothing in the portal says why.
  security_rule {
    name                       = "allow-azure-platform-dns"
    priority                   = 150
    direction                  = "Outbound"
    access                     = "Allow"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "53"
    source_address_prefix      = local.aca_prefix
    destination_address_prefix = "168.63.129.16"
  }

  security_rule {
    name                       = "deny-other-outbound"
    priority                   = 4000
    direction                  = "Outbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "container_apps" {
  subnet_id                 = azurerm_subnet.container_apps.id
  network_security_group_id = azurerm_network_security_group.container_apps.id
}
Enter fullscreen mode Exit fullscreen mode

The full module adds four more outbound allows on 443, to MicrosoftContainerRegistry, AzureFrontDoor.FirstParty, AzureMonitor and the private endpoint prefix. What is missing is the interesting part. Most Container Apps NSGs in the wild were copied off the Consumption-only tab of firewall-integration, and on a workload profiles environment those extra rules grant reachability nobody needs:

NSG rules from the Consumption-only guidance against what a workload profiles environment actually needs, including the inbound gateway subnet ports that only workload profiles require

AzureActiveDirectory on 443 outbound sits on the workload profiles outbound list with a condition attached: "If you're using a managed identity, it's required" (firewall-integration). In this architecture that condition is never false, because every identity here is a managed identity and every one of them fetches its token over that path. Remove that rule and you do not break the platform; you break every grant the previous section set up, some hours later, when a cached token expires.

Two more rules are not about ports. Never deny 168.63.129.16: the docs carry an explicit warning and the environment stops working. And the moment a catch-all inbound deny exists, an explicit intra-subnet inbound allow becomes mandatory, because an NSG is evaluated on both the source and the destination NIC and a rule at priority 4000 wins over the default AllowVnetInBound at 65000. One more shows up only at apply time: service tags work in source_address_prefix and not in source_address_prefixes, so the plural form takes your AzureLoadBalancer string and fails on the API call.

The sharper point is that on an external workload profiles environment, none of the inbound rules do anything at all. Traffic arrives through a public IP in the environment's managed resource group and reaches the app without ever transiting your subnet, so the NSG sees it only on the way out. internal_only = true is what turns this file from documentation into enforcement, which is the Terraform-shaped version of the argument Part 6 made about Private Link at the gateway.

None of those rules apply until the association exists, which is the second dashed edge on the module diagram near the top of this article. The networking module exports nsg_association_id for callers who would rather have that ordering as a reference than as a hand-written prerequisite; the root module takes the blunter route and lists module.networking in depends_on.

Private DNS: the zone the private endpoint will not create for you

Four services go behind private endpoints and they need five zones:

locals {
  # ACR Premium needs the region-specific data zone as well as the registry zone.
  # Without it the manifest resolves privately and the layer download falls back to the public endpoint.
  private_dns_zone_names = {
    cosmos     = "privatelink.documents.azure.com"
    servicebus = "privatelink.servicebus.windows.net"
    keyvault   = "privatelink.vaultcore.azure.net"
    acr        = "privatelink.azurecr.io"
    acr_data   = "${var.location}.data.privatelink.azurecr.io"
  }
}

resource "azurerm_private_dns_zone" "this" {
  for_each = local.private_dns_zone_names

  name                = each.value
  resource_group_name = var.resource_group_name
  tags                = var.tags
}

# private_dns_zone_group on the endpoint writes the records. It does not link the zone to the
# VNet, and an unlinked zone resolves to the public IP with no DNS error anywhere.
resource "azurerm_private_dns_zone_virtual_network_link" "this" {
  for_each = azurerm_private_dns_zone.this

  name                 = "link-${each.key}-${var.workload}-${var.environment}"
  private_dns_zone_id  = each.value.id
  virtual_network_id   = azurerm_virtual_network.this.id
  registration_enabled = false
  tags                 = var.tags
}

resource "azurerm_private_endpoint" "container_registry" {
  name                = "pe-acr-${var.workload}-${var.environment}"
  location            = var.location
  resource_group_name = var.resource_group_name
  subnet_id           = azurerm_subnet.private_endpoints.id
  tags                = var.tags

  private_service_connection {
    name                           = "psc-acr"
    private_connection_resource_id = var.container_registry_id
    subresource_names              = ["registry"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name = "acr"
    private_dns_zone_ids = [
      azurerm_private_dns_zone.this["acr"].id,
      azurerm_private_dns_zone.this["acr_data"].id,
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

A private endpoint does exactly one DNS job for you, and it is not the one people assume. private_dns_zone_group writes the A record for the endpoint's private IP into the zones you list. It does not link those zones to a VNet. Skip the link and every lookup falls through to public DNS, which answers with the public IP, and nothing reports a DNS failure anywhere: the application gets a connection refused or a 403 from a firewall, and the first four people who look at it will look at the firewall. The for_each pairing zones with links is worth more than the typing it saves, because it makes an unlinked zone impossible to create.

Three details in that file will only ever be checked by the service. subresource_names is case-sensitive at the API and the casing is not consistent between services: Sql for Cosmos NoSQL, namespace for Service Bus, vault for Key Vault, registry for the registry. Cosmos in particular has one answer per API, so a Table or Gremlin account needs a different subresource and a different zone than the privatelink.documents.azure.com above. Second, privatelink.servicebus.windows.net is shared by Service Bus, Event Hubs, Relay and IoT Hub, so four unrelated services collide in one zone and one for_each key. Third, ACR Premium needs two zones. The registry zone resolves the manifest; the region-specific {region}.data.privatelink.azurecr.io zone resolves the data endpoint that serves the layers. Omit the second and a pull starts privately, then stalls or falls back partway through the download, which looks like a network problem and is a missing zone.

The zone nobody remembers belongs to the environment itself, and it lives in the root module because the environment is not a private endpoint at all:

# A private endpoint gets its records from private_dns_zone_group. The environment is not a
# private endpoint, so its default_domain zone is hand-built: wildcard for the apps, apex for
# the environment itself, both pointing at the internal load balancer address.
resource "azurerm_private_dns_zone" "container_apps_environment" {
  name                = module.container_apps.environment_default_domain
  resource_group_name = azurerm_resource_group.this.name
  tags                = local.required_tags
}

resource "azurerm_private_dns_zone_virtual_network_link" "container_apps_environment" {
  name                 = "link-cae-${local.name_suffix}"
  private_dns_zone_id  = azurerm_private_dns_zone.container_apps_environment.id
  virtual_network_id   = module.networking.vnet_id
  registration_enabled = false
  resolution_policy    = "Default"
  tags                 = local.required_tags
}

resource "azurerm_private_dns_a_record" "container_apps_wildcard" {
  name                = "*"
  private_dns_zone_id = azurerm_private_dns_zone.container_apps_environment.id
  ttl                 = 300
  records             = [module.container_apps.environment_static_ip]
  tags                = local.required_tags
}
Enter fullscreen mode Exit fullscreen mode

Both halves of that are exported attributes, default_domain and static_ip_address, so no data source and no hard-coded domain is involved. The apex record is the same shape with name = "@". This zone only belongs on an internal environment: static_ip_address is the internal load balancer address when internal_load_balancer_enabled is true and a public IP when it is not, and publishing a public IP into a private zone is a confusing way to achieve nothing.

Coming from azurerm v4

Four v5 changes hit this stack, and the private DNS one is why the diff is large rather than interesting:

AzureRM provider v4 to v5 breaking changes: renamed arguments, the service endpoint block syntax switch, the inverted Cosmos DB local auth flag, and where each one bites

The link and record rows are mechanical: five zones against one VNet means five links, plus every A record resource. The row to slow down on is the Cosmos one, because it is a rename and a polarity flip. Carry the boolean across unchanged and you have written local_authentication_enabled = true, which turns key authentication back on in the same commit where you believed you turned it off, and the plan diff reads like a rename. The neighbouring services spell the same idea three more ways for good measure: Service Bus uses local_auth_enabled = false and Key Vault uses rbac_authorization_enabled = true.

None of the container_app resources themselves carry a breaking change across either major. The whole tree validated on ~> 4.51 and on ~> 5.6. Everything that hurt was in the network and the data services around them.

The identity chain, and the edge Terraform cannot see

One identity per app, and every grant it needs hanging off it:

locals {
  # Role GUIDs, not names. A renamed role keeps its ID, and a name costs a role-definitions
  # lookup on every plan.
  role_ids = {
    acr_pull         = "7f951dda-4ed3-4680-a7ca-43fe172d538d"
    kv_secrets_user  = "4633458b-17de-408a-b874-0445c86b69e6"
    sb_data_sender   = "69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"
    sb_data_receiver = "4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"
  }

  role_definition_prefix = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions"

  # Grants are scoped to the individual queue, never to the namespace.
  app_queue_pairs = {
    for pair in setproduct(tolist(var.app_ids), keys(var.service_bus_queue_ids)) :
    "${pair[0]}.${pair[1]}" => {
      app_id = pair[0]
      queue  = pair[1]
    }
  }
}

resource "azurerm_user_assigned_identity" "app" {
  for_each = var.app_ids

  name                = "id-${each.value}-${var.environment}"
  location            = var.location
  resource_group_name = var.resource_group_name
  tags                = var.tags
}

# principal_id is unknown until apply, so Terraform orders the identity ahead of every grant
# below without any help. The edge it cannot see is the one from the container app to this
# grant; see the depends_on on the container-apps module call in the root module.
resource "azurerm_role_assignment" "acr_pull" {
  for_each = var.app_ids

  scope              = var.container_registry_id
  role_definition_id = "${local.role_definition_prefix}/${local.role_ids.acr_pull}"
  principal_id       = azurerm_user_assigned_identity.app[each.value].principal_id

  # Correct for a managed identity and wrong for a user or a group: it suppresses the existence
  # check that fails with PrincipalNotFound while Entra is still replicating the new principal.
  principal_type                   = "ServicePrincipal"
  skip_service_principal_aad_check = true
}

resource "azurerm_role_assignment" "service_bus_sender" {
  for_each = local.app_queue_pairs

  scope                            = var.service_bus_queue_ids[each.value.queue]
  role_definition_id               = "${local.role_definition_prefix}/${local.role_ids.sb_data_sender}"
  principal_id                     = azurerm_user_assigned_identity.app[each.value.app_id].principal_id
  principal_type                   = "ServicePrincipal"
  skip_service_principal_aad_check = true
}

# Cosmos data-plane access is not azurerm_role_assignment. A Cosmos DB Built-in Data Contributor
# handed out through the control plane grants nothing at the data plane, and the apply is green.
# 0001 is Data Reader, 0002 is Data Contributor.
resource "azurerm_cosmosdb_sql_role_assignment" "data_contributor" {
  for_each = var.app_ids

  resource_group_name = var.cosmosdb_resource_group_name
  account_name        = var.cosmosdb_account_name
  role_definition_id  = "${var.cosmosdb_account_id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"
  principal_id        = azurerm_user_assigned_identity.app[each.value].principal_id
  scope               = "${var.cosmosdb_account_id}/dbs/${var.cosmosdb_database_name}"
}
Enter fullscreen mode Exit fullscreen mode

Two edges in that file run in opposite directions and only one of them exists. principal_id is unknown until apply, so Terraform has to create the identity before it can even render the role assignment, and it orders them correctly with no help. The container app in the next module over references the identity and the registry login server, never the grant, so Terraform is equally free to create the app first and let the first revision fail its pull. That is the failed pull from the opening paragraph, and the depends_on on the module call is the whole of the fix.

The same grant carries two more failure modes that have nothing to do with ordering. A brand new service principal can return PrincipalNotFound from the role assignment API while Entra is still replicating it, which skip_service_principal_aad_check = true suppresses. That argument is correct for every managed identity and wrong for a user or a group, where the check is the only thing standing between a typo and a grant to nothing. And the one nothing in Terraform can fix: managed identity backends cache tokens per resource "for around 24 hours", and Cosmos data-plane assignments propagate on their own schedule, so a green apply is evidence that a grant exists and not that it works yet. Removing a grant lags the same way, which matters more.

The GUID map is not premature optimisation: Microsoft's own advice is to use IDs because "even if the role is renamed, the role ID does not change", and role_definition_name makes the provider list role definitions on every plan. app_queue_pairs comes from the same instinct. setproduct over apps and queues produces one grant per app per queue, scoped to azurerm_servicebus_queue.orders.id. Scoping to the namespace instead is the most common over-grant in this architecture, and it is one shorter scope argument away.

The Cosmos resource at the bottom breaks the intuition of anyone who already knows Azure RBAC. azurerm_role_assignment with a Cosmos DB Built-in Data Contributor role does exist, does apply, and grants nothing at the data plane, because Cosmos keeps its own role system underneath the account. azurerm_cosmosdb_sql_role_assignment takes a sqlRoleDefinitions/... path, not a subscription-level role definition ID, and its scope is a data-plane path like <account>/dbs/orders. Built-in ...0001 is Data Reader and ...0002 is Data Contributor. This is what makes local_authentication_enabled = false on the account survivable: turn keys off without the data-plane assignment and every read returns 403, so the two changes belong in the same commit.

State, and the lease nothing expires

One root module, one backend config per environment, one tfvars file per environment, and no workspaces:

use_oidc             = true
use_azuread_auth     = true
storage_account_name = "sttfstateprod01"
container_name       = "tfstate"
key                  = "container-apps/prod.tfstate"
Enter fullscreen mode Exit fullscreen mode
subscription_id = "00000000-0000-0000-0000-000000000000"

workload      = "orders"
environment   = "prod"
location      = "westeurope"
address_space = "10.70.0.0/16"

cost_center = "GAZE"
owner       = "AZE"
project     = "orders"

image_tag    = "1.4.2"
min_replicas = 2
max_replicas = 30

log_retention_days = 90

dedicated_workload_profiles = {
  D4 = {
    profile_type  = "D4"
    minimum_count = 1
    maximum_count = 5
  }
}

# orders_api_key has no default and is deliberately absent here. Supply it as
# TF_VAR_orders_api_key from the pipeline, not from a file in git.
Enter fullscreen mode Exit fullscreen mode
cd infra
terraform init -reconfigure -backend-config=envs/prod.backend.hcl
terraform plan -var-file=envs/prod.tfvars -out=tfplan
terraform apply tfplan
Enter fullscreen mode Exit fullscreen mode

use_azuread_auth = true is now a required option rather than an opt-in when you authenticate to the state account with Entra ID, and resource_group_name moved from required to optional on the backend; it is needed only alongside lookup_blob_endpoint. The -reconfigure on init lets the same working directory point at a different state blob without Terraform offering to migrate the old one.

Refusing workspaces is a decision with a cost, so here are both halves. HashiCorp's own position is that "CLI workspaces within a working directory use the same backend", which makes them unsuitable for environments that differ in blast radius. The Azure-specific reason is uglier: the azurerm backend builds the workspace blob name by string concatenation, so a staging workspace on key = "container-apps/prod.tfstate" writes its state to a blob literally named container-apps/prod.tfstateenv:staging. What you give up is per-environment configuration divergence. Anything dev needs that prod does not has to become a variable, and a variable that exists only to make dev cheaper is one somebody will eventually set in prod.

The lock deserves more attention than the docs give it. All the backend documentation says is "supports state locking", and the mechanism is in the implementation: Lock() acquires a blob lease on the state file with LeaseDuration: -1, the Terraform lock ID travels as the proposed lease ID in the x-ms-lease-id header, and the holder is recorded in the blob's terraformlockid metadata. There is no lock table to provision, which is the pleasant half.

The unpleasant half is that -1 means infinite, and infinite means nothing expires it. A runner that is hard-killed mid-apply leaves the state locked until a human runs terraform force-unlock <LOCK_ID> or breaks the lease in the portal. That is a runbook entry, not a piece of folklore, and it is the reason every plan and apply in the next section passes -lock-timeout=5m and the workflow sets cancel-in-progress: false. The default reflex of cancel-in-progress: true is precisely the wrong setting here: it cancels a running apply halfway through and strands the lease it was holding.

Correct reasoning gets you the wrong answer on one RBAC detail. Storage Blob Data Reader is not enough for terraform plan, because plan takes the lock and taking the lock writes blob metadata. Grant Contributor scoped to the container, or run read-only plans with -lock=false and accept that you can read a state file somebody else is halfway through writing.

Tags: the provider argument that does not exist

cost_center, owner and project are in that tfvars file because the policy in this tenant denies any resource missing cost-center, owner, environment and project. That is a provider-level problem in every IaC tool that has provider-level tags. azurerm is not one of them:

# azurerm has no provider-level default_tags, so the policy-required four go in a locals map and
# get assigned on every resource. merge() where a resource needs extras.
locals {
  required_tags = {
    "cost-center" = var.cost_center
    "owner"       = var.owner
    "environment" = var.environment
    "project"     = var.project
  }
}

resource "azurerm_log_analytics_workspace" "this" {
  name                = "log-${local.name_suffix}"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  sku                 = "PerGB2018"
  retention_in_days   = var.log_retention_days
  tags                = local.required_tags
}
Enter fullscreen mode Exit fullscreen mode

tags = local.required_tags on every resource, tags = merge(local.required_tags, { ... }) where one needs extras, and a review that catches the resource where somebody forgot. The tracker will tell you otherwise if you let it: issue #11682 asking for default_tags has 48 reactions and is closed as completed, while PR #31108 adding the argument has been open and unmerged since November 2025. A closed-as-completed issue is not a released feature.

The tooling will not correct you either. Write default_tags { tags = local.required_tags } into a provider block, run terraform validate, and you get Success!, because validate never decodes the provider block, and neither does plan when no resource needs a provider instance: a configuration whose only content was that invented block also returned No changes. "It validated" is not evidence that an argument exists. terraform providers schema -json is the answer: the azurerm provider block carries no tag attribute and exactly one nested block, features.

The pipeline: plan on the PR, apply behind an environment gate

name: Terraform

on:
  pull_request:
    branches: [main]
    paths: ["TerraformContainerAppsDemo/infra/**"]
  push:
    branches: [main]
    paths: ["TerraformContainerAppsDemo/infra/**"]

permissions:
  contents: read

concurrency:
  group: terraform-${{ github.ref }}
  # Never true here. The azurerm backend lock is an infinite blob lease, so a cancelled apply
  # strands the state locked until someone runs terraform force-unlock.
  cancel-in-progress: false

env:
  TF_IN_AUTOMATION: "true"
  ARM_USE_OIDC: "true"
  ARM_USE_AZUREAD: "true"
  ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
  ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}

jobs:
  plan:
    name: Plan (prod)
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    defaults:
      run:
        working-directory: TerraformContainerAppsDemo/infra
    steps:
      - uses: actions/checkout@v5

      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: 1.16.3

      - name: Init
        id: init
        run: terraform init -input=false -backend-config=envs/prod.backend.hcl

      - name: Validate
        id: validate
        run: terraform validate -no-color

      - name: Plan
        id: plan
        env:
          TF_VAR_orders_api_key: ${{ secrets.ORDERS_API_KEY }}
        run: terraform plan -input=false -no-color -lock-timeout=5m -var-file=envs/prod.tfvars -out=tfplan
        continue-on-error: true

      - name: Fail if plan errored
        if: steps.plan.outcome == 'failure'
        run: exit 1

      # A saved plan carries sensitive values in cleartext and any user with repo read can
      # download an artifact. Keep the retention short.
      - name: Upload plan
        if: github.event_name == 'push'
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: TerraformContainerAppsDemo/infra/tfplan
          retention-days: 1

  apply:
    name: Apply (prod)
    needs: plan
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    # The approval gate is this key and nothing else. Required reviewers, wait timers and
    # deployment branch rules are configured on the environment in repo settings.
    environment: prod
    permissions:
      contents: read
      id-token: write
    defaults:
      run:
        working-directory: TerraformContainerAppsDemo/infra
    steps:
      - uses: actions/checkout@v5

      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: 1.16.3
          terraform_wrapper: false

      - uses: actions/download-artifact@v4
        with:
          name: tfplan
          path: TerraformContainerAppsDemo/infra

      - name: Init
        run: terraform init -input=false -backend-config=envs/prod.backend.hcl

      # No -var-file and no -auto-approve: a saved plan takes no further planning options, and
      # passing the file is the approval.
      - name: Apply
        run: terraform apply -input=false -lock-timeout=5m tfplan
Enter fullscreen mode Exit fullscreen mode

The trimmed step is a github-script block that posts the plan as a PR comment and updates that comment on every push instead of stacking new ones. Four smaller choices in the file get a line each. id-token: write sits on each job and never at workflow level, because at workflow level every job in the file holds a token exchangeable for Azure credentials, including the ones that only run a linter. azure/login is absent because the azurerm provider performs the OIDC exchange itself. The apply job passes a saved plan with no -var-file and no -auto-approve, since a saved plan accepts no further planning options and handing it over is the approval. And terraform validate needs no Azure credentials at all, because subscription_id is required for plan and apply and not for validate.

Everything that actually breaks in this file breaks on one string. The federated credential matches the sub claim on the token GitHub mints, and that claim changes shape per trigger:

repo:OWNER/REPO:pull_request
repo:OWNER/REPO:ref:refs/heads/main
repo:OWNER/REPO:environment:prod
Enter fullscreen mode Exit fullscreen mode

Three subjects, so three federated credentials on the identity. The precedence rule is what makes this a trap rather than an inconvenience: GitHub uses the environment name whenever the job references an environment, pull_request only if the job references no environment and the trigger was a pull request, and the branch only when neither applies (subject claim examples). Add environment: prod to the plan job so it can read a secret and you have silently rewritten that job's own subject, its credential stops matching, and the AADSTS700213-class error you get back says nothing about environments.

The segment key is singular ref:. HashiCorp's own azurerm OIDC guide prints refs:refs/heads/main; GitHub mints the token, GitHub uses ref:, and the plural form is a typo that will cost you an afternoon because the failure looks like a trust configuration problem rather than a spelling one.

Then there is the change that made every older tutorial wrong. "Repositories created after July 15, 2026 now use an immutable default subject format that includes both the owner ID and repository ID" (immutable subject claims), which looks like repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH. Repos created before that date keep the old format until they opt in, and a rename or a transfer after it flips them. The IDs cannot be stripped back out with include_claim_keys. Copy a subject string out of any pre-2026 blog post, including a good one, and it will not match on a new repository. Read the real sub off a token, or build the string from the owner and repo IDs the API gives you. There is a ceiling on that string, too: an application or user-assigned identity accepts a maximum of 20 federated identity credentials and wildcards are not supported, so one credential per subject is a budget rather than a pattern.

Drift detection that is still believed in week three

on:
  schedule:
    - cron: "17 6 * * 1-5"
  workflow_dispatch:

jobs:
  drift:
    runs-on: ubuntu-latest
    # If the prod environment has required reviewers, this scheduled run sits waiting for
    # approval. Give drift its own read-scoped environment with no reviewers when that happens.
    environment: prod
    permissions:
      contents: read
      id-token: write
      issues: write
    steps:
      # -detailed-exitcode is what makes this possible: 0 no changes, 1 error, 2 changes.
      # Without it a plan with changes and a plan without both exit 0.
      - name: Plan with detailed exit code
        id: plan
        env:
          TF_VAR_orders_api_key: ${{ secrets.ORDERS_API_KEY }}
        run: terraform plan -input=false -no-color -lock-timeout=5m -detailed-exitcode -var-file=envs/prod.tfvars
        continue-on-error: true

      - name: Fail the job on a real error
        if: steps.plan.outputs.exitcode == '1'
        run: exit 1

      # A detector that only opens issues trains everyone to ignore it within two weeks.
      - name: Close the drift issue when clean
        if: steps.plan.outputs.exitcode == '0'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh issue list --state open --label drift --json number --jq '.[].number' \
            | xargs -r -I{} gh issue close {} --comment "Plan is clean again."
Enter fullscreen mode Exit fullscreen mode

The checkout, setup and init steps are the same three as the plan job, and the step that opens or comments on the issue on exit code 2 is in the companion repo. continue-on-error: true on the plan step is not optional: exit code 2 is a success for our purposes and a failure as far as Actions is concerned, so without it the step fails, every conditional after it is skipped, and you get a red run that tells you nothing about whether prod drifted.

The step most drift workflows are missing is the last one. Opening an issue when the plan is dirty is the easy half; closing it when the plan comes back clean keeps anyone reading the issues in week three. A detector that only ever adds to a pile teaches the team to filter the label, and after that it is a scheduled job that costs money and detects nothing.

Whether this ever runs comes down to scheduling rules the docs bury. Scheduled workflows only ever run from the default branch, so you cannot test a schedule change on a branch and workflow_dispatch is how you try it. GitHub delays scheduled runs during high load at the top of the hour, hence 17 rather than 0. And a public repository has its scheduled workflows disabled after 60 days without activity, which lands exactly when drift has had time to accumulate and nobody is watching. The environment: prod on this job exists to match the third federated credential, and it brings the approval gate with it: if that environment has required reviewers, the scheduled run sits waiting for a human who is not expecting to approve anything. Give drift a read-scoped environment of its own with its own credential.

Conclusion

A grant before a pull. An NSG before an environment. A zone link before a lookup. A data-plane role before a read. None of those four are things your configuration has any reason to reference, which is why depends_on and a handful of precondition blocks are where you write them down, and why every silent failure in this article is the same failure.

The full tree is in the companion repo, including the two workflow files and the steps trimmed out of the samples above. It validates and fmts clean on Terraform 1.16.3 with azurerm 5.6.0, and it deploys nothing on its own.

One question, and it splits teams cleanly: does your Terraform run plan on every pull request, or only after the merge?

Top comments (0)