IaC Azure

Terraform Module: Azure Communication Services — one resource for Email, SMS and chat

Quick take — Build a reusable azurerm Terraform module for Azure Communication Services: linked data-location, a managed Email Communication domain, sender usernames and SMTP-ready outputs for hashicorp/azurerm ~> 4.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "communication_services" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-communication-services?ref=v1.0.0"

  name                = "..."  # Name of the Communication Service resource (3-63 chars,…
  resource_group_name = "..."  # Resource group that holds the ACS resources.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Communication Services (ACS) is Microsoft’s fully managed CPaaS layer — the same engineering that powers Teams, exposed as REST/SDK primitives for Email, SMS, chat, voice and video calling. You provision one azurerm_communication_service resource, pick a data_location (where customer data is stored at rest, e.g. United States, Europe, India), and you get a connection string plus access keys your apps use to send mail or texts. There is no VM, no SMTP server, no capacity planning.

The catch is that a bare Communication Service does nothing on its own. To actually send email you need an Email Communication Service (azurerm_email_communication_service), at least one domain under it (azurerm_email_communication_service_domain — Azure-managed or your own verified domain), optional sender usernames (azurerm_email_communication_service_domain_sender_username), and finally a link between the email domain and the Communication Service (azurerm_communication_service_email_domain_association). That is five resources with strict ordering and a non-obvious association at the end — the kind of thing every team gets wrong the first time.

Wrapping it in a module collapses that whole graph into a handful of variables. Callers say “I want an ACS instance with an Azure-managed email domain and a donotreply sender” and the module wires the dependency chain, enforces a valid data_location, and hands back the connection string and a ready-to-use MailFrom address.

When to use it

If you only ever need a single hand-clicked test domain, the portal is faster. The module pays off the moment ACS appears in more than one environment.

Module structure

terraform-module-azure-communication-services/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

# Core Communication Services resource. data_location is immutable and
# governs where customer content is stored at rest.
resource "azurerm_communication_service" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  data_location       = var.data_location

  tags = var.tags
}

# Email Communication Service — parent for any email domains.
# Note: data_location here must match the Communication Service above.
resource "azurerm_email_communication_service" "this" {
  count = var.enable_email ? 1 : 0

  name                = coalesce(var.email_service_name, "${var.name}-email")
  resource_group_name = var.resource_group_name
  data_location       = var.data_location

  tags = var.tags
}

# The sending domain. "AzureManaged" gives an instant *.azurecomm.net
# domain; "CustomerManaged" expects a domain you own and will verify.
resource "azurerm_email_communication_service_domain" "this" {
  count = var.enable_email ? 1 : 0

  name             = var.domain_management == "AzureManaged" ? "AzureManagedDomain" : var.custom_domain_name
  email_service_id = azurerm_email_communication_service.this[0].id

  domain_management = var.domain_management

  # Engagement tracking only applies to managed/custom domains; harmless to set.
  user_engagement_tracking = var.user_engagement_tracking

  tags = var.tags
}

# Optional named senders, e.g. donotreply@<domain>. AzureManaged domains
# always expose a default "donotreply"; these add friendly extras.
resource "azurerm_email_communication_service_domain_sender_username" "this" {
  for_each = var.enable_email ? { for s in var.sender_usernames : s.username => s } : {}

  name          = each.value.username
  email_service_domain_id = azurerm_email_communication_service_domain.this[0].id
  username      = each.value.username
  display_name  = each.value.display_name
}

# Bind the verified email domain to the Communication Service so the
# Email SDK can send from it. This is the step most setups forget.
resource "azurerm_communication_service_email_domain_association" "this" {
  count = var.enable_email ? 1 : 0

  communication_service_id = azurerm_communication_service.this.id
  email_service_domain_id  = azurerm_email_communication_service_domain.this[0].id
}

variables.tf

variable "name" {
  description = "Name of the Communication Service resource. Globally scoped within the subscription; use a stable, env-suffixed name."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]$", var.name))
    error_message = "name must be 3-63 chars, alphanumeric or hyphen, and may not start or end with a hyphen."
  }
}

variable "resource_group_name" {
  description = "Resource group that will hold the Communication Services resources."
  type        = string
}

variable "data_location" {
  description = "Region where customer data is stored at rest. Immutable after creation."
  type        = string
  default     = "United States"

  validation {
    condition = contains([
      "Africa", "Asia Pacific", "Australia", "Brazil", "Canada", "Europe",
      "France", "Germany", "India", "Japan", "Korea", "Norway",
      "Switzerland", "UAE", "UK", "United States"
    ], var.data_location)
    error_message = "data_location must be a supported ACS geography (e.g. 'India', 'Europe', 'United States')."
  }
}

variable "enable_email" {
  description = "Create the Email Communication Service, a sending domain, senders and the domain association."
  type        = bool
  default     = true
}

variable "email_service_name" {
  description = "Optional explicit name for the Email Communication Service. Defaults to '<name>-email'."
  type        = string
  default     = null
}

variable "domain_management" {
  description = "How the sending domain is managed: 'AzureManaged' (instant *.azurecomm.net) or 'CustomerManaged' (your own verified domain)."
  type        = string
  default     = "AzureManaged"

  validation {
    condition     = contains(["AzureManaged", "CustomerManaged"], var.domain_management)
    error_message = "domain_management must be either 'AzureManaged' or 'CustomerManaged'."
  }
}

variable "custom_domain_name" {
  description = "FQDN of the domain to send from when domain_management is 'CustomerManaged' (e.g. mail.kloudvin.com). Ignored for AzureManaged."
  type        = string
  default     = null

  validation {
    condition     = var.custom_domain_name == null || can(regex("^([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$", var.custom_domain_name))
    error_message = "custom_domain_name must be a valid FQDN, e.g. mail.kloudvin.com."
  }
}

variable "user_engagement_tracking" {
  description = "Enable open/click engagement tracking on the email domain."
  type        = bool
  default     = false
}

variable "sender_usernames" {
  description = "List of additional MailFrom sender usernames to register on the domain."
  type = list(object({
    username     = string
    display_name = string
  }))
  default = []
}

variable "tags" {
  description = "Tags applied to all created resources."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Communication Service."
  value       = azurerm_communication_service.this.id
}

output "name" {
  description = "Name of the Communication Service."
  value       = azurerm_communication_service.this.name
}

output "primary_connection_string" {
  description = "Primary connection string for the Communication Service. Use in app config / Key Vault for Email and SMS SDKs."
  value       = azurerm_communication_service.this.primary_connection_string
  sensitive   = true
}

output "primary_key" {
  description = "Primary access key for the Communication Service."
  value       = azurerm_communication_service.this.primary_key
  sensitive   = true
}

output "email_service_id" {
  description = "Resource ID of the Email Communication Service, or null when email is disabled."
  value       = var.enable_email ? azurerm_email_communication_service.this[0].id : null
}

output "email_domain_name" {
  description = "The provisioned sending domain (e.g. the *.azurecomm.net FQDN or your custom domain)."
  value       = var.enable_email ? azurerm_email_communication_service_domain.this[0].from_sender_domain : null
}

output "mail_from_address" {
  description = "Ready-to-use default MailFrom address (donotreply@<domain>) when email is enabled."
  value       = var.enable_email ? "donotreply@${azurerm_email_communication_service_domain.this[0].from_sender_domain}" : null
}

How to use it

module "communication_services" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-communication-services?ref=v1.0.0"

  name                = "acs-kloudvin-prod"
  resource_group_name = azurerm_resource_group.platform.name

  # Keep customer data in-region for an India-hosted product.
  data_location = "India"

  enable_email             = true
  domain_management        = "AzureManaged"
  user_engagement_tracking = true

  sender_usernames = [
    { username = "donotreply", display_name = "KloudVin (no-reply)" },
    { username = "alerts",     display_name = "KloudVin Alerts" },
  ]

  tags = {
    environment = "prod"
    owner       = "platform-team"
    costcenter  = "cc-1042"
  }
}

# Downstream: stash the connection string in Key Vault so app teams
# never see the raw key, and surface the MailFrom for the notification app.
resource "azurerm_key_vault_secret" "acs_connection_string" {
  name         = "acs-connection-string"
  value        = module.communication_services.primary_connection_string
  key_vault_id = azurerm_key_vault.platform.id
}

resource "azurerm_linux_web_app" "notifier" {
  name                = "app-notifier-prod"
  resource_group_name = azurerm_resource_group.platform.name
  location            = azurerm_resource_group.platform.location
  service_plan_id     = azurerm_service_plan.platform.id

  site_config {}

  app_settings = {
    # App reads the secret via Key Vault reference at runtime.
    "ACS_CONNECTION_STRING" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.acs_connection_string.versionless_id})"
    "ACS_MAIL_FROM"         = module.communication_services.mail_from_address
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/communication_services/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-communication-services?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/communication_services && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Name of the Communication Service resource (3-63 chars, alphanumeric/hyphen).
resource_group_name string Yes Resource group that holds the ACS resources.
data_location string "United States" No Region for data at rest; immutable. Validated against the supported ACS geographies.
enable_email bool true No Create the Email Communication Service, sending domain, senders and domain association.
email_service_name string null No Explicit Email Communication Service name; defaults to <name>-email.
domain_management string "AzureManaged" No AzureManaged (instant *.azurecomm.net) or CustomerManaged (your verified domain).
custom_domain_name string null No FQDN to send from when domain_management = "CustomerManaged".
user_engagement_tracking bool false No Enable open/click engagement tracking on the email domain.
sender_usernames list(object({ username, display_name })) [] No Additional MailFrom sender usernames to register on the domain.
tags map(string) {} No Tags applied to all created resources.

Outputs

Name Description
id Resource ID of the Communication Service.
name Name of the Communication Service.
primary_connection_string Primary connection string (sensitive) for Email/SMS SDKs.
primary_key Primary access key (sensitive).
email_service_id Resource ID of the Email Communication Service, or null when email is disabled.
email_domain_name The provisioned sending domain FQDN.
mail_from_address Ready-to-use donotreply@<domain> MailFrom address.

Enterprise scenario

A retail bank runs a multi-tenant notification platform that fans out statement-ready alerts, OTPs and fraud warnings. Their compliance line requires Indian customer data to stay in-region, so the platform team stamps this module per business unit with data_location = "India" and an AzureManaged domain for lower environments, switching to CustomerManaged with mail.<bu>.bank.example in production once DNS verification lands. Every instance pushes its connection string straight into a per-BU Key Vault via the downstream pattern above, so no application team ever handles a raw ACS key, and the SMS connection string is reused for the OTP service without a second resource.

Best practices

TerraformAzureCommunication ServicesModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading