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({ |
{} |
no |
| app_config | The app config file | object({ |
n/a | yes |
| app_secrets | The configurations for the apps | map(object({ |
n/a | yes |
| apps [d] | The configurations for the apps | map(object({ |
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({ |
{} |
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:// |
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_mountsonly declares which existing SMB share an app consumes — the share itself is created by the filestore module, andquota_gbis carried for type consistency with it. Mounts resolving to the samestorage_account+namepair collapse into oneazurerm_container_app_environment_storageregistration, so the access key for it comes fromvar.storage_access_keyswhen that is populated and from the mount's ownaccess_keyotherwise.2:
authenticationrequirestenant_id. When non-null it creates anazuread_application,azuread_service_principal, andazuread_application_passwordfor that app, and anazapi_resourceACA auth config that uses the module's auth-token blob container (auth-token-storage-sas) as its token store.identifier_uriandwebare 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 authenticationazurerm_log_analytics_workspace- Log workspace backing the Container App Environmentazurerm_container_app_environment- Container App Environment with VNET integrationazurerm_container_app_environment_storage- One per unique SMB share referenced byshare_mountsazurerm_container_app- One per entry inappsazurerm_container_app_job- One per entry injobsazurerm_storage_account/azurerm_storage_container- Blob container holding container app auth tokensazuread_application/azuread_service_principal/azuread_application_password- One per app declaringauthenticationazapi_resource- Container app auth config, one per app declaringauthentication
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