Skip to content

Azure Container Apps Module

This OpenTofu module provisions a shared Azure Container App Environment along with one or more Azure Container Apps/App Jobs, using a User-Assigned Managed Identity to authorize Azure Container Registry (ACR) access.

Features

  • Multi-App Support: Deploy multiple container apps (app jobs) in a single environment
  • Managed Identity Authentication: Secure ACR access without storing credentials
  • VNET Integration: Deploy into existing subnet for network isolation
  • Flexible Ingress: Optional external/internal ingress configuration per app
  • Customizable Workload Profiles: Support for Consumption and Dedicated profiles
  • Tagging Strategy: Global and per-app/app job tag support

Prerequisites

  • Existing Azure Container Registry (ACR)
  • Existing Virtual Network with available subnet
  • Appropriate Azure permissions to create resources

Requirements

Name Version
azapi 2.7.0
azuread ~> 3.0
azurerm ~> 4.0
random ~> 3.0

Providers

Name Version
azapi 2.7.0
azuread 3.8.0
azurerm 4.60.0

Modules

No modules.

Resources

Name Type
azapi_resource.auths resource
azuread_application.apps resource
azuread_application_password.app_secrets resource
azuread_service_principal.apps resource
azurerm_container_app.apps resource
azurerm_container_app_environment.env resource
azurerm_container_app_environment_storage.share_storage resource
azurerm_container_app_job.jobs resource
azurerm_log_analytics_workspace.log_storage resource
azurerm_storage_account.auth_tokens_account resource
azurerm_storage_container.auth_tokens resource
azurerm_user_assigned_identity.aca_identity resource
azurerm_storage_account_blob_container_sas.auth_storage_sas data source

Inputs

Name Description Type Default Required
acr_login_server The login server URL of the ACR (e.g., myacr.azurecr.io) string n/a yes
additional_workload_profiles Additional workload profiles for the Container App Environment, in addition to the always-created Consumption profile
map(object({
workload_profile_type = string
minimum_count = optional(number)
maximum_count = optional(number)
}))
{} no
app_config The app config file
object({
organization = string
environment_name = string
purpose = string
system_storage = string
storage_tiers = string
queue_writer = optional(string, null)
})
n/a yes
app_secrets The configurations for the apps
map(object({
env_secrets = optional(map(string), {})
}))
n/a yes
apps [d] The configurations for the apps
map(object({
container_config = object({
name = string
image = string
cpu = number
memory = string
min_replicas = optional(number, 1)
max_replicas = optional(number, 5)
dicomweb_app_name = optional(string, null)
webapi_app_name = optional(string, null)
})
ingress_config = optional(object({
external_enabled = bool
target_port = number
transport = optional(string, "auto")
cors_app_name = optional(string, null)
}))
share_mounts = optional(map(object({
mount = string
name = string
storage_account = string
access_key = optional(string, null)
quota_gb = number
})), {})
revision_mode = optional(string, "Single")
authentication = optional(object({
identifier_uri = optional(bool, null)
web = optional(bool, null)
global_validation = object({
unauthenticatedClientAction = string
redirectToProvider = optional(string, null)
})
}), null)
user_managed_storage_app = optional(object({
id = string
client_id = string
}), null)
user_managed_queue_app = optional(object({
id = string
client_id = string
}), null)
otel_exporter = optional(string, null)
workload_profile = optional(string, null)
env_vars = optional(map(string), {})
# env var name -> Key Vault secret name (referenced from the env's Key Vault)
key_vault_secrets = optional(map(string), {})
tags = map(string)
}))
n/a yes
base_name [d] The base name used in naming resources string n/a yes
gateway_subnet_id The ID of the subnet for the application gateway string n/a yes
jobs [d] The configurations for the jobs
map(object({
container_config = object({
name = string
image = string
cpu = number
memory = string
args = optional(list(string), [])
})
share_mounts = optional(map(object({
mount = string
name = string
storage_account = string
access_key = optional(string, null)
quota_gb = number
})), {})
replica_timeout = number
trigger_schedule = optional(string, null)
user_managed_storage_app = optional(object({
id = string
client_id = string
}), null)
user_managed_queue_app = optional(object({
id = string
client_id = string
}), null)
otel_exporter = optional(string, null)
workload_profile = optional(string, null)
env_vars = optional(map(string), {})
# env var name -> Key Vault secret name (referenced from the env's Key Vault)
key_vault_secrets = optional(map(string), {})
tags = map(string)
}))
{} no
key_vault_reader_identity_id Resource ID of a user-assigned identity with Key Vault Secrets User on the vault; attached to apps/jobs that use Key Vault-referenced secrets string null no
key_vault_uri Base URI of the Key Vault for Key Vault-referenced secrets (e.g. https://.vault.azure.net) string null no
location [d] Azure region string n/a yes
resource_group_name [d] The name of the resource group to deploy into string n/a yes
storage_access_keys Map of mount IDs to storage account access keys (sensitive, provided separately to avoid tainting for_each) map(string) {} no
subnet_id [d] The ID of the subnet for VNET integration string n/a yes
tags Tags to apply to resources map(string) {} no
tenant_id Entra Tenant ID string n/a yes
workload_profile Default workload profile name assigned to container apps and jobs string "Consumption" no

[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.

Outputs

Name Description
app_fqdns The FQDNs of the deployed applications
container_app_environment_default_domain The default domain of the container app environment
container_app_environment_id The ID of the compute container app environment
identity_id The Resource ID of the Managed Identity, used for ACR registry authentication
identity_principal_id The Principal ID of the Managed Identity, used for RBAC assignments

Example Usage

Basic Example

module "container_apps" {
  source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/compute?tag=<module-version>"

  base_name           = "myapp-prod"
  resource_group_name = "rg-container-apps"
  location            = "eastus"
  subnet_id           = azurerm_subnet.aca_subnet.id
  gateway_subnet_id   = azurerm_subnet.gateway_subnet.id
  acr_login_server    = "myacr.azurecr.io"
  tenant_id           = data.azurerm_client_config.current.tenant_id

  app_config = {
    organization     = "My Org"
    environment_name = "myapp-prod"
    purpose          = "production"
    system_storage   = jsonencode([])
    storage_tiers    = jsonencode([{ name = "hot", blockStore = [{}] }])
  }

  app_secrets = {
    api = { env_secrets = { API_DB_URL = "postgres://..." } }
  }

  apps = {
    api = {
      container_config = {
        name   = "api-container"
        image  = "myacr.azurecr.io/api:latest"
        cpu    = 0.5
        memory = "1Gi"
      }
      ingress_config = {
        external_enabled = true
        target_port      = 8080
        transport        = "http"
      }
      revision_mode = "Single"
      tags = {
        component = "api"
      }
    }
    worker = {
      container_config = {
        name   = "worker-container"
        image  = "myacr.azurecr.io/worker:latest"
        cpu    = 1.0
        memory = "2Gi"
      }
      ingress_config = null  # No ingress for background worker
      revision_mode = "Single"
      tags = {
        component = "worker"
      }
    }
  }

  tags = {
    environment = "production"
    managed_by  = "opentofu"
  }
}

With Additional Workload Profiles

module "container_apps" {
  source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/compute?tag=<module-version>"

  base_name           = "myapp-staging"
  resource_group_name = "rg-container-apps"
  location            = "eastus"
  subnet_id           = azurerm_subnet.aca_subnet.id
  gateway_subnet_id   = azurerm_subnet.gateway_subnet.id
  acr_login_server    = "myacr.azurecr.io"

  # tenant_id, app_config and app_secrets are required; see the basic example
  tenant_id   = data.azurerm_client_config.current.tenant_id
  app_config  = local.app_config
  app_secrets = local.app_secrets

  additional_workload_profiles = {
    d4 = {
      workload_profile_type = "D4"
      minimum_count         = 1
      maximum_count         = 2
    }
  }

  apps = {
    webapp = {
      container_config = {
        name   = "webapp"
        image  = "myacr.azurecr.io/webapp:v1.2.3"
        cpu    = 2.0
        memory = "4Gi"
      }
      workload_profile = "d4"
      ingress_config = {
        external_enabled = true
        target_port      = 443
        transport        = "http2"
      }
      revision_mode = "Multiple"
      tags          = {}
    }
  }
}

Apps Object Structure

Each entry in the apps map should have the following structure:

{
  container_config = {
    name              = string          # Container name
    image             = string          # Full image path (e.g., myacr.azurecr.io/app:tag)
    cpu               = number          # CPU cores (0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
    memory            = string          # Memory (e.g., "0.5Gi", "1Gi", "2Gi", "4Gi")
    min_replicas      = optional(number, 1)   # Minimum running replicas
    max_replicas      = optional(number, 5)   # Maximum running replicas
    dicomweb_app_name = optional(string, null) # Key of the DICOMweb app (accepted, not yet consumed)
    webapi_app_name   = optional(string, null) # Literal string used for API_BACKEND_URL / API_HOST_NAME
  }
  ingress_config = optional(object({
    external_enabled = bool             # true for external, false for internal only
    target_port      = number           # Container port to expose
    transport        = optional(string, "auto") # "auto", "http", or "http2"
    cors_app_name    = optional(string, null) # Key of the app allowed as a CORS origin
  }))
  share_mounts = optional(map(object({
    mount           = string            # Sub-path of /mnt where the share is mounted
    name            = string            # SMB file share name
    storage_account = string            # Storage account hosting the share
    access_key      = optional(string, null) # Fallback key; var.storage_access_keys wins when set
    quota_gb        = number            # Share quota in GB
  })), {})
  revision_mode = optional(string, "Single") # "Single" or "Multiple"
  authentication = optional(object({
    identifier_uri = optional(bool, null)
    web            = optional(bool, null)
    global_validation = object({
      unauthenticatedClientAction = string # e.g. "RedirectToLoginPage" or "Return401"
      redirectToProvider          = optional(string, null)
    })
  }), null)                              # Non-null creates an Entra app + ACA auth config
  user_managed_storage_app = optional(object({
    id        = string                  # User-assigned identity resource ID attached for storage access
    client_id = string                  # Its client ID, emitted as AZURE_CLIENT_ID
  }), null)
  user_managed_queue_app = optional(object({
    id        = string                  # Queue publisher identity resource ID, attached alongside the storage identity
    client_id = string                  # Its client ID; not emitted as an env var
  }), null)
  otel_exporter     = optional(string, null) # OTLP endpoint; non-null adds collector env vars
  workload_profile  = optional(string, null) # Environment workload profile name; falls back to var.workload_profile
  env_vars          = optional(map(string), {}) # plain env var name -> value
  key_vault_secrets = optional(map(string), {}) # env var name -> Key Vault secret name
  tags              = map(string)       # App-specific tags
}

Notes

1: share_mounts only declares which existing SMB share an app consumes — the share itself is created by the filestore module, and quota_gb is carried for type consistency with it. Mounts resolving to the same storage_account + name pair collapse into one azurerm_container_app_environment_storage registration, so the access key for it comes from var.storage_access_keys when that is populated and from the mount's own access_key otherwise.

2: authentication requires tenant_id. When non-null it creates an azuread_application, azuread_service_principal, and azuread_application_password for that app, and an azapi_resource ACA auth config that uses the module's auth-token blob container (auth-token-storage-sas) as its token store. identifier_uri and web are declared but not consumed by the module.

Jobs Object Structure

Each entry in the jobs map should have the following structure:

{
  container_config = {
    name   = string                     # Container name
    image  = string                     # Full image path (e.g., myacr.azurecr.io/job:tag)
    cpu    = number                     # CPU cores
    memory = string                     # Memory (e.g., "1Gi", "2Gi")
    args   = optional(list(string), []) # Container arguments
  }
  share_mounts = optional(map(object({
    mount           = string            # Sub-path of /mnt where the share is mounted
    name            = string            # SMB file share name
    storage_account = string            # Storage account hosting the share
    access_key      = optional(string, null) # Fallback key; var.storage_access_keys wins when set
    quota_gb        = number            # Share quota in GB
  })), {})
  replica_timeout  = number             # Execution timeout in seconds
  trigger_schedule = optional(string, null) # Cron expression; null creates a manual-trigger-only job
  user_managed_storage_app = optional(object({
    id        = string                  # App's user assigned identity ID
    client_id = string                  # Emitted as AZURE_CLIENT_ID
  }), null)
  user_managed_queue_app = optional(object({
    id        = string                  # Queue publisher identity resource ID, attached alongside the storage identity
    client_id = string                  # Its client ID; not emitted as an env var
  }), null)
  otel_exporter    = optional(string, null) # OTLP endpoint; non-null adds collector env vars
  workload_profile = optional(string, null) # Environment workload profile name; falls back to var.workload_profile
  env_vars         = optional(map(string), {}) # plain env var name -> value
  key_vault_secrets = optional(map(string), {}) # env var name -> Key Vault secret name
  tags              = map(string)       # Job-specific tags
}

Queue Writer Configuration

app_config.queue_writer is optional. When set, it is rendered as the queueWriter section of the Kastoria configuration document shared by every app and job, which is how the event notification job learns where to publish. It carries an account name and a managed identity client ID only — never a connection string. That client ID is the queue module's publisher identity, which the publishing job must carry through user_managed_queue_app. AZURE_CLIENT_ID stays owned by user_managed_storage_app, so a job carrying both identities uses the storage identity for storage and the publisher for the queue. See Wiring the Queue Writer for the expected shape.

Environment Variables and Key Vault-Referenced Secrets

Apps and jobs can receive secret environment variables referenced from the environment's Azure Key Vault instead of literal values. Each app/job config accepts an optional key_vault_secrets map — a sibling of env_vars — mapping env var name → Key Vault secret name:

apps = {
  kastoria = {
    env_vars = {
      DICOMWEB_URL = "https://..."
    }
    key_vault_secrets = {
      MY_SECRET_VAR = "my-secret-var" # env var name -> KV secret name
    }
  }
}

jobs = {
  stdyproc = {
    key_vault_secrets = {
      JOB_SECRET_VAR = "job-secret-var"
    }
  }
}

Requirements: - key_vault_uri and key_vault_reader_identity_id must be set on the module when any app or job uses key_vault_secrets (enforced by a lifecycle precondition). - The reader identity must hold Key Vault Secrets User on the vault. It is attached only to apps/jobs that actually declare key_vault_secrets, so other apps see no identity drift. - The secret must already exist in the vault before the app revision or job is created (set it out-of-band with az keyvault secret set).

The secret is referenced with a versionless URI (${key_vault_uri}/secrets/<name>), so the secret value never enters OpenTofu state, plan output, or PR comments. Rotation behavior differs between apps and jobs:

  • Apps: ACA detects a new secret version within ~30 minutes and automatically restarts active revisions referencing it. Scaled-to-zero apps get the latest value on cold start. OpenTofu shows no diff for KV-side rotation (benign). Pin a versioned URI in the env config if change control is required.
  • Jobs: no long-running revisions — each execution resolves the versionless URI at start, so every run picks up the latest value immediately.

Valid CPU and Memory Combinations

CPU (cores) Memory Options
0.25 0.5Gi
0.5 1.0Gi
0.75 1.5Gi
1.0 2.0Gi
1.25 2.5Gi
1.5 3.0Gi
1.75 3.5Gi
2.0 4.0Gi

Resources Created

  • azurerm_user_assigned_identity - Shared managed identity for ACR pull authentication
  • azurerm_log_analytics_workspace - Log workspace backing the Container App Environment
  • azurerm_container_app_environment - Container App Environment with VNET integration
  • azurerm_container_app_environment_storage - One per unique SMB share referenced by share_mounts
  • azurerm_container_app - One per entry in apps
  • azurerm_container_app_job - One per entry in jobs
  • azurerm_storage_account / azurerm_storage_container - Blob container holding container app auth tokens
  • azuread_application / azuread_service_principal / azuread_application_password - One per app declaring authentication
  • azapi_resource - Container app auth config, one per app declaring authentication

The AcrPull role assignment is not created by this module — see ACR Integration.

Network Requirements

The subnet provided via subnet_id must meet these requirements:

  • Minimum CIDR: /27 (32 IPs) for Consumption, larger for Dedicated profiles
  • Delegated to Microsoft.App/environments
  • No NSG rules blocking required ports (unless using custom NSG configuration)

Example subnet configuration:

resource "azurerm_subnet" "aca_subnet" {
  name                 = "snet-container-apps"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.1.0/27"]

  delegation {
    name = "aca-delegation"
    service_delegation {
      name = "Microsoft.App/environments"
      actions = [
        "Microsoft.Network/virtualNetworks/subnets/join/action",
      ]
    }
  }
}

ACR Integration

The module creates a User-Assigned Managed Identity (identity_id / identity_principal_id outputs) which every container app and job uses to authenticate image pulls from acr_login_server.

The module does not grant the AcrPull role — assign it in the environment so the scope stays under the environment's provider configuration:

resource "azurerm_role_assignment" "acr_pull" {
  scope                = data.azurerm_container_registry.acr.id
  role_definition_name = "AcrPull"
  principal_id         = module.compute_apps.identity_principal_id
}

Revision Modes

  • Single: Only one revision is active at a time. New deployments replace the previous revision.
  • Multiple: Multiple revisions can be active simultaneously, useful for blue/green deployments or traffic splitting.

Tagging Strategy

Tags are merged with the following priority: 1. Per-app tags (from apps[*].tags) 2. Global tags (from tags variable)

Per-app tags override global tags with the same key.

Security Considerations

  • Uses Managed Identity for ACR authentication (no credentials stored)
  • Uses (a separate) Managed Identity for Key Vault read authentication (no credentials stored) where desired
  • Supports VNET integration for network isolation
  • Each app can have internal-only ingress (set external_enabled = false)
  • Consider using Azure Key Vault references for sensitive environment variables

Limitations

  • All apps share the same Container App Environment and Managed Identity
  • Single container per app (no sidecar support yet)
  • No scaling rules (min/max replicas only)
  • No health probe configuration

Available versions

  • v0.9.1