Skip to content

microsoftexpert/tf-mod-azure-container-instance

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ“¦ Azure Container Instance Terraform Module

Serverless container groups, secure-by-default β€” a deeply-typed wrapper around azurerm_container_group with multi-container groups, init containers, liveness/readiness probes, Azure File / secret / git-repo / empty-dir volumes, identity-based registry pulls, managed identity, VNet integration, CMK encryption, and Log Analytics diagnostics β€” every enum and cross-field rule validated at plan time. Built for AzureRM v4.x.

Terraform AzureRM module type


🧩 Overview

This module creates a single Azure Container Group (azurerm_container_group) β€” the top-level resource in Azure Container Instances (ACI), a collection of containers scheduled on one host that share a lifecycle, local network, and storage (conceptually a Kubernetes pod):

  • 🐳 One or more containers β€” required containers list; each with cpu/memory requests and optional cpu_limit/memory_limit
  • 🧱 Init containers β€” run-to-completion containers that execute before the main containers start
  • ❀️ Liveness & readiness probes β€” http_get or exec, fully typed
  • πŸ’Ύ Volumes β€” Azure File share, secret, empty_dir, and git_repo, mountable into any container
  • πŸ”Œ Group networking β€” ip_address_type (Public/Private/None), exposed ports, DNS label β†’ FQDN, or VNet integration via subnet_ids
  • πŸ”‘ Identity-based registry pulls β€” user_assigned_identity_id on registry credentials (preferred over username/password)
  • πŸͺͺ Managed identity β€” SystemAssigned, UserAssigned, or both
  • πŸ”’ CMK encryption β€” key_vault_key_id + key_vault_user_assigned_identity_id
  • πŸ“Š Diagnostics β€” stream container logs to a Log Analytics workspace
  • βœ… Plan-time validation β€” os_type, sku, ip_address_type, restart_policy, priority, dns_name_label_reuse_policy, port protocol, probe scheme, log_type, and identity type are all enum-checked; the hard cross-field rules (Private β‡’ subnet_ids, Spot β‡’ ip_address_type = None, UserAssigned β‡’ identity_ids) fail the plan, not the apply

πŸ’‘ Why it matters: the type is the contract. A malformed nested input β€” a misspelled probe key, a "TCP/IP" protocol, a Spot group with a public IP β€” is a plan-time error pointing at the exact path, not a failed apply 90 seconds into a deployment.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


πŸ—ΊοΈ Where this fits in the family

This module is a standalone compute resource: it takes a resource group/location and, optionally, a pre-created managed identity, and hands its network coordinates (ip_address, fqdn) and identity onward to DNS, gateway, RBAC, and diagnostics modules.

flowchart LR
 RG["tf-mod-azure-resource-group<br/>(resource group Β· location)"]
 MI["tf-mod-azure-managed-identity<br/>(id β†’ identity_ids, UserAssigned)"]
 THIS["tf-mod-azure-container-instance<br/>(THIS MODULE)"]
 RA["tf-mod-azure-role-assignment"]
 DIAG["tf-mod-azure-monitor-diagnostic-settings"]
 DNS["tf-mod-azure-dns-a-record / dns-cname-record"]
 AGW["tf-mod-azure-application-gateway"]

 RG -->|"resource_group_name Β· location"| THIS
 MI -->|"id (identity.identity_ids)"| THIS
 THIS -->|"id (scope)"| RA
 THIS -->|"identity_principal_id (principal_id)"| RA
 THIS -->|"id (target_resource_id)"| DIAG
 THIS -->|"ip_address Β· fqdn"| DNS
 THIS -->|"ip_address Β· fqdn"| AGW

 style THIS fill:#0078D4,color:#fff
Loading

This module consumes resource_group_name / location (from tf-mod-azure-resource-group) and, optionally, identity.identity_ids (from tf-mod-azure-managed-identity); it emits ip_address / fqdn (DNS records, Application Gateway) and id / identity_principal_id (role assignments, diagnostics) β€” see the Typical wiring table.


βœ… Provider / Versions

  • Terraform >= 1.12.0 (required for the cross-variable validation blocks that enforce Private β‡’ subnet_ids and Spot β‡’ ip_address_type = None)
  • AzureRM >= 4.0, < 5.0 β€” validated against the 4.77.0 binary schema
  • Underlying resource provider: Microsoft.ContainerInstance (API 2025-09-01)

ℹ️ 4.x schema notes surfaced during authoring:

  • Nearly every argument is ForceNew. Only tags updates in place β€” any change to containers, networking, identity, volumes, etc. destroys and recreates the group. Treat a Container Group as immutable infrastructure.
  • network_profile_id is DEPRECATED (and computed) β€” superseded by subnet_ids. This module surfaces it for legacy state only; do not set it on new groups.
  • cpu / memory are number in 4.x (e.g. 0.5, 1.5) β€” not the quoted strings ("0.5") some older examples and the Container App module use.
  • diagnostics.log_analytics is Required-within-the-block β€” workspace_id and workspace_key are both required when diagnostics is present.
  • Container-group has no inline private endpoint β€” private connectivity is via subnet_ids (VNet injection), not azurerm_private_endpoint.
  • No container_group-specific breaking change appears in the azurerm 4.0 upgrade changelog; the 3.xβ†’4.x churn was the network_profile_id deprecation, already reflected above.

🧬 What this module builds

A single primary resource, rendered as a total projection of the typed inputs:

flowchart TD
 subgraph mod["tf-mod-azure-container-instance"]
 THIS["azurerm_container_group.this<br/>(keystone)<br/>group β€” os_type Β· sku Β· IP/DNS/VNet Β· priority Β· CMK Β· zones"]
 CTR["container<br/>(dynamic Β· 1..N, one per containers entry)"]
 PORTS["ports<br/>(dynamic Β· 0..N)"]
 LIVE["liveness_probe<br/>(dynamic Β· 0..1) β†’ http_get"]
 READY["readiness_probe<br/>(dynamic Β· 0..1) β†’ http_get"]
 SEC["security<br/>(dynamic Β· 0..1)"]
 VOL["volume<br/>(dynamic Β· 0..N) β†’ git_repo"]
 INIT["init_container<br/>(dynamic Β· 0..N) β†’ security Β· volume"]
 ID["identity<br/>(dynamic Β· 0..1 when var.identity != null)"]
 IRC["image_registry_credential<br/>(dynamic Β· 0..N)"]
 EP["exposed_port<br/>(dynamic Β· 0..N)"]
 DNSC["dns_config<br/>(dynamic Β· 0..1)"]
 DIAG2["diagnostics<br/>(dynamic Β· 0..1) β†’ log_analytics"]
 TO["timeouts<br/>(dynamic Β· 0..1)"]
 end

 THIS --> CTR
 CTR --> PORTS
 CTR --> LIVE
 CTR --> READY
 CTR --> SEC
 CTR --> VOL
 THIS --> INIT
 THIS --> ID
 THIS --> IRC
 THIS --> EP
 THIS --> DNSC
 THIS --> DIAG2
 THIS --> TO

 style THIS fill:#0078D4,color:#fff
Loading
  • azurerm_container_group.this β€” the group (name, RG, location, os_type, sku, IP/DNS/VNet, priority, CMK, zones)
  • container (dynamic, 1..N) β€” one block per var.containers entry
  • ports (0..N), liveness_probe / readiness_probe (0..1, each with http_get 0..1), security (0..1), volume (0..N, each with git_repo 0..1)
  • init_container (dynamic, 0..N) β€” security + volume children, no ports/probes
  • identity (dynamic, 0..1) β€” present when var.identity != null
  • image_registry_credential (dynamic, 0..N) β€” private registry auth
  • exposed_port (dynamic, 0..N) β€” group-level port exposure
  • dns_config (dynamic, 0..1) β€” custom resolver config
  • diagnostics (dynamic, 0..1) β†’ log_analytics (1) β€” log streaming
  • timeouts (dynamic, 0..1) β€” present only when var.timeouts is non-empty

No data source is instantiated: a creation module never reads the resource it creates. Lookups of pre-existing groups belong in the caller.


πŸ“ Module Structure

.
β”œβ”€β”€ providers.tf # Terraform & azurerm version pins (no provider{} block)
β”œβ”€β”€ variables.tf # Deeply-typed inputs, optional defaults, validation{} (enums + cross-field)
β”œβ”€β”€ main.tf # azurerm_container_group.this (total renderer)
β”œβ”€β”€ outputs.tf # id, name, resource_group_name, location, ip_address, fqdn, identity_*
β”œβ”€β”€ SCOPE.md # In-scope resource + emits table + provider gotchas
└── README.md

βš™οΈ Quick Start

provider "azurerm" { features {} }

module "aci" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
 name = "aci-hello-prod-01"
 resource_group_name = module.rg.name
 location = module.rg.location
 os_type = "Linux"

 containers = [
 {
 name = "hello"
 image = "mcr.microsoft.com/azuredocs/aci-helloworld:latest"
 cpu = 0.5
 memory = 1.5
 ports = [{ port = 443, protocol = "TCP" }]
 }
 ]

 tags = {
 environment = "prod"
 owner = "platform-team"
 }
}

πŸ”’ ip_address_type defaults to provider behavior (Public). For internal-only workloads set ip_address_type = "None"; for VNet-internal set "Private" + subnet_ids.


πŸ”Œ Typical wiring

Every output in outputs.tf gets a row β€” derived from SCOPE.md.

This module output Feeds into
id tf-mod-azure-role-assignment (scope), tf-mod-azure-monitor-diagnostic-settings (target_resource_id)
name Any module referencing the group by name
resource_group_name Sibling modules deployed in the same resource group
location Sibling modules / tf-mod-azure-monitor-diagnostic-settings co-location
ip_address tf-mod-azure-dns-a-record (records), tf-mod-azure-application-gateway (backend pool address)
fqdn tf-mod-azure-dns-cname-record (record target), tf-mod-azure-application-gateway (backend host)
identity_principal_id tf-mod-azure-role-assignment (principal_id), Key Vault RBAC / ACR AcrPull grants
identity_tenant_id Cross-tenant identity references (rarely consumed directly)

🧠 Architecture Notes

The design decisions and provider gotchas that save the next engineer hours:

  • A Container Group is immutable. Almost every field is ForceNew β€” only tags updates in place. Editing an image tag, an env var, a port, or scaling CPU destroys and recreates the group (and releases its public IP/FQDN). Design pipelines around recreate, not in-place mutate; for rolling, blue/green semantics, look at Container Apps or AKS instead.
  • Windows = single container, no VNet. Multi-container groups are Linux only. With os_type = "Windows" only one container block is supported, and Windows groups cannot join a virtual network. The module doesn't hard-block this (the provider does), but the os_type description flags it.
  • Private and Spot are network-coupled β€” enforced at plan time. ip_address_type = "Private" requires subnet_ids (a cross-field validation). priority = "Spot" requires ip_address_type = "None" (Spot supports neither public nor private IP, nor GPU, nor availability zones). Both rules fail the plan with an actionable message instead of a late API rejection.
  • Containers talk over localhost; the group shares one IP. All containers in a group share the external IP, exposed ports, and DNS label. Within the group they reach each other on localhost regardless of exposed ports. A port must be opened on both the container (ports) and, if you want it externally reachable on a specific subset, the group (exposed_ports).
  • The public IP is ephemeral. A restart (including platform-maintenance restarts) can change the group's IP. Never hardcode it downstream β€” front it with Application Gateway for a stable IP, or rely on the dns_name_label FQDN (and an init container to re-warm) for a stable name.
  • secure_environment_variables over environment_variables for secrets. Both are maps; secure_environment_variables is marked sensitive at the schema level and is not returned in plan output or the API GET. Plain environment_variables are visible. The module renders empty maps as null to avoid perpetual diffs.
  • Identity-based registry pulls beat username/password. Set image_registry_credentials[*].user_assigned_identity_id and grant that identity AcrPull on the registry β€” no long-lived password in state. The whole image_registry_credentials input is sensitive.
  • Volumes are exactly-one-of. Per volume, set exactly one of: empty_dir, git_repo, secret, or a full storage-account mount (share_name + storage_account_name + storage_account_key). The provider rejects mixed or partial volumes at apply; secret values must be base64-encoded.
  • security.privilege_enabled is Confidential-Linux only. It is honored only when os_type = "Linux" and sku = "Confidential". ACI otherwise does not permit privileged container operations β€” don't depend on root-directory writes.
  • CMK needs an identity. key_vault_key_id pairs with key_vault_user_assigned_identity_id; that identity needs Get, UnwrapKey, WrapKey, and GetRotationPolicy on the key. If omitted, the Azure Container Instance Service RP principal is used instead.
  • main.tf is a total renderer. No business logic β€” optional single blocks render on != null, repeating blocks render over lists (empty list = zero blocks), and try(x, null) on every optional nested field keeps the renderer total: it omits an absent key rather than erroring.

πŸ“š Example Library (copy-paste)

1️⃣ Minimal β€” a single Linux container
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-min-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  containers = [
    { name = "app", image = "mcr.microsoft.com/azuredocs/aci-helloworld:latest", cpu = 0.5, memory = 1.5 }
  ]
}
# => no ports exposed; reachable only container-to-container within the group

πŸ’‘ A group needs a minimum of 1 CPU and 1 GB total across its containers.

2️⃣ Public web app β€” exposed port + DNS label β†’ FQDN
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-web-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"
  ip_address_type     = "Public"
  dns_name_label      = "casey-aci-web-01" # => casey-aci-web-01.eastus2.azurecontainer.io

  containers = [
    {
      name   = "web"
      image  = "mcr.microsoft.com/azuredocs/aci-helloworld:latest"
      cpu    = 1
      memory = 1.5
      ports  = [{ port = 80, protocol = "TCP" }, { port = 443, protocol = "TCP" }]
    }
  ]
}

⚠️ The public IP is ephemeral β€” it can change on restart. Use the FQDN, or front with Application Gateway for a stable IP.

3️⃣ Multi-container sidecar β€” app + log shipper (Linux only)
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-sidecar-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  containers = [
    {
      name   = "app"
      image  = "myacr.azurecr.io/app:1.4.2"
      cpu    = 1
      memory = 2
      ports  = [{ port = 8080, protocol = "TCP" }]
    },
    {
      name   = "log-shipper"
      image  = "myacr.azurecr.io/fluent-bit:2.2"
      cpu    = 0.5
      memory = 0.5
    }
  ]
}
# Containers reach each other on localhost (app -> localhost:8080).

πŸ’‘ Group resources are the sum of container requests β€” this group is allocated 1.5 CPU / 2.5 GB.

4️⃣ Environment variables β€” plain + secure (sensitive)
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-env-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  containers = [
    {
      name   = "api"
      image  = "myacr.azurecr.io/api:2.0"
      cpu    = 1
      memory = 1.5
      environment_variables = {
        LOG_LEVEL = "info"
        REGION    = "eastus2"
      }
      secure_environment_variables = {
        DB_PASSWORD = var.db_password # not shown in plan or API GET
        API_KEY     = var.api_key
      }
    }
  ]
}

πŸ”’ Put secrets in secure_environment_variables β€” they're sensitive and never echoed. Plain environment_variables are visible.

5️⃣ Health probes β€” liveness + readiness via http_get
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-probes-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  containers = [
    {
      name   = "api"
      image  = "myacr.azurecr.io/api:2.0"
      cpu    = 1
      memory = 1.5
      ports  = [{ port = 8080, protocol = "TCP" }]

      liveness_probe = {
        initial_delay_seconds = 15
        period_seconds        = 10
        failure_threshold     = 3
        http_get              = { path = "/healthz", port = 8080, scheme = "Http" }
      }
      readiness_probe = {
        initial_delay_seconds = 5
        period_seconds        = 5
        success_threshold     = 1
        http_get              = { path = "/ready", port = 8080, scheme = "Http", http_headers = { "X-Probe" = "readiness" } }
      }
    }
  ]
}

πŸ’‘ Use exec = ["cat", "/tmp/ready"] instead of http_get for command-based probes.

6️⃣ Volumes β€” Azure File, secret, empty_dir, git_repo
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-vol-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  containers = [
    {
      name   = "app"
      image  = "myacr.azurecr.io/app:1.0"
      cpu    = 1
      memory = 2
      volume = [
        {
          name                 = "data"
          mount_path           = "/mnt/data"
          share_name           = "appdata"
          storage_account_name = module.sa.name
          storage_account_key  = module.sa.primary_access_key # sensitive
        },
        {
          name       = "config"
          mount_path = "/etc/config"
          secret     = { "appsettings.json" = base64encode(file("appsettings.json")) } # base64!
        },
        { name = "scratch", mount_path = "/tmp/scratch", empty_dir = true },
        {
          name       = "src"
          mount_path = "/src"
          git_repo   = { url = "https://github.com/org/repo.git", revision = "main", directory = "." }
        }
      ]
    }
  ]
}

⚠️ Each volume is exactly one of empty_dir / git_repo / secret / storage-account. secret values must be base64-encoded.

7️⃣ Init containers β€” run-to-completion setup before main containers
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-init-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  init_containers = [
    {
      name                         = "migrate"
      image                        = "myacr.azurecr.io/migrate:1.0"
      commands                     = ["/bin/sh", "-c", "./run-migrations.sh"]
      secure_environment_variables = { DB_CONN = var.db_conn }
    }
  ]

  containers = [
    { name = "app", image = "myacr.azurecr.io/app:1.0", cpu = 1, memory = 1.5 }
  ]
}
# Init containers run to completion (in order) before main containers start.

πŸ’‘ Init containers have no ports or probes β€” they're for setup work, not serving.

8️⃣ Private registry β€” identity-based pull (preferred) and username/password
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-reg-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  identity = { type = "UserAssigned", identity_ids = [module.aci_identity.id] }

  image_registry_credentials = [
    # Preferred: identity-based, no password in state
    { server = "myacr.azurecr.io", user_assigned_identity_id = module.aci_identity.id },
    # Fallback: username/password (sensitive)
    { server = "ghcr.io", username = "casey-bot", password = var.ghcr_token }
  ]

  containers = [
    { name = "app", image = "myacr.azurecr.io/app:1.0", cpu = 1, memory = 1.5 }
  ]
}

πŸ”’ Grant the user-assigned identity AcrPull on the registry. The whole image_registry_credentials input is sensitive.

9️⃣ Managed identity β€” system + user assigned
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-mi-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  identity = {
    type         = "SystemAssigned, UserAssigned"
    identity_ids = [module.aci_identity.id]
  }

  containers = [
    { name = "app", image = "myacr.azurecr.io/app:1.0", cpu = 1, memory = 1.5 }
  ]
}
# module.aci.identity_principal_id is non-null -> use it in a role assignment.

⚠️ identity_ids is required when type includes UserAssigned β€” enforced at plan time. Managed identity is not supported for VNet-deployed groups.

πŸ”Ÿ VNet-integrated β€” Private IP into a subnet
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-vnet-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"
  ip_address_type     = "Private"
  subnet_ids          = [module.subnet_aci.id] # REQUIRED for Private

  containers = [
    { name = "worker", image = "myacr.azurecr.io/worker:1.0", cpu = 1, memory = 2 }
  ]
}

⚠️ VNet groups need a NAT gateway for outbound connectivity, cannot use dns_name_label, and cannot use managed identity. Windows is not supported in a VNet.

1️⃣1️⃣ Log Analytics diagnostics
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-diag-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  diagnostics = {
    log_analytics = {
      workspace_id  = module.law.workspace_id
      workspace_key = module.law.primary_shared_key # sensitive
      log_type      = "ContainerInsights"           # or "ContainerInstanceLogs"
    }
  }

  containers = [
    { name = "app", image = "myacr.azurecr.io/app:1.0", cpu = 1, memory = 1.5 }
  ]
}

ℹ️ workspace_id and workspace_key are both required within the block.

1️⃣2️⃣ Confidential SKU β€” privileged container on Confidential compute
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-conf-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"
  sku                 = "Confidential"

  containers = [
    {
      name     = "secure-app"
      image    = "myacr.azurecr.io/secure:1.0"
      cpu      = 2
      memory   = 4
      security = { privilege_enabled = true } # honored only on Linux + Confidential
    }
  ]
}

πŸ”’ security.privilege_enabled is ignored unless os_type = "Linux" and sku = "Confidential".

1️⃣3️⃣ Spot priority β€” cheap, interruptible batch (no public/private IP)
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-spot-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"
  priority            = "Spot"
  ip_address_type     = "None"  # REQUIRED for Spot
  restart_policy      = "Never" # batch: run to completion

  containers = [
    { name = "batch", image = "myacr.azurecr.io/batch:1.0", cpu = 4, memory = 8 }
  ]
}

⚠️ Spot groups can be preempted at any time and support neither public/private IP, GPU, nor availability zones. Default StandardSpotCores quota is low (10 on most subscriptions, 0 on some).

1️⃣4️⃣ CMK encryption β€” customer-managed key
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-cmk-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"

  key_vault_key_id                    = module.kv_key.id
  key_vault_user_assigned_identity_id = module.cmk_identity.id

  containers = [
    { name = "app", image = "myacr.azurecr.io/app:1.0", cpu = 1, memory = 1.5 }
  ]
}

πŸ”’ The identity needs Get, UnwrapKey, WrapKey, and GetRotationPolicy on the key. Omit the identity to use the ACI service RP principal.

1️⃣5️⃣ Hardened / secure-by-default (explicit)
module "aci" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  name                = "aci-secure-01"
  resource_group_name = module.rg.name
  location            = "eastus2"
  os_type             = "Linux"
  ip_address_type     = "Private" # no public exposure
  subnet_ids          = [module.subnet_aci.id]
  restart_policy      = "OnFailure"

  identity = { type = "UserAssigned", identity_ids = [module.aci_identity.id] }

  image_registry_credentials = [
    { server = "myacr.azurecr.io", user_assigned_identity_id = module.aci_identity.id } # no password
  ]

  diagnostics = {
    log_analytics = {
      workspace_id  = module.law.workspace_id
      workspace_key = module.law.primary_shared_key
      log_type      = "ContainerInsights"
    }
  }

  containers = [
    {
      name                         = "app"
      image                        = "myacr.azurecr.io/app:1.0"
      cpu                          = 1
      memory                       = 2
      secure_environment_variables = { DB_PASSWORD = var.db_password }
    }
  ]

  tags = { environment = "prod", data_class = "confidential" }
}

πŸ”’ Private-only networking, identity-based registry pull (no password in state), secrets via secure_environment_variables, and centralized log collection.

1️⃣6️⃣ for_each at scale β€” a fleet of workers from a map
locals {
  workers = {
    east = { location = "eastus2", cpu = 2 }
    west = { location = "westus2", cpu = 4 }
  }
}

module "aci" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
  for_each = local.workers

  name                = "aci-worker-${each.key}"
  resource_group_name = module.rg.name
  location            = each.value.location
  os_type             = "Linux"

  containers = [
    { name = "worker", image = "myacr.azurecr.io/worker:1.0", cpu = each.value.cpu, memory = each.value.cpu * 2 }
  ]
}
# Stable keys: renaming a map key destroys & recreates that group (name is ForceNew).

⚠️ Don't deploy many groups with the same name across regions in one run β€” it trips ACI's fraud-detection quota logic. Deploy distinct names, or one region at a time.

πŸ—οΈ End-to-end composition (finale) β€” RG β†’ identity β†’ ACI β†’ diagnostics + DNS + RBAC
provider "azurerm" { features {} }

module "rg" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-resource-group?ref=v1.0.0"
 name = "rg-aci-prod"
 location = "eastus2"
}

module "aci_identity" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-managed-identity?ref=v1.0.0"
 name = "id-aci-prod"
 resource_group_name = module.rg.name
 location = module.rg.location
}

module "aci" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-container-instance?ref=v1.0.0"
 name = "aci-app-prod"
 resource_group_name = module.rg.name
 location = module.rg.location
 os_type = "Linux"
 ip_address_type = "Public"
 dns_name_label = "casey-aci-app-prod"

 identity = { type = "UserAssigned", identity_ids = [module.aci_identity.id] }
 image_registry_credentials = [{ server = "myacr.azurecr.io", user_assigned_identity_id = module.aci_identity.id }]

 containers = [
 {
 name = "app"
 image = "myacr.azurecr.io/app:1.4.2"
 cpu = 1
 memory = 2
 ports = [{ port = 443, protocol = "TCP" }]
 }
 ]

 tags = { environment = "prod" }
}

# Grant the group's identity AcrPull on the registry:
module "acr_pull" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-role-assignment?ref=v1.0.0"
 scope = module.acr.id
 role_definition_name = "AcrPull"
 principal_id = module.aci.identity_principal_id # ← consumes the identity principal ID
}

# Stream platform logs to Log Analytics:
module "aci_diag" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-monitor-diagnostic-settings?ref=v1.0.0"
 name = "diag-aci-app-prod"
 target_resource_id = module.aci.id # ← consumes the group ID
 log_analytics_workspace_id = module.law.id
}

# Stable DNS name for the group:
module "aci_dns" {
 source = "git::https://github.com/microsoftexpert/tf-mod-azure-dns-cname-record?ref=v1.0.0"
 name = "app"
 zone_name = module.dns_zone.name
 resource_group_name = module.rg.name
 record = module.aci.fqdn # ← consumes the FQDN
}

output "aci_fqdn" { value = module.aci.fqdn }

πŸ“¦ Inputs (high-level)

  • Core (required): name, resource_group_name, location, os_type ("Linux"|"Windows"), containers[] (β‰₯ 1)
  • Group SKU / scheduling: sku ("Confidential"|"Dedicated"|"Standard", default "Standard"), priority ("Regular"|"Spot"), restart_policy ("Always"|"Never"|"OnFailure"), zones[]
  • Networking: ip_address_type ("Public"|"Private"|"None"), subnet_ids[], dns_name_label, dns_name_label_reuse_policy, exposed_ports[], dns_config
  • Identity / registry: identity { type, identity_ids[] }, image_registry_credentials[] (sensitive)
  • Encryption (CMK): key_vault_key_id, key_vault_user_assigned_identity_id
  • Observability: diagnostics { log_analytics {...} } (sensitive)
  • Legacy: network_profile_id (DEPRECATED)
  • Workload: containers[], init_containers[]
  • Universal tail: tags, timeouts { create, read, update, delete }
Full object schemas for the nested inputs (mirroring variables.tf)
identity = object({
 type = string # "SystemAssigned" | "UserAssigned" | "SystemAssigned, UserAssigned"
 identity_ids = optional(list(string)) # required when type includes UserAssigned
}) # default null

image_registry_credentials = list(object({
 server = string
 username = optional(string)
 password = optional(string) # sensitive
 user_assigned_identity_id = optional(string) # preferred over username/password
})) # default [] (whole input is sensitive)

exposed_ports = list(object({
 port = optional(number)
 protocol = optional(string, "TCP") # "TCP" | "UDP"
})) # default []

dns_config = object({
 nameservers = list(string) # required
 search_domains = optional(list(string))
 options = optional(list(string))
}) # default null

diagnostics = object({ # whole input is sensitive
 log_analytics = object({
 workspace_id = string # required
 workspace_key = string # required, sensitive
 log_type = optional(string) # "ContainerInsights" | "ContainerInstanceLogs"
 metadata = optional(map(string), {})
 })
}) # default null

containers = list(object({ # >= 1 required
 name = string
 image = string
 cpu = number # cores, e.g. 0.5
 memory = number # GB, e.g. 1.5
 cpu_limit = optional(number)
 memory_limit = optional(number)
 commands = optional(list(string))
 environment_variables = optional(map(string), {})
 secure_environment_variables = optional(map(string), {}) # sensitive
 ports = optional(list(object({
 port = optional(number)
 protocol = optional(string, "TCP") # "TCP" | "UDP"
 })), [])
 liveness_probe = optional(object({...probe... }))
 readiness_probe = optional(object({...probe... }))
 security = optional(object({ privilege_enabled = bool })) # Linux + Confidential only
 volume = optional(list(object({
 name = string
 mount_path = string
 read_only = optional(bool, false)
 empty_dir = optional(bool)
 storage_account_name = optional(string)
 storage_account_key = optional(string) # sensitive
 share_name = optional(string)
 secret = optional(map(string)) # sensitive; base64-encoded values
 git_repo = optional(object({ url = string, directory = optional(string), revision = optional(string) }))
 })), [])
}))

# probe (liveness_probe / readiness_probe):
probe = object({
 exec = optional(list(string))
 initial_delay_seconds = optional(number)
 period_seconds = optional(number)
 failure_threshold = optional(number)
 success_threshold = optional(number)
 timeout_seconds = optional(number)
 http_get = optional(object({
 path = optional(string)
 port = optional(number)
 scheme = optional(string) # "Http" | "Https"
 http_headers = optional(map(string), {})
 }))
})

init_containers = list(object({ # default []
 name = string
 image = string
 commands = optional(list(string))
 environment_variables = optional(map(string), {})
 secure_environment_variables = optional(map(string), {}) # sensitive
 security = optional(object({ privilege_enabled = bool }))
 volume = optional(list(object({...same as container volume... })), [])
}))

timeouts = object({
 create = optional(string)
 read = optional(string)
 update = optional(string)
 delete = optional(string)
}) # default {}

🧾 Outputs

Output Description Notes
id Container Group resource ID always present
name Container Group name always present
resource_group_name Resource Group name (passthrough) always present
location Azure region always present
ip_address Allocated group IP ⚠️ null when ip_address_type = "None" (try(..., null)); ephemeral β€” can change on restart
fqdn FQDN derived from dns_name_label ⚠️ null when no DNS label (try(..., null))
identity_principal_id System-assigned identity principal ID ⚠️ null unless a system-assigned identity is enabled (try(..., null))
identity_tenant_id System-assigned identity tenant ID ⚠️ null unless a system-assigned identity is enabled (try(..., null))

ℹ️ No outputs are sensitive β€” this module emits identifiers and network coordinates only. Secret inputs (image_registry_credentials, diagnostics, secure_environment_variables, volume secrets) are never exported.


🧱 Design Principles

  • The type is the contract. Every block is a typed object with optional defaults and baked-in safe values; a fat-fingered nested key is a plan-time type error, not a runtime surprise.
  • Closed sets are validated. os_type, sku, ip_address_type, restart_policy, priority, dns_name_label_reuse_policy, port protocol, probe scheme, log_type, and identity type are enforced with validation {} blocks β€” plus the three hard cross-field rules the provider enforces at apply (Private β‡’ subnet_ids, Spot β‡’ None, UserAssigned β‡’ identity_ids).
  • Secure by default. Secret-bearing inputs are sensitive; the README and descriptions steer callers to identity-based registry pulls and secure_environment_variables over passwords and plain env vars.
  • Small blast radius. One group per call; role assignments, diagnostics, DNS records, and gateways live in sibling modules and consume this group by id / fqdn / ip_address / identity_principal_id.
  • main.tf as a total renderer. dynamic blocks for every optional/repeating block, try(x, null) on every optional nested field, empty maps rendered as null to avoid perpetual diffs.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output

⚠️ Pin the version. Reference the module by immutable tag β€” ?ref=v1.0.0 β€” never a branch. Branch references produce non-deterministic plans.


πŸ§ͺ Testing

terraform fmt -check
terraform validate
tflint

πŸ’¬ Example Output

Outputs:

id = "/subscriptions/.../resourceGroups/rg-aci-prod/providers/Microsoft.ContainerInstance/containerGroups/aci-app-prod"
name = "aci-app-prod"
resource_group_name = "rg-aci-prod"
location = "eastus2"
ip_address = "20.81.x.x" # null when ip_address_type = "None"
fqdn = "casey-aci-app-prod.eastus2.azurecontainer.io"
identity_principal_id = "00000000-0000-0000-0000-000000000000" # null unless system-assigned identity enabled
identity_tenant_id = "11111111-1111-1111-1111-111111111111"

πŸ” Troubleshooting

  • Plan wants to destroy/recreate the whole group β†’ Expected. Almost every argument is ForceNew β€” only tags updates in place. Changing an image tag, env var, port, CPU, or any block recreates the group (and releases its IP/FQDN). Design for recreate; use Container Apps/AKS if you need in-place rolling updates.
  • ip_address_type = 'Private' requires subnet_ids to be set β†’ Set subnet_ids to the target subnet(s). VNet groups also need a NAT gateway for outbound traffic and cannot use dns_name_label or managed identity.
  • priority = 'Spot' requires ip_address_type = 'None' β†’ Spot groups support neither public nor private IP (nor GPU nor availability zones). Set ip_address_type = "None".
  • identity.identity_ids is required when identity.type includes 'UserAssigned' β†’ Supply at least one user-assigned identity ID, or use type = "SystemAssigned".
  • ContainerGroupQuotaReached / StandardSpotCores... exceeded β†’ Spot quota defaults are low (10 on Pay-As-You-Go, 100 on EA, 0 on some). File a quota request for StandardSpotCores, or use priority = "Regular".
  • container group quota 'ContainerGroups' exceeded... Limit: '0', Usage: '0' β†’ Fraud-detection trip from deploying multiple groups with the same name across regions in one run. Use distinct names, or deploy one region at a time.
  • ServiceUnavailable (409)... not available in the location β†’ Regional capacity, often for a Big Container Group (> 4 CPU or 16 GB). Retry, lower the resource request, or pick another region. Hard max per group is 31 CPU / 240 GB (180 GB Confidential).
  • Windows group rejects a second container β†’ Multi-container groups are Linux only. Use one container for Windows, or split into separate groups. Windows also can't join a VNet.
  • Image pull fails / Unauthorized β†’ For ACR, grant the registry identity AcrPull and reference it via image_registry_credentials[*].user_assigned_identity_id. For username/password, verify server has no scheme (myacr.azurecr.io, not https://...).
  • Volume mount fails / InvalidVolume β†’ Each volume must specify exactly one of empty_dir, git_repo, secret, or a full storage-account mount (all three of share_name + storage_account_name + storage_account_key). secret values must be base64-encoded.
  • security.privilege_enabled seems ignored β†’ It's honored only when os_type = "Linux" and sku = "Confidential". ACI otherwise disallows privileged operations β€” don't rely on root-directory writes.
  • Public IP changed after a restart β†’ Expected: the group IP is ephemeral and can change on platform-maintenance restarts. Use the dns_name_label FQDN, or front with Application Gateway for a static IP.
  • Image won't start / pull times out β†’ Container images must be ≀ 15 GB; larger images cause unexpected behavior. Mount an Azure File share for large assets instead.
  • secure_environment_variables values not visible β†’ Expected and intended β€” they're sensitive, redacted in plan output and not returned by the API GET. Use them (not plain environment_variables) for secrets.
  • Hit a hard limit β†’ Per group: 60 containers, 20 volumes, 5 ports per IP. These are non-increasable.

πŸ”— Related Docs

  • Terraform Registry β€” azurerm_container_group (resource schema: container, init_container, identity, image_registry_credential, diagnostics, dns_config blocks)
  • Terraform Registry β€” azurerm_role_assignment (RBAC grants β€” tf-mod-azure-role-assignment)
  • Terraform Registry β€” azurerm_monitor_diagnostic_setting (platform logs β€” tf-mod-azure-monitor-diagnostic-settings)
  • Microsoft Learn β€” Container groups in Azure Container Instances (multi-container, networking, storage, resource allocation)
  • Microsoft Learn β€” Resource availability & quota limits for ACI (per-group maximums and hard limits)
  • Microsoft Learn β€” Best practices and considerations for Azure Container Instances (NAT gateway, ephemeral IP, reserved ports, image size)
  • Microsoft Learn β€” Azure Container Instances states (Running / Stopped / Pending / Succeeded / Failed vs. restart policy)
  • Microsoft Learn β€” Terraform AzureRM provider version history 4.0.0 β†’ current (upgrade notes)

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages