ESEN

Hybrid private DNS (on-premises ↔ cloud)

On-premises resolves the cloud's private zones (Private Endpoints included) and the cloud resolves the corporate domain, with no self-managed DNS servers in the cloud.

Problem

With a VPN or dedicated link there's already IP connectivity, but names don't cross: on-premises can't query the cloud's internal resolver and cloud workloads don't know corp.example.com. The classic fix is DNS VMs in the cloud that need patching and scaling. Requirements: conditional forwarding both ways, high availability, spokes resolving with no config of their own, and no forwarding loops.

The topology is the same on every cloud; what changes is what implements each role.

Microsoft Azure

Hybrid private DNS (on-premises ↔ cloud) — Azureconditional forwarderresolvescorp.example.comforwards over VPNOn-prem DNSInbound (on-prem → cloud)DNS Private Resolver:inbound endpoint3 gotchas in this roleCloud private zoneAzure Private DNS1 gotcha in this roleCloud workloadsOutbound (cloud → on-prem)DNS Private Resolver:outbound endpoint +forwarding ruleset5 gotchas in this role

Implementation

Inbound (on-prem → cloud)
DNS Private Resolver: inbound endpointStatic IP in a dedicated, delegated /28 subnetComing from AWS: there this role is Route 53 Resolver inbound endpoint.Coming from GCP: there this role is Cloud DNS inbound server policy.
Cloud private zone
Azure Private DNSZone linked to the hub; spokes resolve it through a rule to the inbound endpointComing from AWS: there this role is Route 53 private hosted zone. The zone linked to the VNet is checked before the ruleset; in Route 53 a forward rule beats the zone with the same name.Coming from GCP: there this role is Cloud DNS private zone.
Outbound (cloud → on-prem)
DNS Private Resolver: outbound endpoint + forwarding rulesetRuleset linked to the spokes (not the hub)Coming from AWS: there this role is Route 53 Resolver outbound endpoint + FORWARD rule.Coming from GCP: there this role is Cloud DNS forwarding zone.

Cost model

Doesn't scale to zero

Fixed cost even with zero traffic:

  • Inbound endpoint (hourly)
  • Outbound endpoint (hourly)
  • Forwarding ruleset (monthly)

Endpoints and the ruleset are billed even with zero queries, plus a per-query charge. Still cheaper than running two highly available DNS VMs.

Monthly floor with no traffic

USD 363/ mo

With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.

List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.

⚖ Compare SKUs: Hybrid DNS (on-premises ↔ cloud) →

Fine print · 5 gotchas

Networking● verified 34 days ago◌ no external reviewer

Each endpoint needs its own delegated subnet, and the resolver is tied to one VNet and one region

Inbound and outbound can't share a subnet: each goes in a dedicated /28 to /24, delegated to Microsoft.Network/dnsResolvers and with no other resources. There's one resolver per VNet, rulesets only link to VNets in the same region, and it doesn't work with IPv6 subnets or VNets with virtual network encryption. Reserve both /28s in the hub design before the address space fills up.

⚑ Report outdated
Networking● verified 34 days ago◌ no external reviewer

In Azure the linked private zone wins over the ruleset; in AWS it's the other way around

With Azure DNS on the VNet, linked Private DNS zones are checked first, and only if nothing matches are ruleset rules evaluated (longest suffix wins). In Route 53 Resolver, a forward rule beats a private hosted zone with the same name. If you migrate a "forward all of corp.example.com to on-prem" design with a private zone of the same name, the behavior flips.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

Linking the ruleset to the hub, with a rule pointing at its own inbound endpoint, creates a loop

The typical design sends the private zone from spokes to the hub's inbound endpoint. If that same ruleset is also linked to the hub VNet, the hub's queries come back to the hub and loop. That's why the module links only spokes. If hub workloads need to resolve corp.example.com, use a second ruleset without the rule to the inbound endpoint.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

A wildcard "." rule must resolve public names, and 168.63.129.16 can't be a target

If you forward everything with ".", the target DNS must resolve public names: several Azure services depend on it. Some reserved Azure domains are ignored by the wildcard rule anyway. Also, 168.63.129.16 isn't accepted as a rule target, and Microsoft advises against the wildcard in a VNet with an ExpressRoute gateway because it can break management connectivity.

⚑ Report outdated
Operations● verified 34 days ago◌ no external reviewer

DNS Private Resolver isn't compatible with Azure Lighthouse or ExpressRoute FastPath

If a partner manages the customer's tenant with Lighthouse, it can't operate the resolver that way: it needs direct access in the tenant. It also doesn't support ExpressRoute FastPath or linking rulesets across tenants. For an MSP that's a day-one design decision, not an implementation detail.

⚑ Report outdated

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# DNS privado híbrido — Azure
#
# DNS Private Resolver en el hub: inbound endpoint para on-premises, outbound
# endpoint + forwarding ruleset para corp.example.com, y una regla que manda la
# zona privada al inbound endpoint para que los spokes la resuelvan sin links.

locals {
  # Las reglas usan nombres terminados en punto.
  onprem_fqdn = "${trimsuffix(var.onprem_domain, ".")}."
  zone_fqdn   = "${trimsuffix(var.private_zone_name, ".")}."
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.name}"
  location = var.location
  tags     = var.tags
}

resource "azurerm_virtual_network" "hub" {
  name                = "vnet-${var.name}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  address_space       = [var.hub_address_space]
  tags                = var.tags
}

# Una subnet por endpoint, delegada y sin otros recursos (gotcha: resolver-dedicated-subnets).
resource "azurerm_subnet" "inbound" {
  name                 = "snet-dns-inbound"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [var.inbound_subnet_prefix]

  delegation {
    name = "dns-resolver"
    service_delegation {
      name    = "Microsoft.Network/dnsResolvers"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

resource "azurerm_subnet" "outbound" {
  name                 = "snet-dns-outbound"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [var.outbound_subnet_prefix]

  delegation {
    name = "dns-resolver"
    service_delegation {
      name    = "Microsoft.Network/dnsResolvers"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

resource "azurerm_network_security_group" "resolver" {
  name                = "nsg-${var.name}-dns"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  tags                = var.tags

  # UDP y TCP: las respuestas grandes caen a TCP.
  security_rule {
    name                       = "dns-from-onprem"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_address_prefixes    = var.onprem_cidrs
    source_port_range          = "*"
    destination_address_prefix = var.inbound_subnet_prefix
    destination_port_range     = "53"
  }
}

resource "azurerm_subnet_network_security_group_association" "inbound" {
  subnet_id                 = azurerm_subnet.inbound.id
  network_security_group_id = azurerm_network_security_group.resolver.id
}

resource "azurerm_subnet_network_security_group_association" "outbound" {
  subnet_id                 = azurerm_subnet.outbound.id
  network_security_group_id = azurerm_network_security_group.resolver.id
}

resource "azurerm_private_dns_resolver" "this" {
  name                = "dnspr-${var.name}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  virtual_network_id  = azurerm_virtual_network.hub.id
  tags                = var.tags
}

# IP estática: es la que se carga en los forwarders condicionales on-premises.
resource "azurerm_private_dns_resolver_inbound_endpoint" "this" {
  name                    = "in-${var.name}"
  private_dns_resolver_id = azurerm_private_dns_resolver.this.id
  location                = azurerm_private_dns_resolver.this.location
  tags                    = var.tags

  ip_configurations {
    subnet_id                    = azurerm_subnet.inbound.id
    private_ip_allocation_method = "Static"
    private_ip_address           = cidrhost(var.inbound_subnet_prefix, 4)
  }
}

resource "azurerm_private_dns_resolver_outbound_endpoint" "this" {
  name                    = "out-${var.name}"
  private_dns_resolver_id = azurerm_private_dns_resolver.this.id
  location                = azurerm_private_dns_resolver.this.location
  subnet_id               = azurerm_subnet.outbound.id
  tags                    = var.tags
}

# --- Zona privada (vinculada solo al hub; el inbound endpoint la resuelve) ------

resource "azurerm_private_dns_zone" "cloud" {
  name                = var.private_zone_name
  resource_group_name = azurerm_resource_group.this.name
  tags                = var.tags
}

resource "azurerm_private_dns_zone_virtual_network_link" "hub" {
  name                 = "hub"
  private_dns_zone_id  = azurerm_private_dns_zone.cloud.id
  virtual_network_id   = azurerm_virtual_network.hub.id
  registration_enabled = false
  tags                 = var.tags
}

# --- Reglas de reenvío -----------------------------------------------------------

resource "azurerm_private_dns_resolver_dns_forwarding_ruleset" "this" {
  name                                       = "dnsfrs-${var.name}"
  resource_group_name                        = azurerm_resource_group.this.name
  location                                   = azurerm_resource_group.this.location
  private_dns_resolver_outbound_endpoint_ids = [azurerm_private_dns_resolver_outbound_endpoint.this.id]
  tags                                       = var.tags
}

resource "azurerm_private_dns_resolver_forwarding_rule" "onprem" {
  name                      = "onprem"
  dns_forwarding_ruleset_id = azurerm_private_dns_resolver_dns_forwarding_ruleset.this.id
  domain_name               = local.onprem_fqdn
  enabled                   = true

  dynamic "target_dns_servers" {
    for_each = var.onprem_dns_servers
    content {
      ip_address = target_dns_servers.value
      port       = 53
    }
  }
}

# Los spokes resuelven la zona privada vía el inbound endpoint del hub.
resource "azurerm_private_dns_resolver_forwarding_rule" "cloud_zone" {
  name                      = "cloud-zone"
  dns_forwarding_ruleset_id = azurerm_private_dns_resolver_dns_forwarding_ruleset.this.id
  domain_name               = local.zone_fqdn
  enabled                   = true

  target_dns_servers {
    ip_address = azurerm_private_dns_resolver_inbound_endpoint.this.ip_configurations[0].private_ip_address
    port       = 53
  }
}

# Solo spokes: linkear el hub, con una regla que apunta a su propio inbound
# endpoint, genera un loop (gotcha: ruleset-hub-loop).
resource "azurerm_private_dns_resolver_virtual_network_link" "spoke" {
  for_each = var.spoke_vnet_ids

  name                      = "spoke-${each.key}"
  dns_forwarding_ruleset_id = azurerm_private_dns_resolver_dns_forwarding_ruleset.this.id
  virtual_network_id        = each.value
}
variables.tf
variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"dns-hub\")."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{3,20}$", var.name))
    error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
  }
}

variable "location" {
  description = "Región del hub. El resolver y los ruleset links solo funcionan con VNets de la misma región."
  type        = string
  default     = "eastus2"
}

variable "hub_address_space" {
  description = "Espacio de direcciones de la VNet hub."
  type        = string
  default     = "10.10.0.0/16"
}

variable "inbound_subnet_prefix" {
  description = "Subnet dedicada al inbound endpoint (/28 a /24)."
  type        = string
  default     = "10.10.0.0/28"

  validation {
    condition     = tonumber(split("/", var.inbound_subnet_prefix)[1]) >= 24 && tonumber(split("/", var.inbound_subnet_prefix)[1]) <= 28
    error_message = "inbound_subnet_prefix: entre /28 y /24 (gotcha: resolver-dedicated-subnets)."
  }
}

variable "outbound_subnet_prefix" {
  description = "Subnet dedicada al outbound endpoint (/28 a /24)."
  type        = string
  default     = "10.10.0.16/28"

  validation {
    condition     = tonumber(split("/", var.outbound_subnet_prefix)[1]) >= 24 && tonumber(split("/", var.outbound_subnet_prefix)[1]) <= 28
    error_message = "outbound_subnet_prefix: entre /28 y /24 (gotcha: resolver-dedicated-subnets)."
  }
}

variable "private_zone_name" {
  description = "Zona privada de Azure que on-premises tiene que resolver (puede ser una privatelink.*)."
  type        = string
  default     = "azure.example.internal"
}

variable "onprem_domain" {
  description = "Dominio corporativo que se reenvía a on-premises."
  type        = string
  default     = "corp.example.com"
}

variable "onprem_dns_servers" {
  description = "IPs de los DNS on-premises (alcanzables por VPN/ExpressRoute)."
  type        = list(string)

  validation {
    condition     = length(var.onprem_dns_servers) >= 1 && length(var.onprem_dns_servers) <= 6 && !contains(var.onprem_dns_servers, "168.63.129.16")
    error_message = "onprem_dns_servers: 1 a 6 IPs, y 168.63.129.16 no se acepta como destino de una regla."
  }
}

variable "onprem_cidrs" {
  description = "Rangos on-premises que consultan el inbound endpoint."
  type        = list(string)
}

variable "spoke_vnet_ids" {
  description = "VNets spoke (misma región) que usan el ruleset. No incluyas el hub: el ruleset reenvía la zona privada al inbound endpoint del hub."
  type        = map(string)
  default     = {}
}

variable "tags" {
  description = "Tags comunes."
  type        = map(string)
  default     = {}
}
outputs.tf
output "inbound_endpoint_ip" {
  description = "IP para los forwarders condicionales on-premises (zona privada → esta IP)."
  value       = azurerm_private_dns_resolver_inbound_endpoint.this.ip_configurations[0].private_ip_address
}

output "hub_vnet_id" {
  description = "VNet hub (a peerear con los spokes y conectar a on-premises)."
  value       = azurerm_virtual_network.hub.id
}

output "ruleset_id" {
  description = "Forwarding ruleset para linkear más spokes de la misma región."
  value       = azurerm_private_dns_resolver_dns_forwarding_ruleset.this.id
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.8"
    }
  }
}

provider "azurerm" {
  features {}

  # subscription_id se toma de ARM_SUBSCRIPTION_ID.
}

Amazon Web Services

Hybrid private DNS (on-premises ↔ cloud) — AWSconditional forwarderresolvescorp.example.comforwards over VPNOn-prem DNSInbound (on-prem → cloud)Route 53 Resolverinbound endpoint4 gotchas in this roleCloud private zoneRoute 53 privatehosted zone2 gotchas in this roleCloud workloadsOutbound (cloud → on-prem)Route 53 Resolveroutbound endpoint +FORWARD rule2 gotchas in this role

Implementation

Inbound (on-prem → cloud)
Route 53 Resolver inbound endpointENIs in two AZs; SG with UDP and TCP 53 from on-premisesComing from Azure: there this role is DNS Private Resolver: inbound endpoint.Coming from GCP: there this role is Cloud DNS inbound server policy.
Cloud private zone
Route 53 private hosted zoneAssociated with the inbound endpoint's VPCComing from Azure: there this role is Azure Private DNS.Coming from GCP: there this role is Cloud DNS private zone.
Outbound (cloud → on-prem)
Route 53 Resolver outbound endpoint + FORWARD ruleRule associated with each VPC; to other accounts via AWS RAMComing from Azure: there this role is DNS Private Resolver: outbound endpoint + forwarding ruleset. The FORWARD rule beats a private hosted zone with the same name; in Azure the linked zone wins over the ruleset.Coming from GCP: there this role is Cloud DNS forwarding zone.

Cost model

Doesn't scale to zero

Fixed cost even with zero traffic:

  • 4 Resolver ENIs (2 inbound + 2 outbound) hourly

The fixed cost comes from the endpoint ENIs; forwarded queries are billed per million. Queries the VPC resolver answers without going through an endpoint aren't charged.

Monthly floor with no traffic

USD 365/ mo

With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.

List price in us-east-1 as of 2026-10-24, in USD, with no discounts or taxes.

⚖ Compare SKUs: Hybrid DNS (on-premises ↔ cloud) →

Fine print · 5 gotchas

Networking● verified 34 days ago◌ no external reviewer

A forward rule beats a private hosted zone with the same name

If a VPC has a FORWARD rule for corp.example.com and also a corp.example.com private hosted zone, the rule wins. Among rules the most specific wins, and a SYSTEM rule excludes a subdomain from forwarding.

⚑ Report outdated
Cost● verified 34 days ago◌ no external reviewer

Each endpoint IP is an ENI billed hourly, and the minimum is two per endpoint

Route 53 Resolver charges per ENI per hour, plus queries, and each endpoint needs at least two IPs in different AZs. Inbound plus outbound is four ENIs billing around the clock even with zero queries. In GCP the inbound policy and forwarding zones have no hourly charge. With several accounts, centralize the endpoints in the hub and share the rules with RAM instead of repeating them.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

It's 10,000 QPS per IP, but with connection tracking it can drop to 1,500

Each endpoint IP handles up to 10,000 UDP queries per second. If restrictive security group rules force connection tracking (or queries go through an NLB), the inbound endpoint can drop to 1,500 QPS per IP. This module restricts the source to on-premises ranges, so plan for the low number: monitor the per-ENI metric and add IPs once it passes 50%.

⚑ Report outdated

Terraform

aws ~> 6.67✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# DNS privado híbrido — AWS
#
# Route 53 Resolver en la VPC hub: inbound endpoint para on-premises, outbound
# endpoint + regla FORWARD para corp.example.com, y la private hosted zone
# asociada a la VPC del inbound endpoint.

locals {
  # TCP además de UDP: respuestas grandes (DNSSEC, muchos registros) caen a TCP.
  dns_protocols = ["udp", "tcp"]
}

resource "aws_security_group" "resolver" {
  name        = "${var.name}-resolver"
  description = "Route 53 Resolver endpoints: DNS desde y hacia on-premises"
  vpc_id      = var.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "dns_from_onprem" {
  for_each = { for pair in setproduct(var.onprem_cidrs, local.dns_protocols) : "${pair[0]}-${pair[1]}" => pair }

  security_group_id = aws_security_group.resolver.id
  description       = "DNS desde on-premises (${each.value[1]})"
  cidr_ipv4         = each.value[0]
  ip_protocol       = each.value[1]
  from_port         = 53
  to_port           = 53
}

resource "aws_vpc_security_group_egress_rule" "dns_to_onprem" {
  for_each = { for pair in setproduct(var.onprem_cidrs, local.dns_protocols) : "${pair[0]}-${pair[1]}" => pair }

  security_group_id = aws_security_group.resolver.id
  description       = "DNS hacia on-premises (${each.value[1]})"
  cidr_ipv4         = each.value[0]
  ip_protocol       = each.value[1]
  from_port         = 53
  to_port           = 53
}

# Cada IP es una ENI que se cobra por hora (gotcha: resolver-endpoints-fixed-cost).
resource "aws_route53_resolver_endpoint" "inbound" {
  name               = "${var.name}-inbound"
  direction          = "INBOUND"
  security_group_ids = [aws_security_group.resolver.id]

  dynamic "ip_address" {
    for_each = var.endpoint_subnet_ids
    content {
      subnet_id = ip_address.value
    }
  }
}

resource "aws_route53_resolver_endpoint" "outbound" {
  name               = "${var.name}-outbound"
  direction          = "OUTBOUND"
  security_group_ids = [aws_security_group.resolver.id]

  dynamic "ip_address" {
    for_each = var.endpoint_subnet_ids
    content {
      subnet_id = ip_address.value
    }
  }
}

resource "aws_route53_resolver_rule" "onprem" {
  name                 = "${var.name}-onprem"
  domain_name          = var.onprem_domain
  rule_type            = "FORWARD"
  resolver_endpoint_id = aws_route53_resolver_endpoint.outbound.id

  dynamic "target_ip" {
    for_each = var.onprem_dns_servers
    content {
      ip   = target_ip.value
      port = 53
    }
  }
}

resource "aws_route53_resolver_rule_association" "this" {
  for_each = merge({ hub = var.vpc_id }, var.spoke_vpc_ids)

  name             = "${var.name}-${each.key}"
  resolver_rule_id = aws_route53_resolver_rule.onprem.id
  vpc_id           = each.value
}

# Tiene que estar asociada a la VPC del inbound endpoint para que on-premises la resuelva.
resource "aws_route53_zone" "cloud" {
  #checkov:skip=CKV2_AWS_38:DNSSEC signing no aplica a private hosted zones.
  #checkov:skip=CKV2_AWS_39:El query logging se configura a nivel VPC (Resolver query logging), fuera del alcance del patrón.
  name = var.private_zone_name

  vpc {
    vpc_id = var.vpc_id
  }
}
variables.tf
variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"dns-hub\")."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{3,20}$", var.name))
    error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
  }
}

variable "region" {
  description = "Región del hub."
  type        = string
  default     = "us-east-1"
}

variable "vpc_id" {
  description = "VPC hub (la que tiene la VPN o el Direct Connect)."
  type        = string
}

variable "endpoint_subnet_ids" {
  description = "Subnets para las ENIs de los endpoints, en al menos dos AZs distintas."
  type        = list(string)

  validation {
    condition     = length(var.endpoint_subnet_ids) >= 2 && length(var.endpoint_subnet_ids) <= 6
    error_message = "endpoint_subnet_ids: entre 2 y 6 (Resolver exige al menos dos IPs por endpoint)."
  }
}

variable "onprem_cidrs" {
  description = "Rangos on-premises que consultan el inbound endpoint y donde viven los DNS destino."
  type        = list(string)
}

variable "onprem_dns_servers" {
  description = "IPs de los DNS on-premises."
  type        = list(string)

  validation {
    condition     = length(var.onprem_dns_servers) >= 1 && length(var.onprem_dns_servers) <= 6
    error_message = "onprem_dns_servers: entre 1 y 6 (máximo de target IPs por regla)."
  }
}

variable "onprem_domain" {
  description = "Dominio corporativo que se reenvía a on-premises."
  type        = string
  default     = "corp.example.com"
}

variable "private_zone_name" {
  description = "Private hosted zone que on-premises tiene que resolver."
  type        = string
  default     = "aws.example.internal"
}

variable "spoke_vpc_ids" {
  description = "VPCs adicionales (misma cuenta y región) que usan la regla de reenvío. Para otras cuentas, compartila con AWS RAM."
  type        = map(string)
  default     = {}
}

variable "tags" {
  description = "Tags comunes."
  type        = map(string)
  default     = {}
}
outputs.tf
output "inbound_endpoint_ips" {
  description = "IPs para los forwarders condicionales on-premises (se conocen después del apply)."
  value       = [for ip in aws_route53_resolver_endpoint.inbound.ip_address : ip.ip]
}

output "resolver_rule_id" {
  description = "Regla de reenvío, para compartir con AWS RAM a otras cuentas."
  value       = aws_route53_resolver_rule.onprem.id
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

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

provider "aws" {
  region = var.region

  default_tags {
    tags = var.tags
  }
}