IaC AWS

Terraform Module: AWS Route 53 Resolver — Hybrid DNS Between On-Prem and Your VPCs Done Right

Quick take — A reusable hashicorp/aws ~> 5.0 module for Route 53 Resolver: inbound and outbound endpoints over private subnets, FORWARD rules to on-prem name servers, rule-to-VPC associations, and clean outputs for sharing across a landing zone. 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 "resolver" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-route53-resolver?ref=v1.0.0"

  name               = "..."                 # Name prefix for endpoints, rules, and tags.
  vpc_id             = "..."                 # VPC the resolver endpoints and rule associations bind to.
  security_group_ids = ["..."]               # SG(s) allowing UDP/TCP 53 to/from on-prem ranges.

  # Two+ subnets in distinct AZs for endpoint high availability.
  outbound_subnet_ids = ["...", "..."]       # Private subnets for the OUTBOUND (AWS -> on-prem) endpoint.

  # Forward an internal domain to your on-prem DNS servers.
  forward_rules = {
    corp = {
      domain_name = "corp.example.com"       # On-prem domain to forward.
      target_ips  = [{ ip = "10.0.0.10" }, { ip = "10.0.0.11" }]
    }
  }
}

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 Resolver is the DNS service that already runs inside every VPC at the .2 address of your CIDR (the famous 169.254.169.253 / VPC +2 resolver). On its own it answers queries for public names and Route 53 private hosted zones. The moment you need hybrid DNS — your VPCs resolving on-prem Active Directory names, or your data centre resolving private records hosted in AWS — you need Resolver endpoints and Resolver rules, and that is exactly what this module codifies.

There are two directions, and getting them straight is the whole game:

Each endpoint is an aws_route53_resolver_endpoint with one ip_address block per AZ (each pinning a subnet_id, optionally a fixed ip), guarded by security_group_ids that must permit DNS on UDP/TCP 53. A FORWARD rule (aws_route53_resolver_rule) names the domain_name, references the outbound resolver_endpoint_id, and lists target_ip blocks ({ ip, port }). An aws_route53_resolver_rule_association then binds each rule to a vpc_id so queries in that VPC actually honour it. Wrapping all of this in a module turns a famously fiddly, easy-to-misconfigure setup into one reviewed, versioned building block.

When to use it

Reach for Route 53 Resolver DNS Firewall (aws_route53_resolver_firewall_rule_group and friends) on top of this when you need to block or allow outbound query domains for data-exfiltration protection; this module focuses on the endpoint + forwarding plumbing that hybrid resolution depends on, and exposes an optional flag to associate a firewall rule group you create elsewhere.

Module structure

terraform-module-aws-route53-resolver/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # inbound/outbound endpoints, forward rules, associations
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # endpoint IDs, resolver IPs, rule IDs/ARNs

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  tags = merge(
    {
      "Name"      = var.name
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-route53-resolver"
    },
    var.tags,
  )

  # An OUTBOUND endpoint is required whenever we have FORWARD rules to create.
  create_outbound = var.create_outbound_endpoint || length(var.forward_rules) > 0
}

# ---- INBOUND endpoint: on-prem -> AWS ----
# Gives external networks a set of IPs *inside the VPC* to forward queries to.
resource "aws_route53_resolver_endpoint" "inbound" {
  count = var.create_inbound_endpoint ? 1 : 0

  name               = "${var.name}-inbound"
  direction          = "INBOUND"
  security_group_ids = var.security_group_ids

  # One IP per AZ. Each block pins a subnet; an explicit ip is optional and
  # lets you keep the resolver addresses stable for on-prem forwarder configs.
  dynamic "ip_address" {
    for_each = var.inbound_ip_addresses
    content {
      subnet_id = ip_address.value.subnet_id
      ip        = lookup(ip_address.value, "ip", null)
    }
  }

  tags = merge(local.tags, { "Direction" = "INBOUND" })
}

# ---- OUTBOUND endpoint: AWS -> on-prem ----
# Source IPs from which Resolver forwards queries out to your name servers.
resource "aws_route53_resolver_endpoint" "outbound" {
  count = local.create_outbound ? 1 : 0

  name               = "${var.name}-outbound"
  direction          = "OUTBOUND"
  security_group_ids = var.security_group_ids

  dynamic "ip_address" {
    for_each = { for s in var.outbound_subnet_ids : s => s }
    content {
      subnet_id = ip_address.value
    }
  }

  # Outbound endpoints can pin the DNS transport protocols they use: Do53
  # (plaintext) and/or DoH (DNS over HTTPS) when forwarding to your name servers.
  protocols = var.outbound_protocols

  tags = merge(local.tags, { "Direction" = "OUTBOUND" })
}

# ---- FORWARD rules: route a domain to on-prem target IPs ----
resource "aws_route53_resolver_rule" "forward" {
  for_each = var.forward_rules

  name                 = "${var.name}-${each.key}"
  rule_type            = "FORWARD"
  domain_name          = each.value.domain_name
  resolver_endpoint_id = aws_route53_resolver_endpoint.outbound[0].id

  dynamic "target_ip" {
    for_each = each.value.target_ips
    content {
      ip   = target_ip.value.ip
      port = lookup(target_ip.value, "port", 53)
    }
  }

  tags = merge(local.tags, { "Rule" = each.key })
}

# Bind each rule to the VPC so queries originating there honour the forward.
resource "aws_route53_resolver_rule_association" "this" {
  for_each = var.forward_rules

  name             = "${var.name}-${each.key}-assoc"
  resolver_rule_id = aws_route53_resolver_rule.forward[each.key].id
  vpc_id           = var.vpc_id
}

# ---- Optional: associate an existing DNS Firewall rule group ----
# Create the rule group + rules elsewhere; pass its id to filter outbound
# query domains (e.g. block known-malicious domains, allow corp domains).
resource "aws_route53_resolver_firewall_rule_group_association" "this" {
  count = var.firewall_rule_group_id == null ? 0 : 1

  name                   = "${var.name}-dns-firewall"
  firewall_rule_group_id = var.firewall_rule_group_id
  vpc_id                 = var.vpc_id
  priority               = var.firewall_rule_group_priority
  mutation_protection    = var.firewall_mutation_protection

  tags = local.tags
}

variables.tf

variable "name" {
  description = "Name prefix applied to endpoints, rules, associations, and tags."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$", var.name))
    error_message = "name must be 1-63 chars, start alphanumeric, and use only letters, digits, hyphen, period, underscore."
  }
}

variable "vpc_id" {
  description = "VPC the resolver endpoints, rule associations, and optional firewall association bind to."
  type        = string

  validation {
    condition     = can(regex("^vpc-[0-9a-f]+$", var.vpc_id))
    error_message = "vpc_id must look like vpc-xxxxxxxx."
  }
}

variable "security_group_ids" {
  description = "Security group IDs for the resolver endpoints. Must allow UDP and TCP port 53 to/from the relevant networks."
  type        = list(string)

  validation {
    condition     = length(var.security_group_ids) > 0
    error_message = "At least one security group ID is required for resolver endpoints."
  }
}

variable "create_inbound_endpoint" {
  description = "Create an INBOUND endpoint so on-prem/peered networks can resolve AWS private DNS."
  type        = bool
  default     = false
}

variable "inbound_ip_addresses" {
  description = <<-EOT
    IP address blocks for the INBOUND endpoint (one per AZ, two minimum for HA). Each value:
      subnet_id - private subnet that hosts the resolver IP (required)
      ip        - optional fixed IPv4 to keep on-prem forwarder configs stable
  EOT
  type = list(object({
    subnet_id = string
    ip        = optional(string)
  }))
  default = []

  validation {
    condition     = !var.create_inbound_endpoint || length(var.inbound_ip_addresses) >= 2
    error_message = "An INBOUND endpoint needs at least two ip_address blocks in distinct AZs."
  }
}

variable "create_outbound_endpoint" {
  description = "Force creation of an OUTBOUND endpoint even when no forward_rules are defined."
  type        = bool
  default     = false
}

variable "outbound_subnet_ids" {
  description = "Private subnet IDs (two minimum, distinct AZs) for the OUTBOUND endpoint source IPs."
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.outbound_subnet_ids) == 0 || length(var.outbound_subnet_ids) >= 2
    error_message = "Provide at least two subnet IDs in distinct AZs for the OUTBOUND endpoint, or leave empty if unused."
  }
}

variable "outbound_protocols" {
  description = "DNS transport protocols the OUTBOUND endpoint uses when forwarding queries. Valid values: Do53 (plaintext) and DoH (DNS over HTTPS)."
  type        = list(string)
  default     = ["Do53"]

  validation {
    condition     = length(var.outbound_protocols) > 0 && alltrue([for p in var.outbound_protocols : contains(["Do53", "DoH"], p)])
    error_message = "outbound_protocols must be a non-empty subset of [\"Do53\", \"DoH\"]."
  }
}

variable "forward_rules" {
  description = <<-EOT
    Map of FORWARD rules keyed by short name. Each value:
      domain_name - domain whose queries are forwarded (e.g. corp.example.com) (required)
      target_ips  - list of { ip, port } on-prem name servers (port defaults to 53)
  EOT
  type = map(object({
    domain_name = string
    target_ips = list(object({
      ip   = string
      port = optional(number, 53)
    }))
  }))
  default = {}

  validation {
    condition = alltrue([
      for r in values(var.forward_rules) : length(r.target_ips) > 0
    ])
    error_message = "Every forward rule must specify at least one target_ip."
  }

  validation {
    condition = alltrue([
      for r in values(var.forward_rules) :
      alltrue([for t in r.target_ips : can(regex("^(\\d{1,3}\\.){3}\\d{1,3}$", t.ip))])
    ])
    error_message = "Every target_ip.ip must be a valid IPv4 address."
  }
}

variable "firewall_rule_group_id" {
  description = "ID of an existing Route 53 Resolver DNS Firewall rule group to associate with the VPC. null = none."
  type        = string
  default     = null
}

variable "firewall_rule_group_priority" {
  description = "Evaluation priority for the DNS Firewall rule group association (100-9900)."
  type        = number
  default     = 101

  validation {
    condition     = var.firewall_rule_group_priority >= 100 && var.firewall_rule_group_priority <= 9900
    error_message = "firewall_rule_group_priority must be between 100 and 9900."
  }
}

variable "firewall_mutation_protection" {
  description = "Mutation protection for the firewall association: ENABLED or DISABLED."
  type        = string
  default     = "DISABLED"

  validation {
    condition     = contains(["ENABLED", "DISABLED"], var.firewall_mutation_protection)
    error_message = "firewall_mutation_protection must be ENABLED or DISABLED."
  }
}

variable "tags" {
  description = "Additional tags merged onto every taggable resource."
  type        = map(string)
  default     = {}
}

outputs.tf

output "inbound_endpoint_id" {
  description = "ID of the INBOUND resolver endpoint, or null when not created."
  value       = try(aws_route53_resolver_endpoint.inbound[0].id, null)
}

output "inbound_endpoint_ip_addresses" {
  description = "Resolved IP addresses of the INBOUND endpoint (point on-prem conditional forwarders here)."
  value       = try([for ip in aws_route53_resolver_endpoint.inbound[0].ip_address : ip.ip], [])
}

output "outbound_endpoint_id" {
  description = "ID of the OUTBOUND resolver endpoint, or null when not created."
  value       = try(aws_route53_resolver_endpoint.outbound[0].id, null)
}

output "outbound_endpoint_arn" {
  description = "ARN of the OUTBOUND resolver endpoint."
  value       = try(aws_route53_resolver_endpoint.outbound[0].arn, null)
}

output "resolver_rule_ids" {
  description = "Map of forward-rule key => resolver rule ID (share these via RAM to other VPCs)."
  value       = { for k, r in aws_route53_resolver_rule.forward : k => r.id }
}

output "resolver_rule_arns" {
  description = "Map of forward-rule key => resolver rule ARN."
  value       = { for k, r in aws_route53_resolver_rule.forward : k => r.arn }
}

output "rule_association_ids" {
  description = "Map of forward-rule key => rule-to-VPC association ID."
  value       = { for k, a in aws_route53_resolver_rule_association.this : k => a.id }
}

output "firewall_association_id" {
  description = "DNS Firewall rule group association ID, or null when not configured."
  value       = try(aws_route53_resolver_firewall_rule_group_association.this[0].id, null)
}

How to use it

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

  name               = "hybrid-dns-prod"
  vpc_id             = module.vpc.vpc_id
  security_group_ids = [aws_security_group.resolver.id]

  # INBOUND: on-prem -> AWS. On-prem DNS forwards *.aws.example.com here.
  create_inbound_endpoint = true
  inbound_ip_addresses = [
    { subnet_id = module.vpc.private_subnet_ids[0], ip = "10.20.1.10" },
    { subnet_id = module.vpc.private_subnet_ids[1], ip = "10.20.2.10" },
  ]

  # OUTBOUND: AWS -> on-prem. Source IPs for forwarded queries.
  outbound_subnet_ids = [
    module.vpc.private_subnet_ids[0],
    module.vpc.private_subnet_ids[1],
  ]

  # FORWARD corp.example.com and a reverse zone to the data-centre DNS pair.
  forward_rules = {
    corp = {
      domain_name = "corp.example.com"
      target_ips  = [{ ip = "10.0.0.10" }, { ip = "10.0.0.11" }]
    }
    legacy = {
      domain_name = "legacy.internal"
      target_ips  = [{ ip = "10.0.0.10", port = 5353 }]
    }
  }

  # Filter outbound queries through a pre-built DNS Firewall rule group.
  firewall_rule_group_id = aws_route53_resolver_firewall_rule_group.block_bad_domains.id

  tags = {
    Environment = "prod"
    Team        = "platform-networking"
    CostCenter  = "NET-3310"
  }
}

# The security group that the resolver endpoints attach to: allow DNS to/from
# the on-prem CIDR over both UDP and TCP (TCP for large/zone-transfer answers).
resource "aws_security_group" "resolver" {
  name_prefix = "hybrid-dns-prod-resolver-"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "DNS UDP from on-prem"
    from_port   = 53
    to_port     = 53
    protocol    = "udp"
    cidr_blocks = ["10.0.0.0/8"]
  }

  ingress {
    description = "DNS TCP from on-prem"
    from_port   = 53
    to_port     = 53
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]
  }

  egress {
    description = "DNS to on-prem name servers"
    from_port   = 53
    to_port     = 53
    protocol    = "-1"
    cidr_blocks = ["10.0.0.0/8"]
  }
}

# Downstream: publish the inbound IPs to SSM so on-prem automation can read them.
resource "aws_ssm_parameter" "inbound_ips" {
  name  = "/hybrid-dns/prod/inbound-ips"
  type  = "StringList"
  value = join(",", module.resolver.inbound_endpoint_ip_addresses)
}

Pin the module with ?ref=<tag> so a stack never silently picks up a breaking module change — important for resolver rules, where a changed domain_name re-creates the rule and momentarily breaks name resolution for that domain.

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/resolver/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-resolver?ref=v1.0.0"
}

inputs = {
  name = "..."
  vpc_id = "..."
  security_group_ids = ["..."]
  outbound_subnet_ids = ["...", "..."]
  forward_rules = {
    corp = {
      domain_name = "..."
      target_ips  = [{ ip = "..." }]
    }
  }
}

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

cd live/prod/resolver && 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 prefix for endpoints, rules, associations, and tags.
vpc_id string Yes VPC the endpoints and rule associations bind to.
security_group_ids list(string) Yes SGs for endpoints; must allow UDP/TCP 53.
create_inbound_endpoint bool false No Create an INBOUND endpoint (on-prem → AWS).
inbound_ip_addresses list(object) [] No Inbound IP blocks (subnet_id, optional ip); ≥2 when inbound enabled.
create_outbound_endpoint bool false No Force an OUTBOUND endpoint even without forward rules.
outbound_subnet_ids list(string) [] No Subnets for the OUTBOUND endpoint (≥2, distinct AZs).
outbound_protocols list(string) ["Do53"] No DNS transport protocols for the outbound endpoint (Do53, DoH).
forward_rules map(object) {} No FORWARD rules (domain_name, target_ips of {ip, port}).
firewall_rule_group_id string null No Existing DNS Firewall rule group to associate; null = none.
firewall_rule_group_priority number 101 No Firewall association priority (100–9900).
firewall_mutation_protection string DISABLED No ENABLED or DISABLED mutation protection.
tags map(string) {} No Additional tags merged onto every resource.

Outputs

Name Description
inbound_endpoint_id INBOUND endpoint ID, or null.
inbound_endpoint_ip_addresses INBOUND endpoint IPs (point on-prem forwarders here).
outbound_endpoint_id OUTBOUND endpoint ID, or null.
outbound_endpoint_arn OUTBOUND endpoint ARN.
resolver_rule_ids Map of rule key → resolver rule ID (share via RAM).
resolver_rule_arns Map of rule key → resolver rule ARN.
rule_association_ids Map of rule key → rule-to-VPC association ID.
firewall_association_id DNS Firewall association ID, or null.

Enterprise scenario

A retail group runs a hub-and-spoke landing zone in us-east-1. The connectivity account owns one VPC where this module provisions both an inbound and an outbound endpoint across two AZs, plus FORWARD rules for corp.example.com and a couple of reverse zones pointing at the redundant on-prem Active Directory DNS pair. The outbound rules are shared to every spoke with AWS RAM, and each spoke account associates the shared rule to its own VPC — so a new microservice account inherits hybrid resolution by consuming resolver_rule_ids rather than rebuilding endpoints (which would needlessly multiply the per-ENI hourly cost). On-prem domain controllers forward aws.example.com to the inbound IPs published in SSM, and a DNS Firewall rule group association blocks outbound queries to known data-exfiltration domains — closing the loop on a single, auditable hybrid-DNS design.

Best practices

TerraformAWSRoute 53 ResolverModuleIaC
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