IaC Azure

Terraform Module: Azure Container Apps — serverless containers with scale-to-zero, baked in

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_container_app: ingress, KEDA autoscaling, Key Vault secrets, managed identity and revision control in one wrapper. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "container_apps" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-container-apps?ref=v1.0.0"

  name                         = "..."  # Container App name (3-32 chars, lowercase + hyphens); a…
  resource_group_name          = "..."  # Resource group hosting the app and its identity.
  location                     = "..."  # Region for the user-assigned identity.
  container_app_environment_id = "..."  # Resource ID of the existing managed environment.
  image                        = "..."  # Fully-qualified container image reference.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Container Apps is the serverless container runtime built on Kubernetes and KEDA, but with the cluster hidden away. You hand it an image, an ingress definition and a scale rule, and it runs your workload across managed revisions — scaling to zero when idle and back up under load — without you ever touching a node pool, an ingress controller or a Helm chart. It sits in the sweet spot between Azure Functions (too opinionated for long-running services) and AKS (too much operational surface for a single team).

The trouble is that a production azurerm_container_app is rarely just an image and a port. You almost always need a managed identity so the app can pull from ACR and read secrets, a secret block wired to Key Vault, an ingress block with the right transport and traffic-splitting, and a KEDA custom_scale_rule so it doesn’t either fall over or burn money idling at minimum replicas. Copy-pasting that across a dozen services means a dozen subtly different ingress and identity configs. This module wraps azurerm_container_app so every service in the estate gets the same identity wiring, the same secret-from-Key-Vault pattern, the same revision-mode discipline and the same scale defaults — driven entirely by variables.

When to use it

Module structure

terraform-module-azure-container-apps/
├── versions.tf      # provider + version pins
├── main.tf          # azurerm_container_app + identity wiring
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id, name, FQDN, identity principal

versions.tf

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

# A user-assigned identity the app uses to pull from ACR and read Key Vault.
resource "azurerm_user_assigned_identity" "this" {
  name                = "id-${var.name}"
  resource_group_name = var.resource_group_name
  location            = var.location
  tags                = var.tags
}

resource "azurerm_container_app" "this" {
  name                         = var.name
  resource_group_name          = var.resource_group_name
  container_app_environment_id = var.container_app_environment_id
  revision_mode                = var.revision_mode
  workload_profile_name        = var.workload_profile_name
  tags                         = var.tags

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.this.id]
  }

  # Pull from a private ACR using the user-assigned identity (no admin creds).
  dynamic "registry" {
    for_each = var.registry_server == null ? [] : [1]
    content {
      server   = var.registry_server
      identity = azurerm_user_assigned_identity.this.id
    }
  }

  # Secrets are Key Vault references — values never land in TF state as plaintext.
  dynamic "secret" {
    for_each = var.secrets
    content {
      name                = secret.value.name
      key_vault_secret_id = secret.value.key_vault_secret_id
      identity            = azurerm_user_assigned_identity.this.id
    }
  }

  template {
    min_replicas = var.min_replicas
    max_replicas = var.max_replicas

    container {
      name   = var.container_name
      image  = var.image
      cpu    = var.cpu
      memory = var.memory

      dynamic "env" {
        for_each = var.env_vars
        content {
          name        = env.value.name
          value       = env.value.value
          secret_name = env.value.secret_name
        }
      }

      dynamic "liveness_probe" {
        for_each = var.liveness_probe_path == null ? [] : [1]
        content {
          transport               = "HTTP"
          path                    = var.liveness_probe_path
          port                    = var.target_port
          initial_delay           = 10
          interval_seconds        = 10
          failure_count_threshold = 3
        }
      }

      dynamic "readiness_probe" {
        for_each = var.readiness_probe_path == null ? [] : [1]
        content {
          transport               = "HTTP"
          path                    = var.readiness_probe_path
          port                    = var.target_port
          interval_seconds        = 10
          failure_count_threshold = 3
        }
      }
    }

    # KEDA HTTP concurrency scaler — the bread-and-butter rule for web APIs.
    dynamic "http_scale_rule" {
      for_each = var.http_concurrent_requests == null ? [] : [1]
      content {
        name                = "http-scaler"
        concurrent_requests = var.http_concurrent_requests
      }
    }

    # Optional arbitrary KEDA custom scaler (e.g. azure-servicebus, kafka).
    dynamic "custom_scale_rule" {
      for_each = var.custom_scale_rules
      content {
        name             = custom_scale_rule.value.name
        custom_rule_type = custom_scale_rule.value.custom_rule_type
        metadata         = custom_scale_rule.value.metadata

        dynamic "authentication" {
          for_each = custom_scale_rule.value.authentication
          content {
            secret_name       = authentication.value.secret_name
            trigger_parameter = authentication.value.trigger_parameter
          }
        }
      }
    }
  }

  dynamic "ingress" {
    for_each = var.ingress_enabled ? [1] : []
    content {
      external_enabled           = var.ingress_external_enabled
      target_port                = var.target_port
      transport                  = var.ingress_transport
      allow_insecure_connections = false

      traffic_weight {
        latest_revision = true
        percentage      = 100
      }
    }
  }
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Container App (also seeds the identity name)."

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$", var.name))
    error_message = "name must be 3-32 chars, lowercase alphanumeric and hyphens, not starting/ending with a hyphen."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group that hosts the Container App and its identity."
}

variable "location" {
  type        = string
  description = "Azure region for the user-assigned identity (the app inherits the environment's region)."
}

variable "container_app_environment_id" {
  type        = string
  description = "Resource ID of the existing Container App managed environment."
}

variable "revision_mode" {
  type        = string
  default     = "Single"
  description = "Revision mode: Single or Multiple (Multiple enables weighted traffic for canary/blue-green)."

  validation {
    condition     = contains(["Single", "Multiple"], var.revision_mode)
    error_message = "revision_mode must be either 'Single' or 'Multiple'."
  }
}

variable "workload_profile_name" {
  type        = string
  default     = null
  description = "Optional workload profile name (e.g. a dedicated D4 profile). Null uses the Consumption profile."
}

variable "container_name" {
  type        = string
  default     = "app"
  description = "Name of the container inside the template."
}

variable "image" {
  type        = string
  description = "Fully-qualified container image, e.g. myregistry.azurecr.io/api:1.4.2."
}

variable "cpu" {
  type        = number
  default     = 0.5
  description = "vCPU cores for the container (must pair with a valid memory value, e.g. 0.5 CPU / 1Gi)."
}

variable "memory" {
  type        = string
  default     = "1Gi"
  description = "Memory for the container, e.g. '1Gi'. Must form a valid CPU/memory combination."

  validation {
    condition     = can(regex("^[0-9.]+Gi$", var.memory))
    error_message = "memory must be expressed in Gi, e.g. '0.5Gi', '1Gi', '2Gi'."
  }
}

variable "min_replicas" {
  type        = number
  default     = 0
  description = "Minimum replicas. 0 enables scale-to-zero; set >=1 to avoid cold starts."

  validation {
    condition     = var.min_replicas >= 0 && var.min_replicas <= 1000
    error_message = "min_replicas must be between 0 and 1000."
  }
}

variable "max_replicas" {
  type        = number
  default     = 10
  description = "Maximum replicas the app may scale out to."

  validation {
    condition     = var.max_replicas >= 1 && var.max_replicas <= 1000
    error_message = "max_replicas must be between 1 and 1000."
  }
}

variable "registry_server" {
  type        = string
  default     = null
  description = "ACR login server (e.g. myregistry.azurecr.io). Pulled via the user-assigned identity. Null skips registry config."
}

variable "secrets" {
  type = list(object({
    name                = string
    key_vault_secret_id = string
  }))
  default     = []
  description = "Secrets sourced from Key Vault by versionless secret ID; referenced from env via secret_name."
}

variable "env_vars" {
  type = list(object({
    name        = string
    value       = optional(string)
    secret_name = optional(string)
  }))
  default     = []
  description = "Container env vars. Provide either a literal value or a secret_name referencing a declared secret."

  validation {
    condition = alltrue([
      for e in var.env_vars : (e.value != null) != (e.secret_name != null)
    ])
    error_message = "Each env var must set exactly one of value or secret_name."
  }
}

variable "ingress_enabled" {
  type        = bool
  default     = true
  description = "Whether to expose the app via ingress. Set false for background/event-driven workers."
}

variable "ingress_external_enabled" {
  type        = bool
  default     = false
  description = "If true, ingress is reachable from the internet; if false, only from within the environment/VNet."
}

variable "target_port" {
  type        = number
  default     = 8080
  description = "Container port that ingress and probes target."

  validation {
    condition     = var.target_port >= 1 && var.target_port <= 65535
    error_message = "target_port must be a valid TCP port (1-65535)."
  }
}

variable "ingress_transport" {
  type        = string
  default     = "auto"
  description = "Ingress transport: auto, http, http2 or tcp."

  validation {
    condition     = contains(["auto", "http", "http2", "tcp"], var.ingress_transport)
    error_message = "ingress_transport must be one of: auto, http, http2, tcp."
  }
}

variable "http_concurrent_requests" {
  type        = number
  default     = null
  description = "KEDA HTTP scaler target: concurrent requests per replica. Null disables the HTTP scale rule."
}

variable "custom_scale_rules" {
  type = list(object({
    name             = string
    custom_rule_type = string
    metadata         = map(string)
    authentication = optional(list(object({
      secret_name       = string
      trigger_parameter = string
    })), [])
  }))
  default     = []
  description = "KEDA custom scalers, e.g. azure-servicebus or kafka, with metadata and optional auth from secrets."
}

variable "liveness_probe_path" {
  type        = string
  default     = null
  description = "HTTP path for the liveness probe (e.g. /healthz). Null disables the probe."
}

variable "readiness_probe_path" {
  type        = string
  default     = null
  description = "HTTP path for the readiness probe (e.g. /ready). Null disables the probe."
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied to the Container App and its user-assigned identity."
}

outputs.tf

output "id" {
  description = "Resource ID of the Container App."
  value       = azurerm_container_app.this.id
}

output "name" {
  description = "Name of the Container App."
  value       = azurerm_container_app.this.name
}

output "latest_revision_name" {
  description = "Name of the latest revision (useful for traffic-splitting and diagnostics)."
  value       = azurerm_container_app.this.latest_revision_name
}

output "ingress_fqdn" {
  description = "Public/internal FQDN of the app's ingress, or null when ingress is disabled."
  value       = try(azurerm_container_app.this.ingress[0].fqdn, null)
}

output "identity_id" {
  description = "Resource ID of the user-assigned identity used by the app."
  value       = azurerm_user_assigned_identity.this.id
}

output "identity_principal_id" {
  description = "Principal (object) ID of the user-assigned identity — grant it AcrPull and Key Vault access."
  value       = azurerm_user_assigned_identity.this.principal_id
}

output "identity_client_id" {
  description = "Client ID of the user-assigned identity (for federated/workload identity scenarios)."
  value       = azurerm_user_assigned_identity.this.client_id
}

How to use it

module "container_apps" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-container-apps?ref=v1.0.0"

  name                         = "orders-api"
  resource_group_name          = azurerm_resource_group.platform.name
  location                     = azurerm_resource_group.platform.location
  container_app_environment_id = azurerm_container_app_environment.shared.id

  image           = "${azurerm_container_registry.acr.login_server}/orders-api:1.4.2"
  registry_server = azurerm_container_registry.acr.login_server
  cpu             = 0.5
  memory          = "1Gi"

  # Multiple-revision mode so we can canary new builds.
  revision_mode = "Multiple"

  # Scale-to-zero off-peak, scale on HTTP concurrency under load.
  min_replicas             = 1
  max_replicas             = 30
  http_concurrent_requests = 50

  ingress_enabled          = true
  ingress_external_enabled = false
  target_port              = 8080
  liveness_probe_path      = "/healthz"
  readiness_probe_path     = "/ready"

  secrets = [{
    name                = "db-connection"
    key_vault_secret_id = "${azurerm_key_vault.platform.vault_uri}secrets/orders-db-conn"
  }]

  env_vars = [
    { name = "ASPNETCORE_ENVIRONMENT", value = "Production" },
    { name = "DB_CONNECTION", secret_name = "db-connection" },
  ]

  tags = {
    workload = "orders"
    env      = "prod"
  }
}

# Grant the app's identity rights to pull images and read Key Vault.
resource "azurerm_role_assignment" "acr_pull" {
  scope                = azurerm_container_registry.acr.id
  role_definition_name = "AcrPull"
  principal_id         = module.container_apps.identity_principal_id
}

resource "azurerm_role_assignment" "kv_secrets" {
  scope                = azurerm_key_vault.platform.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = module.container_apps.identity_principal_id
}

# Downstream: route Front Door to the app's internal ingress FQDN.
resource "azurerm_cdn_frontdoor_origin" "orders" {
  name                          = "orders-origin"
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.apis.id
  host_name                     = module.container_apps.ingress_fqdn
  origin_host_header            = module.container_apps.ingress_fqdn
  https_port                    = 443
  certificate_name_check_enabled = true
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/container_apps/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-container-apps?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  container_app_environment_id = "..."
  image = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/container_apps && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Container App name (3-32 chars, lowercase + hyphens); also seeds the identity name.
resource_group_name string Yes Resource group hosting the app and its identity.
location string Yes Region for the user-assigned identity.
container_app_environment_id string Yes Resource ID of the existing managed environment.
image string Yes Fully-qualified container image reference.
revision_mode string "Single" No Single or Multiple (Multiple enables weighted traffic).
workload_profile_name string null No Optional dedicated workload profile; null uses Consumption.
container_name string "app" No Name of the container in the template.
cpu number 0.5 No vCPU cores; must form a valid pair with memory.
memory string "1Gi" No Memory in Gi; must form a valid CPU/memory combination.
min_replicas number 0 No Minimum replicas; 0 = scale-to-zero.
max_replicas number 10 No Maximum replicas.
registry_server string null No ACR login server; pulled via the user-assigned identity.
secrets list(object) [] No Key Vault secret references (name, key_vault_secret_id).
env_vars list(object) [] No Env vars; each sets exactly one of value or secret_name.
ingress_enabled bool true No Expose via ingress; false for background workers.
ingress_external_enabled bool false No Internet-facing ingress when true, internal-only when false.
target_port number 8080 No Container port for ingress and probes.
ingress_transport string "auto" No Ingress transport: auto, http, http2 or tcp.
http_concurrent_requests number null No KEDA HTTP scaler target per replica; null disables it.
custom_scale_rules list(object) [] No KEDA custom scalers (e.g. azure-servicebus) with metadata/auth.
liveness_probe_path string null No HTTP liveness path; null disables the probe.
readiness_probe_path string null No HTTP readiness path; null disables the probe.
tags map(string) {} No Tags applied to the app and its identity.

Outputs

Name Description
id Resource ID of the Container App.
name Name of the Container App.
latest_revision_name Name of the latest revision (for traffic-splitting/diagnostics).
ingress_fqdn Ingress FQDN, or null when ingress is disabled.
identity_id Resource ID of the user-assigned identity.
identity_principal_id Principal ID of the identity — grant it AcrPull and Key Vault access.
identity_client_id Client ID of the identity (for federated/workload identity).

Enterprise scenario

A retail platform team runs about 25 microservices on a single internal Container Apps environment fronted by Azure Front Door. Each service is a module "container_apps" instance in its own Terraform stack: ingress_external_enabled = false keeps every app off the public internet, registry_server plus the emitted identity_principal_id give each one a least-privilege AcrPull grant on the shared ACR, and secrets pulls connection strings from a central Key Vault so nothing sensitive lands in state. The checkout service runs revision_mode = "Multiple" so releases roll out as canaries with weighted traffic, while overnight batch workers set ingress_enabled = false and min_replicas = 0 to cost nothing when there is no queue to drain.

Best practices

TerraformAzureContainer AppsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading