IaC AWS

Terraform Module: AWS Route 53 Zone & Records — one DNS contract for every team

Quick take — A reusable Terraform module for AWS Route 53 that provisions a public or private hosted zone and a map of records (A, CNAME, alias, MX, TXT) with validation, ttl defaults, and clean outputs for downstream wiring. 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 "aws" {
  region = "us-east-1"
}

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

  zone_name = "..."  # Fully-qualified domain for the hosted zone (no trailing…
}

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

What this module is

Amazon Route 53 is AWS’s authoritative DNS service. A hosted zone (aws_route53_zone) is the container for all the DNS records that belong to a domain such as kloudvin.com, and each record (aws_route53_record) is an entry inside it — an A record pointing a hostname at an IP, a CNAME aliasing one name to another, an alias record pointing straight at an ELB or CloudFront distribution, an MX record for mail, or a TXT record for SPF/DKIM and domain verification.

Wiring these up by hand is where DNS drift starts: someone clicks a record into the console, the TTL is wrong, a public zone leaks an internal hostname, or a private zone never gets associated with the right VPC. Wrapping the zone and its records in one reusable module turns DNS into a declarative contract. You hand it a domain name and a typed map of records; it returns the zone_id and the four NS name servers you hand to your registrar. Because every record flows through one for_each, the module enforces a default TTL, distinguishes alias records from value records, and refuses to create a public zone with force_destroy accidentally enabled — the kinds of guardrails you cannot put on a console click.

When to use it

If you only ever need a single static record and never touch it again, the raw resource is fine. The module pays off the moment you have more than a handful of records or more than one zone.

Module structure

terraform-module-aws-route53/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # aws_route53_zone + aws_route53_record (for_each)
├── variables.tf     # zone + records map inputs, with validation
└── outputs.tf       # zone_id, name_servers, record fqdns

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Records that carry literal values (A, AAAA, CNAME, MX, TXT, ...)
  value_records = {
    for k, r in var.records : k => r
    if try(r.alias, null) == null
  }

  # Records that point at an AWS resource via alias (ELB, CloudFront, S3, apex)
  alias_records = {
    for k, r in var.records : k => r
    if try(r.alias, null) != null
  }
}

resource "aws_route53_zone" "this" {
  name          = var.zone_name
  comment       = var.comment
  force_destroy = var.force_destroy

  # Only set for private zones; a single primary VPC association is created
  # here, additional associations belong in aws_route53_zone_association.
  dynamic "vpc" {
    for_each = var.private_zone_vpc_id == null ? [] : [var.private_zone_vpc_id]
    content {
      vpc_id     = vpc.value
      vpc_region = var.private_zone_vpc_region
    }
  }

  tags = merge(
    var.tags,
    { Name = var.zone_name }
  )
}

resource "aws_route53_record" "value" {
  for_each = local.value_records

  zone_id = aws_route53_zone.this.zone_id
  # An empty name targets the zone apex (e.g. "kloudvin.com" itself)
  name    = each.value.name == "" ? var.zone_name : "${each.value.name}.${var.zone_name}"
  type    = each.value.type
  ttl     = try(each.value.ttl, var.default_ttl)
  records = each.value.values

  set_identifier = try(each.value.set_identifier, null)

  dynamic "weighted_routing_policy" {
    for_each = try(each.value.weight, null) == null ? [] : [each.value.weight]
    content {
      weight = weighted_routing_policy.value
    }
  }

  allow_overwrite = var.allow_overwrite
}

resource "aws_route53_record" "alias" {
  for_each = local.alias_records

  zone_id = aws_route53_zone.this.zone_id
  name    = each.value.name == "" ? var.zone_name : "${each.value.name}.${var.zone_name}"
  type    = each.value.type

  alias {
    name                   = each.value.alias.name
    zone_id                = each.value.alias.zone_id
    evaluate_target_health = try(each.value.alias.evaluate_target_health, true)
  }

  allow_overwrite = var.allow_overwrite
}

variables.tf

variable "zone_name" {
  description = "Fully-qualified domain name for the hosted zone (e.g. kloudvin.com). No trailing dot."
  type        = string

  validation {
    condition     = can(regex("^([a-z0-9-]+\\.)+[a-z]{2,}$", var.zone_name))
    error_message = "zone_name must be a valid lowercase FQDN with no trailing dot, e.g. api.kloudvin.com."
  }
}

variable "comment" {
  description = "Comment stored on the hosted zone. Max 256 characters per the Route 53 API."
  type        = string
  default     = "Managed by Terraform"

  validation {
    condition     = length(var.comment) <= 256
    error_message = "comment must be 256 characters or fewer."
  }
}

variable "force_destroy" {
  description = "Allow deleting the zone even if it still contains records. Keep false for any customer-facing zone."
  type        = bool
  default     = false
}

variable "private_zone_vpc_id" {
  description = "If set, the zone becomes a PRIVATE hosted zone associated with this VPC. Leave null for a public zone."
  type        = string
  default     = null
}

variable "private_zone_vpc_region" {
  description = "Region of the VPC for a private zone. Defaults to the provider region when null."
  type        = string
  default     = null
}

variable "default_ttl" {
  description = "TTL in seconds applied to value records that do not set their own ttl."
  type        = number
  default     = 300

  validation {
    condition     = var.default_ttl >= 0 && var.default_ttl <= 172800
    error_message = "default_ttl must be between 0 and 172800 seconds (2 days)."
  }
}

variable "allow_overwrite" {
  description = "Allow Terraform to overwrite a pre-existing record of the same name/type (e.g. zone apex NS/SOA)."
  type        = bool
  default     = false
}

variable "records" {
  description = <<-EOT
    Map of DNS records keyed by a stable logical name. For value records set
    `values` (and optionally `ttl`); for alias records set the `alias` object
    and omit `values`/`ttl`. `name` is the host label relative to the zone
    ("" or "@" means the apex). Example:
    {
      apex   = { name = "", type = "A", alias = { name = "d111.cloudfront.net", zone_id = "Z2FDTNDATAQYW2" } }
      www    = { name = "www", type = "CNAME", values = ["kloudvin.com"] }
      mx     = { name = "", type = "MX", values = ["10 inbound-smtp.eu-west-1.amazonaws.com"] }
      spf    = { name = "", type = "TXT", values = ["v=spf1 include:amazonses.com -all"] }
    }
  EOT

  type = map(object({
    name           = string
    type           = string
    values         = optional(list(string))
    ttl            = optional(number)
    set_identifier = optional(string)
    weight         = optional(number)
    alias = optional(object({
      name                   = string
      zone_id                = string
      evaluate_target_health = optional(bool)
    }))
  }))

  default = {}

  validation {
    condition = alltrue([
      for r in values(var.records) :
      contains(["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "PTR"], r.type)
    ])
    error_message = "Each record type must be one of A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, PTR."
  }

  validation {
    condition = alltrue([
      for r in values(var.records) :
      (try(r.alias, null) != null) != (try(r.values, null) != null)
    ])
    error_message = "Each record must set EITHER `values` (value record) OR `alias` (alias record), never both and never neither."
  }
}

variable "tags" {
  description = "Tags applied to the hosted zone."
  type        = map(string)
  default     = {}
}

outputs.tf

output "zone_id" {
  description = "The hosted zone ID, used by alias targets and zone associations."
  value       = aws_route53_zone.this.zone_id
}

output "zone_name" {
  description = "The domain name of the hosted zone."
  value       = aws_route53_zone.this.name
}

output "name_servers" {
  description = "The four authoritative name servers. Hand these to your registrar (public zone) or parent zone (delegated subdomain)."
  value       = aws_route53_zone.this.name_servers
}

output "zone_arn" {
  description = "ARN of the hosted zone, useful for IAM resource policies."
  value       = aws_route53_zone.this.arn
}

output "record_fqdns" {
  description = "Map of record key to its built FQDN, for both value and alias records."
  value = merge(
    { for k, r in aws_route53_record.value : k => r.fqdn },
    { for k, r in aws_route53_record.alias : k => r.fqdn }
  )
}

output "is_private_zone" {
  description = "Whether this zone was created as a private (VPC-associated) zone."
  value       = var.private_zone_vpc_id != null
}

How to use it

# Public zone for kloudvin.com with an apex alias to CloudFront,
# a www CNAME, mail (MX), and SES verification (TXT).
module "route_53_zone_records" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-route53?ref=v1.0.0"

  zone_name = "kloudvin.com"
  comment   = "Primary public zone for KloudVin"

  records = {
    apex = {
      name = ""
      type = "A"
      alias = {
        name                   = aws_cloudfront_distribution.site.domain_name
        zone_id                = "Z2FDTNDATAQYW2" # CloudFront's fixed hosted zone ID
        evaluate_target_health = false
      }
    }

    www = {
      name   = "www"
      type   = "CNAME"
      values = ["kloudvin.com"]
      ttl    = 3600
    }

    mx = {
      name   = ""
      type   = "MX"
      values = ["10 inbound-smtp.eu-west-1.amazonaws.com"]
    }

    ses_verify = {
      name   = "_amazonses"
      type   = "TXT"
      values = ["pmZGd6h2Zt9oQrExampleVerificationToken="]
    }
  }

  tags = {
    Environment = "prod"
    ManagedBy   = "terraform"
    CostCenter  = "platform"
  }
}

# Downstream: prove delegation by handing the zone's name servers to the
# registrar module, and reuse the zone_id for an ACM DNS validation record.
resource "aws_route53_record" "acm_validation" {
  for_each = {
    for dvo in aws_acm_certificate.site.domain_validation_options :
    dvo.domain_name => dvo
  }

  zone_id         = module.route_53_zone_records.zone_id
  name            = each.value.resource_record_name
  type            = each.value.resource_record_type
  records         = [each.value.resource_record_value]
  ttl             = 60
  allow_overwrite = true
}

output "registrar_name_servers" {
  value = module.route_53_zone_records.name_servers
}

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 = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  zone_name = "..."
}

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

cd live/prod/route53 && 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
zone_name string Yes Fully-qualified domain for the hosted zone (no trailing dot); validated as a lowercase FQDN.
comment string "Managed by Terraform" No Comment on the zone; max 256 characters.
force_destroy bool false No Allow zone deletion while it still holds records. Keep false for customer-facing zones.
private_zone_vpc_id string null No When set, creates a PRIVATE zone associated with this VPC. null = public zone.
private_zone_vpc_region string null No Region of the private-zone VPC; defaults to the provider region.
default_ttl number 300 No TTL applied to value records that omit their own ttl; 0–172800s.
allow_overwrite bool false No Allow overwriting a pre-existing record of the same name/type (e.g. validation records).
records map(object) {} No Map of records keyed by logical name; each sets either values or alias. See the variable doc for the object shape.
tags map(string) {} No Tags applied to the hosted zone.

Outputs

Name Description
zone_id Hosted zone ID, used by alias targets, zone associations, and downstream records.
zone_name The domain name of the hosted zone.
name_servers The four authoritative NS records to give your registrar or parent zone.
zone_arn ARN of the hosted zone, for IAM resource policies.
record_fqdns Map of record key to its resolved FQDN, covering both value and alias records.
is_private_zone true if the zone was created as a private (VPC-associated) zone.

Enterprise scenario

A SaaS platform runs a hub account that owns the apex kloudvin.com public zone and delegates eu.kloudvin.com and us.kloudvin.com as separate child zones in regional workload accounts. Each regional team instantiates this module for its subdomain, exports name_servers, and the hub pipeline consumes those four values to create the delegating NS record in the parent zone — so a new region goes live by adding one module block and a CI approval, with zero console clicks and a full audit trail. The same module also stamps out private zones for internal.eu.kloudvin.com associated with each region’s VPC, giving service-discovery DNS that never resolves on the public internet.

Best practices

TerraformAWSRoute 53 Zone & RecordsModuleIaC
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