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
- You send transactional email (password resets, invoices, alerts) and want a SendGrid-free, first-party Azure path billed on your existing EA/MCA.
- You need SMS or OTP delivery and want the messaging connection string managed as code alongside the rest of your landing zone.
- You run many app teams and want every one to get an identical, policy-compliant ACS instance — same data residency, same diagnostic settings — without copy-pasting five resources.
- You are bootstrapping fast and want the Azure-managed domain (
xxxx.azurecomm.net) for non-prod, then flip a variable to a custom verified domain for production, reusing the same module. - You want connection strings and keys flowing into Key Vault rather than living in app settings or a developer’s clipboard.
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 config — live/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 config — live/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
- Treat
data_locationas permanent. It cannot change after creation and must match between the Communication Service and the Email Communication Service — pick the correct residency geography (India,Europe, etc.) up front or you will be recreating resources and losing your*.azurecomm.netsubdomain. - Never output keys to state-in-the-clear. Both
primary_connection_stringandprimary_keyare markedsensitive; route them into Key Vault and consume via@Microsoft.KeyVault(...)references rather than plain app settings, and rotate using the secondary key for zero-downtime rollover. - Don’t forget the domain association. A verified email domain that is not linked via
azurerm_communication_service_email_domain_associationwill silently fail to send — the module’senable_emailpath wires this for you, so keep it on whenever you intend to send mail. - Use AzureManaged for speed, CustomerManaged for deliverability. The
*.azurecomm.netdomain is rate-limited and brand-anonymous; for production transactional mail move to a custom domain so SPF/DKIM/DMARC align with your brand and inbox placement improves. - Control cost at the edges, not the resource. ACS itself is free to provision and billed purely on usage (per email, per SMS segment); keep spend down by enabling
user_engagement_trackingonly where you act on the data and by consolidating to one shared instance per environment instead of one per app. - Tag and name for residency. Encode environment and (where it matters) region into
name, and apply consistenttagsfor cost center and owner so finance can attribute per-message charges back to the team that sent them.