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
- You manage one or more domains or subdomains (
api.kloudvin.com,internal.kloudvin.com) and want the zone plus all its records in version control. - You need both public and private zones from the same code path — public for customer-facing names, private (VPC-associated) for internal service discovery — without copy-pasting two resource blocks.
- You front services with ALBs, CloudFront, or S3 website endpoints and need alias records (which Route 53 evaluates for free and which support zone apexes, unlike CNAMEs).
- You are running delegated subdomains in a hub-and-spoke account model and need
NSoutputs to wire a child zone into a parent zone in another account. - You want guardrails: enforced TTLs, validated record types, and a hard stop on
force_destroyfor anything customer-facing.
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 config — live/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 config — live/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
- Use alias records, not CNAMEs, for AWS targets and the zone apex. Aliases work at the apex (where CNAMEs are illegal), are evaluated by Route 53 at no per-query cost, and auto-track the target’s changing IPs for ALBs, CloudFront, and S3 website endpoints.
- Keep
force_destroy = falseon anything customer-facing. Aterraform destroythat silently wipes a populated production zone is an outage and a delegation re-verification nightmare; require an explicit override only for throwaway test zones. - Tune TTLs deliberately. Stable records (MX, apex) can sit at 3600s to cut query volume and cost; records you expect to cut over (a service behind a planned migration) belong at 60s a day or two before the change, then raised again afterward.
- Never put internal hostnames in a public zone. Use the
private_zone_vpc_idpath for service-discovery and internal names so they only resolve inside the VPC; public zones are world-readable and routinely scraped. - Delegate via
name_serversoutputs, not by copying values. Feed the module’sname_serversstraight into the parent zone’sNSrecord so delegation stays correct automatically if AWS ever reassigns the set. - Tag zones for ownership and cost. Route 53 bills per hosted zone per month plus per query; consistent
CostCenter/Environment/ManagedBytags let you attribute that spend and spot orphaned zones nobody owns.