Points one name inside a private DNS zone at another name. Targets
hashicorp/azurerm ~> 4.0.
- 🔗 Points one name at another. One target — the argument is
record, singular. - 🚫 Rejects
"@", because DNS forbids a CNAME at the zone apex. - 🔁 Detects a self-referential loop, the one target mistake a single module can actually catch.
- 🧭 Emits
effective_target, because a bare label resolves relative to the zone and is not what it literally says.
💡 Why it matters: Azure never validates a CNAME's target, so an alias pointing at nothing applies cleanly and fails only when something tries to resolve it. This module checks the two things it genuinely can — that the target is a hostname rather than an address, and that it is not this record's own name — and reports the rest.
If this module saves you time, a little support goes a long way:
- ⭐ Star the repository on GitHub.
- 🔗 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
This diagram is shared with terraform-azurerm-private-dns-a-record and terraform-azurerm-private-dns-aaaa-record, authored alongside it. This module is the ...-private-dns-cname-record node.
flowchart TB
RG["terraform-azurerm-resource-group"]
ZONE["terraform-azurerm-private-dns-zone"]
A["terraform-azurerm-private-dns-a-record"]
AAAA["terraform-azurerm-private-dns-aaaa-record"]
CNAME["terraform-azurerm-private-dns-cname-record"]
RA["terraform-azurerm-role-assignments"]
VNET["a virtual network, linked by the zone module"]
CLIENT["a client resolving the name"]
RG -->|"name feeds resource_group_name"| ZONE
ZONE -->|"name feeds zone_name, a NAME not an ID"| A
ZONE -->|"name feeds zone_name"| AAAA
ZONE -->|"name feeds zone_name"| CNAME
A -->|"name feeds record, the alias target"| CNAME
A -->|"id is a valid per-record RBAC scope"| RA
ZONE -.->|"virtual_network_links"| VNET
VNET -.->|"resolves only if the zone is linked"| CLIENT
A -.->|"counted by number_of_record_sets"| ZONE
classDef batch fill:#0078D4,stroke:#004578,color:#ffffff
classDef anchor fill:#004578,stroke:#002d4d,color:#ffffff
classDef ext fill:#f0f3f7,stroke:#9aa7b5,color:#1b2733
class A,AAAA,CNAME batch
class ZONE anchor
class RG,RA,VNET,CLIENT ext
flowchart TB
NAME["name<br/>force new, never the apex on this type"]
ZONE["zone_name<br/>resource_group_name<br/>both force new, both NAMES not IDs"]
REC["record<br/>ONE target, singular<br/>the structural difference from A and AAAA"]
TTL["ttl<br/>required, and the ONLY one the provider range checks"]
T["azurerm_private_dns_cname_record.this"]
OUT["id, a valid RBAC scope<br/>fqdn, computed<br/>expected_fqdn, available at plan time"]
RESOLVE["effective_target<br/>target_is_fully_qualified<br/>target_is_inside_this_zone"]
LOOP["points_at_itself<br/>the one target mistake a single module CAN detect"]
RULE["a_cname_cannot_coexist_with_another_record_at_the_same_name<br/>the_target_is_never_validated_by_azure"]
VER["the_provider_documentation_describes_the_next_major_version"]
NAME --> T
ZONE --> T
REC --> T
TTL --> T
T --> OUT
REC --> RESOLVE
ZONE --> RESOLVE
RESOLVE --> LOOP
NAME --> RULE
REC --> RULE
ZONE --> VER
classDef keystone fill:#004578,stroke:#002d4d,color:#ffffff
classDef io fill:#0078D4,stroke:#004578,color:#ffffff
classDef note fill:#f0f3f7,stroke:#9aa7b5,color:#1b2733
class T keystone
class NAME,ZONE,REC,TTL,OUT io
class RESOLVE,LOOP,RULE,VER note
| Resource | Cardinality | Notes |
|---|---|---|
azurerm_private_dns_cname_record.this |
one record set per name, per zone | Exactly one target, and never at the apex |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures the provider, including the mandatory features {} block. |
Schema notes that bite — each verified against the pinned provider's compiled schema and its source at that version:
- 🔴 The provider's published documentation describes the NEXT major version. It documents a single
private_dns_zone_id; the pinned provider exposeszone_nameplusresource_group_name. - 🔴 The argument is
record, singular — a scalar string, not a set. This is the one structural difference from the A and AAAA modules, and it is real: a CNAME has exactly one target by definition. - 🔴 The same
ttlfield gets THREE different treatments across the seven private DNS record types. No validator at all on A and AAAA; a range beginning at 0 here; a range beginning at 1 on PTR, MX, SRV and TXT. This resource is the only one whose validator explicitly permits a zero TTL. - 🔴 A CNAME cannot coexist with any other record at the same name, so it can never sit at the zone apex. This module rejects
"@". ⚠️ Azure does not validate the target. An alias pointing at a name that does not exist is created successfully.⚠️ namemust be lower-case, enforced by the provider here — the AAAA resource carries the same comment and no validator.⚠️ resource_group_nameis case-diff-suppressed, so a capitalisation-only change produces no plan diff.⚠️ Nolocation. Private DNS zones and their records are global.
| Principal | Permission | Scope | Why |
|---|---|---|---|
| The Terraform identity | Private DNS Zone Contributor |
The zone, its resource group, or this record set | The purpose-built role: Microsoft.Network/privateDnsZones/* |
| The Terraform identity | Microsoft.Network/privateDnsZones/CNAME/* |
The zone | A narrower custom role — Microsoft's own worked example is precisely a CNAME-only administrator |
| The Terraform identity | Microsoft.Network/privateDnsZones/read |
The zone | "enabling you to see the zone in which the CNAME is being created" |
✅ Private DNS Zone Contributor, not Network Contributor. Microsoft's description is that it "lets you manage private DNS zone resources, but not the virtual networks they are linked to".
✅ This record type is the canonical least-privilege case. Microsoft documents a custom role for exactly this: "The account that is used to manage CNAMEs is granted permission to manage CNAME records only. The account is unable to modify records of other types."
🔴 A deletion guard built from RBAC does not prevent an outage. Microsoft: an RBAC rule that prevents deleting record sets while still permitting updates "is not an effective control." On a CNAME the equivalent is repointing the target, which an update permission allows and which redirects every client of that name.
✅ Plan access is not credential access. Nothing on this resource is a secret.
Microsoft.Networkregistered on the subscription.- An existing private DNS zone, referenced by name plus its resource group.
- 🔴 No other record already at this name. DNS forbids a CNAME sharing a name with any other record type, and a single module block cannot see the zone's other records — so that collision surfaces at apply time.
⚠️ A virtual-network link on that zone for any client that must resolve this name, owned byterraform-azurerm-private-dns-zone.⚠️ Something that actually answers for the target. Azure will not check.- No region to choose. Private DNS is global.
terraform-azurerm-private-dns-cname-record/
├── providers.tf # required_version + pinned azurerm; no provider block
├── variables.tf # 7 typed inputs, 14 validations
├── main.tf # derived locals + the single azurerm_private_dns_cname_record.this
├── outputs.tf # 26 outputs, none sensitive
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
module "www_cname" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "www"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = "backend"
ttl = 300
}ℹ️ Note
record, singular. Andzone_nameis a name, not a Resource ID.
Consumes
| Input | Type | Source module |
|---|---|---|
zone_name |
string (name) |
terraform-azurerm-private-dns-zone → name |
resource_group_name |
string |
terraform-azurerm-resource-group → name |
record |
string (hostname) |
The name of whatever should answer — often a sibling A record's name |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Resource ID — zone ID plus /CNAME/<name> |
A per-record-set RBAC assignment |
fqdn / expected_fqdn |
The alias name, after apply and at plan time | A client's configuration |
effective_target |
Where the alias actually points | Review |
points_at_itself |
Whether the alias is a loop | check blocks |
1 · A fully qualified target
module "www_cname" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "www"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = "backend.internal.contoso.com"
ttl = 300
}2 · A bare label, and what it really means
module "www_cname" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "www"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = "backend" # resolved RELATIVE to the zone
ttl = 300
}
output "where_it_actually_points" {
value = module.www_cname.effective_target # "backend.internal.contoso.com"
}💡 A bare label is resolved relative to the zone, so
"backend"ininternal.contoso.compoints atbackend.internal.contoso.com— which is not what the configured value literally says.effective_targetreconstructs the real destination so a reviewer reads the same thing the resolver will.
3 · 🔴 Why `"@"` is rejected
# REJECTED at parse time:
# name = "@"🔴 DNS forbids a CNAME coexisting with any other record at the same name, and the zone apex always carries SOA and NS records — so a CNAME can never exist there. The provider does not check this; Azure rejects it at apply. Since there is no reading under which an apex CNAME was intended, this module refuses it. Point the apex at an address with the A or AAAA module instead.
4 · 🔁 The self-referential loop
# ACCEPTED by Azure. DETECTED and reported here:
module "loop" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "www"
zone_name = "internal.contoso.com"
resource_group_name = module.resource_group.name
record = "www.internal.contoso.com" # its own FQDN
ttl = 300
}
check "alias_is_not_a_loop" {
assert {
condition = !module.loop.points_at_itself
error_message = "The CNAME points at its own FQDN - resolution will loop."
}
}🔴 Azure accepts this, nothing in a plan objects, and resolution then fails or loops for every client. It is the one target mistake a single module block can detect, because both sides of the comparison are inputs — so it is emitted as a flag rather than merely warned about in prose.
5 · Pointing outside the zone
module "blob_alias" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "files"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = "blob.core.windows.net."
ttl = 3600
}
output "leaves_the_zone" {
value = module.blob_alias.target_is_inside_this_zone # false
}
⚠️ Legitimate, and worth surfacing: an alias pointing outside the zone works only if whatever resolves it can also resolve that other name. In a private DNS setting that is the usual cause of a name that resolves for some clients and not others. Prefer the trailing dot for an external target —target_ends_with_a_dotreports whether it is there.
6 · A target must be a hostname, not an address
# REJECTED at parse time, each naming the right module:
# record = "10.0.1.4" -> use terraform-azurerm-private-dns-a-record
# record = "2001:db8::1" -> use terraform-azurerm-private-dns-aaaa-record
⚠️ Azure accepts an IP address in a CNAME target and the record then fails to resolve. This is the second target mistake the module can catch offline, and each error names the module that should have been used instead.
7 · The one record type whose TTL the provider validates
# REJECTED here AND by the provider: ttl = -1
# On the A and AAAA resources the provider accepts it and Azure rejects it later.
output "provider_checks_ttl_here" {
value = module.www_cname.the_ttl_validator_differs_across_the_record_types
}
⚠️ The published range is 0–2147483647 and the provider enforces it only on this resource. This suite applies the same range to the A and AAAA modules, which makes those two stricter than their own provider resources.
8 · Repointing an alias is the cutover
module "www_cname" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "www"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = "backend-v2" # was "backend"
ttl = 60
}💡
recordis not force-new, so this repoints the alias in place: same name, same Resource ID, same TTL, new destination. That is the intended way to cut over, and it takes effect as caches expire — which is what the TTL controls. Lower the TTL before the cutover, not during it.
9 · 🔴 The documentation describes a different argument set
# What the provider's published docs show — NOT valid against the pinned version:
# private_dns_zone_id = module.private_zone.id
#
# What the pinned provider actually exposes:
# zone_name = module.private_zone.name
# resource_group_name = module.resource_group.name🔴 The provider has already merged a breaking change replacing
zone_nameandresource_group_namewith a singleprivate_dns_zone_id, and its documentation and current source both describe that shape. This library pins the previous line, so the compiled schema is the authority. This module rejects a Resource ID inzone_namewith an error explaining exactly this.
10 · A CNAME cannot share a name
output "why_the_alias_is_not_called_backend" {
value = module.www_cname.a_cname_cannot_coexist_with_another_record_at_the_same_name
}🔴 If
backendalready has an A record, it cannot also have a CNAME — which is why an alias gets its own name. This module rejects the apex case, where a collision is certain. It cannot detect a collision with a sibling record set, because one module block cannot see the zone's other records, so that surfaces at apply time.
11 · Several aliases with `for_each`
locals {
aliases = {
"www" = "backend"
"api" = "backend"
"files" = "blob.core.windows.net."
}
}
module "aliases" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
for_each = local.aliases
name = each.key
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = each.value
ttl = 300
}
output "alias_targets" {
value = { for k, m in module.aliases : k => m.effective_target }
}💡 Several aliases may point at the same target — that is fine. What they may not do is share a name with a record of another type.
12 · Per-record RBAC, the canonical case
module "cname_admin" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.www_cname.id
role_assignments = {
"www-alias-admin" = {
role_definition_name = "Private DNS Zone Contributor"
principal_id = var.alias_admin_principal_id
}
}
}✅ Microsoft's own worked example of a DNS custom role is a CNAME-only administrator, which makes this record type the canonical least-privilege case. Scoped to this record set's ID, the grant covers one alias and nothing else.
13 · 🏗️ End-to-end composition
A zone linked to a virtual network, dual-stack records for a host, and an alias in front of them.
module "resource_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-net-eastus"
location = "eastus"
}
module "private_zone" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-zone.git?ref=v1.0.0"
name = "internal.contoso.com"
resource_group_name = module.resource_group.name
virtual_network_links = {
"app-vnet" = {
virtual_network_id = var.app_virtual_network_id
}
}
}
module "backend_a" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-a-record.git?ref=v1.0.0"
name = "backend"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
records = ["10.0.1.4"]
ttl = 300
}
module "backend_aaaa" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-aaaa-record.git?ref=v1.0.0"
name = "backend"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
records = ["2001:db8::4"]
ttl = 300
}
# The alias. Its own name must NOT be "backend", which already has two record sets.
module "www_cname" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-cname-record.git?ref=v1.0.0"
name = "www"
zone_name = module.private_zone.name
resource_group_name = module.resource_group.name
record = module.backend_a.name # a bare label, resolved within the zone
ttl = 300
tags = { environment = "prod" }
}
check "alias_is_not_a_loop" {
assert {
condition = !module.www_cname.points_at_itself
error_message = "The CNAME points at its own FQDN - resolution will loop."
}
}
check "alias_stays_inside_the_zone" {
assert {
condition = module.www_cname.target_is_inside_this_zone
error_message = "The alias points outside the zone; not every client will resolve it."
}
}
output "dns_wiring" {
value = {
zone = module.private_zone.name
record_sets = module.private_zone.number_of_record_sets
alias = module.www_cname.expected_fqdn
alias_target = module.www_cname.effective_target
backend_v4 = module.backend_a.expected_fqdn
backend_v6 = module.backend_aaaa.expected_fqdn
}
}💡
record = module.backend_a.namewires the alias to the sibling by reference rather than by a repeated string, so the dependency is real and the two cannot drift apart.
⚠️ The alias iswww, notbackend:backendalready carries an A and an AAAA record, and a CNAME may not share a name with either.
Required (5): name, zone_name, resource_group_name, record, ttl. Tail (2): tags, timeouts.
Full input schemas
| Name | Type | Default | Notes |
|---|---|---|---|
name |
string |
— | Force-new. Lower-case DNS labels. "@" rejected |
zone_name |
string |
— | Force-new. A name; a Resource ID is rejected |
resource_group_name |
string |
— | Force-new. Case-diff-suppressed by the provider |
record |
string |
— | Singular. A hostname; an IP address is rejected |
ttl |
number |
— | Required, no default. 0–2147483647, and the provider checks it here |
tags |
map(string) |
{} |
Updates in place |
timeouts |
object |
null |
All four operations |
| Output | Description | Notes |
|---|---|---|
id |
Resource ID | A valid RBAC scope |
name / zone_name / resource_group_name |
Force-new identity | Never "@" |
fqdn |
Azure's computed FQDN | Unknown until apply |
expected_fqdn |
Plan-time FQDN | Use this in check blocks |
record |
The single target, as configured | |
effective_target |
Where it actually points | Review |
target_is_fully_qualified / target_ends_with_a_dot |
Derived | |
target_is_inside_this_zone |
Derived | check blocks |
points_at_itself |
Derived | 🔁 check blocks |
ttl / ttl_is_zero |
Derived | |
the_ttl_validator_differs_across_the_record_types |
Constant true |
|
a_cname_cannot_coexist_with_another_record_at_the_same_name |
Constant true |
Read this |
the_target_is_never_validated_by_azure |
Constant true |
Read this |
a_write_replaces_the_target |
Constant true |
Cutover behaviour |
deleting_this_does_not_delete_the_zone |
Constant true |
|
the_zone_module_counts_this_record |
Constant true |
|
the_provider_documentation_describes_the_next_major_version |
Constant true |
Read this first |
rbac_can_be_scoped_to_this_record_type_or_this_record_set |
Constant true |
Canonical case |
preventing_deletion_by_rbac_does_not_prevent_an_outage |
Constant true |
Security review |
has_no_location_of_its_own |
Constant true |
|
only_the_name_and_zone_references_are_force_new |
Constant true |
|
accepts_no_credential |
Constant true |
No output is sensitive, because nothing on this resource is a secret.
The singular record is the one real structural difference in this record-type set. The A and AAAA modules take a set; a CNAME has exactly one target by definition, so the provider models it as a scalar. That means a configuration cannot be moved between these modules by changing only the source line, which is worth stating because the three otherwise look interchangeable.
Azure validates the target not at all, so the module validates what it can. Two mistakes are detectable offline and both are rejected: an IP address where a hostname belongs, and a self-referential loop — the latter only because both sides of the comparison are module inputs. A target that is merely wrong is indistinguishable from a right one, and the_target_is_never_validated_by_azure says so rather than implying a check exists.
A bare label does not mean what it says, so the real destination is reconstructed. "backend" in zone internal.contoso.com resolves toward backend.internal.contoso.com. effective_target makes a reviewer read the same thing the resolver will, and target_is_inside_this_zone distinguishes the common case from the one that only resolves for some clients.
"@" is rejected because the collision is certain, while a sibling collision is only likely. The apex always carries SOA and NS records, so a CNAME there can never work — no reading makes it intended. A collision with another record set in the same zone is equally fatal but invisible from one module block, so it is reported and left to apply time. That is the same reject-versus-report line this suite draws elsewhere.
The compiled schema is the only trustworthy authority for shape. The provider's documentation and its current source both describe private_dns_zone_id, a breaking change merged for the next major line. Force-new and behavioural facts here were read from the provider's source at the pinned tag.
| Concern | This module's default | Opt-out | Why |
|---|---|---|---|
"@" as a name |
Rejected | none | DNS forbids a CNAME at the apex; the collision is certain |
| An IP address as the target | Rejected, naming the right module | none | Azure accepts it and the record then fails to resolve |
| A self-referential target | Reported | — | Detectable, and occasionally the result of a rename rather than intent |
| A target outside the zone | Reported | — | Legitimate, and the usual cause of partial resolution |
name case |
Lower-case enforced | none | The provider enforces it here too |
ttl range |
0–2147483647 enforced | none | The provider's own range on this resource |
A Resource ID in zone_name |
Rejected by name | none | The published documentation invites this mistake |
ℹ️ No security-relevant boolean or enum exists on this resource, so the table is short by nature. What this module hardens is the set of values Azure would accept and then fail to resolve.
terraform init -backend=false
terraform validate
terraform fmt -checkPin ?ref=v1.0.0, never a branch. Plan-only — a human applies from CI.
terraform validate proves the configuration parses and the types line up. terraform fmt -check proves formatting. Neither fires a root-module variable validation — terraform console with a .tfvars file does, and that is how all 14 validations here were exercised, each confirmed by the line number it reported, with zero condition-evaluation errors. The self-loop and out-of-zone cases were tested as passing fixtures, to prove they are reported rather than rejected.
What only a real plan against Azure can exercise: that the zone exists, that no other record already holds this name, and whether anything answers for the target.
id = "/subscriptions/.../privateDnsZones/internal.contoso.com/CNAME/www"
name = "www"
fqdn = "www.internal.contoso.com."
expected_fqdn = "www.internal.contoso.com."
record = "backend"
effective_target = "backend.internal.contoso.com"
target_is_fully_qualified = false
target_is_inside_this_zone = true
points_at_itself = false
ttl = 300
accepts_no_credential = true
| Symptom | Cause | Fix |
|---|---|---|
name must not be "@" |
A CNAME cannot sit at the zone apex | Use the A or AAAA module for the apex |
zone_name must be the zone's NAME, not a Resource ID |
The published docs describe private_dns_zone_id |
That belongs to the provider's next major line |
record is an IPv4 address, and a CNAME target must be a HOSTNAME |
An address where a name belongs | Use the A module |
| Apply fails with a conflicting record | Another record already holds this name | DNS forbids a CNAME sharing a name; one module cannot see the other records |
| The alias resolves for some clients and not others | The target is outside the zone | Check target_is_inside_this_zone |
| Resolution loops or fails outright | The target is the alias's own FQDN | Assert on points_at_itself |
| The alias resolves to nothing | Azure never validated the target | Confirm something answers for effective_target |
| The cutover took longer than expected | Resolvers cached the old answer for the TTL | Lower the TTL before the cutover |
ttl must be a whole number... |
Out of range | The provider enforces this on the CNAME record |
| Plan wants to replace the record | name, zone_name or resource_group_name changed |
All three are force-new; record is not |
- Provider resource:
azurerm_private_dns_cname_record— note it documents the next major version's arguments - How to protect private DNS zones and records — including the CNAME-only custom role
- Private DNS Zone Contributor built-in role
- Sibling modules:
terraform-azurerm-private-dns-zone,terraform-azurerm-private-dns-a-record,terraform-azurerm-private-dns-aaaa-record,terraform-azurerm-role-assignments - This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."

