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
- You run HTTP APIs or event-driven workers as containers and want scale-to-zero economics without operating AKS.
- You have multiple Container Apps sharing one managed environment and want them provisioned identically.
- You need secrets sourced from Key Vault by reference (not pasted into Terraform state as plaintext) and a user-assigned identity to do the pull.
- You want revision-based deploys — multiple-revision mode with weighted traffic for blue/green or canary, or single-revision mode for the simplest path.
- You do not want this module to own the managed environment, ACR or Key Vault — those are shared platform resources passed in by ID. Use it strictly for the app workloads layered on top.
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 config — live/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 config — live/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
- Pull with managed identity, never admin creds. Keep ACR admin user disabled and wire
registry_serverto the module’s user-assigned identity; grant onlyAcrPullon the registry scope via the exportedidentity_principal_id. - Reference secrets from Key Vault, don’t inline them. Use the
secretsinput with versionlesskey_vault_secret_idvalues and reference them from env viasecret_name— this keeps plaintext out of Terraform state and lets you rotate in Key Vault without a redeploy. - Tune scale to the workload, not a guess. Set
min_replicas = 0for spiky or batch workloads to capture scale-to-zero savings, but usemin_replicas >= 1for latency-sensitive APIs to dodge cold starts; pairhttp_concurrent_requests(or acustom_scale_rulesqueue scaler) with a sanemax_replicasceiling so a traffic spike can’t run up the bill. - Keep services internal by default. Leave
ingress_external_enabled = falseand front the environment with Front Door or Application Gateway + WAF, so the public attack surface lives at one audited edge rather than on every app. - Always define probes. Set
liveness_probe_pathandreadiness_probe_pathso unhealthy replicas are recycled and traffic only reaches ready instances — without them, a wedged container keeps serving errors during a deploy. - Use Multiple-revision mode for anything customer-facing. It unlocks weighted
traffic_weightfor blue/green and canary; reserveSinglemode for internal tools where a hard cutover is acceptable, and standardize names as<workload>-<role>(e.g.orders-api,orders-worker) so the estate stays legible.