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:
- Inbound endpoint (
direction = "INBOUND") gives on-prem and peered networks a set of IP addresses inside your VPC that they can point their conditional forwarders at. Flow: on-prem → AWS. Your data-centre DNS forwards*.aws.example.comto these inbound IPs, and Route 53 Resolver answers from private hosted zones. - Outbound endpoint (
direction = "OUTBOUND") gives Resolver a set of IPs in your VPC from which it makes queries out to other name servers. Flow: AWS → on-prem. Paired with aFORWARDResolver rule, queries forcorp.example.comoriginating in your VPC are forwarded to your on-prem DNStarget_ips.
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
- You are building hybrid DNS so EC2/EKS/Lambda workloads can resolve on-prem Active Directory or legacy app names through your data-centre DNS servers.
- On-prem systems need to resolve AWS private hosted zones (Private Link endpoints, internal services) and you need a stable set of inbound resolver IPs to forward to.
- You run a hub-and-spoke landing zone and want a single account to own the outbound endpoint + forward rules, then share the rules (via RAM) and associate them across spoke VPCs.
- You want endpoint provisioning to be multi-AZ and reviewed — two subnets in distinct AZs, a dedicated security group, and rule associations as a three-line diff — rather than portal clicks that silently land everything in one AZ.
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 changeddomain_namere-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 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/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
- Always deploy endpoints across at least two AZs. Each
ip_addressblock lands one ENI in one subnet; two blocks in distinct AZs give you the HA Resolver is designed for. This module validates the ≥2 rule for both directions so a single-AZ endpoint never slips through. - Keep the security group tight and open both protocols. DNS needs UDP and TCP on port 53 (TCP carries large answers and zone transfers). Scope ingress/egress to your on-prem CIDR — never
0.0.0.0/0— so the endpoints only talk to trusted name servers. - Pin inbound IPs you publish to on-prem. On-prem conditional forwarders reference fixed IPs; set the optional
ipon each inbound block so the resolver addresses survive a re-apply and you do not have to re-point every data-centre forwarder. - Share rules, don’t duplicate endpoints. Resolver endpoints bill per ENI per hour. Build the outbound endpoint + FORWARD rules once in a connectivity account, share the rules via RAM, and associate them into spokes — cheaper and easier to govern than an endpoint per VPC.
- Layer DNS Firewall for egress control. Associate a
firewall_rule_group_idto block or allow outbound query domains; this is your defence against DNS tunnelling and exfiltration, and it composes cleanly with the forwarding this module sets up. - Treat rule changes as routing changes. Editing a rule’s
domain_nameforces re-creation and a brief resolution gap for that domain. Stage changes in a non-prod VPC, and tag every rule with owner and environment so on-call knows which team to page when a forward breaks.