Serverless container groups, secure-by-default β a deeply-typed wrapper around
azurerm_container_groupwith 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.
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
containerslist; each withcpu/memoryrequests and optionalcpu_limit/memory_limit - π§± Init containers β run-to-completion containers that execute before the main containers start
- β€οΈ Liveness & readiness probes β
http_getorexec, fully typed - πΎ Volumes β Azure File share,
secret,empty_dir, andgit_repo, mountable into any container - π Group networking β
ip_address_type(Public/Private/None), exposed ports, DNS label β FQDN, or VNet integration viasubnet_ids - π Identity-based registry pulls β
user_assigned_identity_idon 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, portprotocol, probescheme,log_type, and identitytypeare 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, aSpotgroup with a public IP β is a plan-time error pointing at the exact path, not a failedapply90 seconds into a deployment.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
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.
- Terraform
>= 1.12.0(required for the cross-variablevalidationblocks that enforcePrivate β subnet_idsandSpot β ip_address_type = None) - AzureRM
>= 4.0, < 5.0β validated against the 4.77.0 binary schema - Underlying resource provider:
Microsoft.ContainerInstance(API2025-09-01)
βΉοΈ 4.x schema notes surfaced during authoring:
- Nearly every argument is
ForceNew. Onlytagsupdates in place β any change to containers, networking, identity, volumes, etc. destroys and recreates the group. Treat a Container Group as immutable infrastructure.network_profile_idis DEPRECATED (andcomputed) β superseded bysubnet_ids. This module surfaces it for legacy state only; do not set it on new groups.cpu/memoryarenumberin 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_analyticsis Required-within-the-block βworkspace_idandworkspace_keyare both required whendiagnosticsis present.- Container-group has no inline private endpoint β private connectivity is via
subnet_ids(VNet injection), notazurerm_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_iddeprecation, already reflected above.
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
azurerm_container_group.thisβ the group (name, RG, location,os_type,sku, IP/DNS/VNet, priority, CMK, zones)container(dynamic, 1..N) β one block pervar.containersentryports(0..N),liveness_probe/readiness_probe(0..1, each withhttp_get0..1),security(0..1),volume(0..N, each withgit_repo0..1)init_container(dynamic, 0..N) βsecurity+volumechildren, no ports/probesidentity(dynamic, 0..1) β present whenvar.identity != nullimage_registry_credential(dynamic, 0..N) β private registry authexposed_port(dynamic, 0..N) β group-level port exposuredns_config(dynamic, 0..1) β custom resolver configdiagnostics(dynamic, 0..1) βlog_analytics(1) β log streamingtimeouts(dynamic, 0..1) β present only whenvar.timeoutsis 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.
.
βββ 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
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_typedefaults to provider behavior (Public). For internal-only workloads setip_address_type = "None"; for VNet-internal set"Private"+subnet_ids.
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) |
The design decisions and provider gotchas that save the next engineer hours:
- A Container Group is immutable. Almost every field is
ForceNewβ onlytagsupdates 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 onecontainerblock is supported, and Windows groups cannot join a virtual network. The module doesn't hard-block this (the provider does), but theos_typedescription flags it. PrivateandSpotare network-coupled β enforced at plan time.ip_address_type = "Private"requiressubnet_ids(a cross-fieldvalidation).priority = "Spot"requiresip_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 onlocalhostregardless 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_labelFQDN (and an init container to re-warm) for a stable name. secure_environment_variablesoverenvironment_variablesfor secrets. Both are maps;secure_environment_variablesis markedsensitiveat the schema level and is not returned in plan output or the API GET. Plainenvironment_variablesare visible. The module renders empty maps asnullto avoid perpetual diffs.- Identity-based registry pulls beat username/password. Set
image_registry_credentials[*].user_assigned_identity_idand grant that identityAcrPullon the registry β no long-lived password in state. The wholeimage_registry_credentialsinput issensitive. - 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;secretvalues must be base64-encoded. security.privilege_enabledis Confidential-Linux only. It is honored only whenos_type = "Linux"andsku = "Confidential". ACI otherwise does not permit privileged container operations β don't depend on root-directory writes.- CMK needs an identity.
key_vault_key_idpairs withkey_vault_user_assigned_identity_id; that identity needsGet,UnwrapKey,WrapKey, andGetRotationPolicyon the key. If omitted, the Azure Container Instance Service RP principal is used instead. main.tfis a total renderer. No business logic β optional single blocks render on!= null, repeating blocks render over lists (empty list = zero blocks), andtry(x, null)on every optional nested field keeps the renderer total: it omits an absent key rather than erroring.
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'resensitiveand never echoed. Plainenvironment_variablesare 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 ofhttp_getfor 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 = "." }
}
]
}
]
}
β οΈ Eachvolumeis exactly one of empty_dir / git_repo / secret / storage-account.secretvalues 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
portsorprobesβ 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
AcrPullon the registry. The wholeimage_registry_credentialsinput issensitive.
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_idsis required whentypeincludesUserAssignedβ 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 usedns_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_idandworkspace_keyare 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_enabledis ignored unlessos_type = "Linux"andsku = "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. DefaultStandardSpotCoresquota 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, andGetRotationPolicyon 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 }- 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 {}| 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.
- The type is the contract. Every block is a typed
objectwithoptionaldefaults 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, portprotocol, probescheme,log_type, and identitytypeare enforced withvalidation {}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 andsecure_environment_variablesover 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.tfas a total renderer.dynamicblocks for every optional/repeating block,try(x, null)on every optional nested field, empty maps rendered asnullto avoid perpetual diffs.
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.
terraform fmt -check
terraform validate
tflintOutputs:
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"- Plan wants to destroy/recreate the whole group β Expected. Almost every argument is ForceNew β only
tagsupdates 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β Setsubnet_idsto the target subnet(s). VNet groups also need a NAT gateway for outbound traffic and cannot usedns_name_labelor managed identity.priority = 'Spot' requires ip_address_type = 'None'β Spot groups support neither public nor private IP (nor GPU nor availability zones). Setip_address_type = "None".identity.identity_ids is required when identity.type includes 'UserAssigned'β Supply at least one user-assigned identity ID, or usetype = "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 forStandardSpotCores, or usepriority = "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).Windowsgroup 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 identityAcrPulland reference it viaimage_registry_credentials[*].user_assigned_identity_id. For username/password, verifyserverhas no scheme (myacr.azurecr.io, nothttps://...). - Volume mount fails /
InvalidVolumeβ Eachvolumemust specify exactly one ofempty_dir,git_repo,secret, or a full storage-account mount (all three ofshare_name+storage_account_name+storage_account_key).secretvalues must be base64-encoded. security.privilege_enabledseems ignored β It's honored only whenos_type = "Linux"andsku = "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_labelFQDN, 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_variablesvalues not visible β Expected and intended β they'resensitive, redacted in plan output and not returned by the API GET. Use them (not plainenvironment_variables) for secrets.- Hit a hard limit β Per group: 60 containers, 20 volumes, 5 ports per IP. These are non-increasable.
- Terraform Registry β
azurerm_container_group(resource schema:container,init_container,identity,image_registry_credential,diagnostics,dns_configblocks) - 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."