Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Private DNS CNAME Record Terraform Module

Points one name inside a private DNS zone at another name. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🔗 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.


❤️ Support this project

If this module saves you time, a little support goes a long way:


🗺️ Where this fits in the family

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
Loading

🧬 What this module builds

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
Loading
Resource Cardinality Notes
azurerm_private_dns_cname_record.this one record set per name, per zone Exactly one target, and never at the apex

✅ Provider / Versions

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 exposes zone_name plus resource_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 ttl field 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.
  • ⚠️ name must be lower-case, enforced by the provider here — the AAAA resource carries the same comment and no validator.
  • ⚠️ resource_group_name is case-diff-suppressed, so a capitalisation-only change produces no plan diff.
  • ⚠️ No location. Private DNS zones and their records are global.

🔑 Required Azure RBAC Roles / Permissions

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.


Azure Prerequisites

  • Microsoft.Network registered 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 by terraform-azurerm-private-dns-zone.
  • ⚠️ Something that actually answers for the target. Azure will not check.
  • No region to choose. Private DNS is global.

📁 Module Structure

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

⚙️ Quick Start

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. And zone_name is a name, not a Resource ID.


🔌 Cross-Module Contract

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

📚 Example Library

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" in internal.contoso.com points at backend.internal.contoso.com — which is not what the configured value literally says. effective_target reconstructs 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_dot reports 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
}

💡 record is 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_name and resource_group_name with a single private_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 in zone_name with 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 backend already 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.name wires 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 is www, not backend: backend already carries an A and an AAAA record, and a CNAME may not share a name with either.


📥 Inputs

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

🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin ?ref=v1.0.0, never a branch. Plan-only — a human applies from CI.


🧪 Testing

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.


💬 Example Output

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

🔍 Troubleshooting

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

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."