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.

Google Cloud

Hybrid private DNS (on-premises ↔ cloud) — GCPconditional forwarderresolvescorp.example.comforwards over VPNOn-prem DNSInbound (on-prem → cloud)Cloud DNS inboundserver policy1 gotcha in this roleCloud private zoneCloud DNS private zone1 gotcha in this roleCloud workloadsOutbound (cloud → on-prem)Cloud DNS forwardingzone2 gotchas in this role

Implementation

Inbound (on-prem → cloud)
Cloud DNS inbound server policyenable_inbound_forwarding: one entry IP per subnet in the VPCComing from Azure: there this role is DNS Private Resolver: inbound endpoint. There are no endpoints or dedicated subnets: the policy takes one IP from every subnet in the VPC.Coming from AWS: there this role is Route 53 Resolver inbound endpoint. There are no endpoints to size and no hourly ENIs: the policy takes one IP from every subnet in the VPC.
Cloud private zone
Cloud DNS private zoneVisible to the hub VPCComing from Azure: there this role is Azure Private DNS.Coming from AWS: there this role is Route 53 private hosted zone.
Outbound (cloud → on-prem)
Cloud DNS forwarding zoneforwarding_path = private + Cloud Router advertising 35.199.192.0/19Coming from Azure: there this role is DNS Private Resolver: outbound endpoint + forwarding ruleset. Forwarded queries come from 35.199.192.0/19, not from your VPC's IPs: on-premises must allow that range and have a return route.Coming from AWS: there this role is Route 53 Resolver outbound endpoint + FORWARD rule. Forwarded queries come from 35.199.192.0/19, not from your VPC's IPs: on-premises must allow that range and have a return route.

Cost model

Partially scales to zero

Fixed cost even with zero traffic:

  • Managed zones (private and forwarding), monthly

There's no hourly endpoint charge. You pay each zone monthly, used or not, and queries per million. The Cloud Router and VPN are billed separately and usually already exist for connectivity.

Monthly floor with no traffic

USD 0.40/ mo

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

List price in us-east1 as of 2026-10-06, in USD, with no discounts or taxes.

Some prices were entered by hand from the official page: that cloud’s API needs a key.

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

Fine print · 4 gotchas

Security● verified 34 days ago◌ no external reviewer

With standard routing, a non-RFC 1918 target is queried over the internet

A forwarding zone's default picks the path by target IP: RFC 1918 goes through the VPC, anything else goes over the internet. If your on-premises DNS uses internally reused public IPs or non-RFC 1918 ranges, queries go out to the internet and never arrive. forwarding_path = "private" always forces the VPC.

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

To Type 1 and 2 targets queries come from 35.199.192.0/19; with Type 2, on-premises needs a route to it

Cloud DNS uses the 35.199.192.0/19 range for all customers, and it is the source of queries to Type 1 and Type 2 targets (VPC internal IPs and on-premises over VPN or Interconnect). Type 3 targets use Google Public DNS source ranges, and for Type 4 the range depends on the IPs the name resolves to. For a Type 2 target, on-premises needs a route to 35.199.192.0/19 whose next hop is in the same VPC, through a Cloud VPN tunnel or a Cloud Interconnect VLAN attachment. With BGP that's a custom route advertisement on the Cloud Router's BGP session.

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

The inbound policy takes one IP from every subnet, not one per region or two per endpoint

There are no endpoints to size: the policy reserves one internal IP from the primary range of every subnet in the VPC (except special-purpose ones), so a VPC with many subnets has many entry IPs. What matters for the response is the region of the tunnel or attachment the query arrived on, not the subnet's region. Put the policy on the VPC that has the VPN or Interconnect.

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

VPC Network Peering doesn't share private zones; you need peering zones

VPC Network Peering doesn't automatically share DNS information, so a peered VPC doesn't resolve the hub VPC's zones on its own. In hub-and-spoke, each spoke needs a DNS peering zone to the hub, and transitive DNS peering only goes one hop. That's the difference from Azure, where a ruleset links to VNets that aren't even peered.

⚑ Report outdated

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# DNS privado híbrido — GCP
#
# Inbound server policy (on-premises consulta las IPs que toma de cada subnet),
# forwarding zone privada para corp.example.com y el Cloud Router anunciando
# 35.199.192.0/19, el rango desde el que Cloud DNS envía las consultas reenviadas.

locals {
  # Origen de toda consulta que Cloud DNS reenvía: on-premises necesita la ruta de vuelta.
  cloud_dns_source_range = "35.199.192.0/19"
  onprem_fqdn            = "${trimsuffix(var.onprem_domain, ".")}."
  zone_fqdn              = "${trimsuffix(var.private_zone_name, ".")}."
}

resource "google_dns_policy" "inbound" {
  project                   = var.project_id
  name                      = "${var.name}-inbound"
  description               = "Permite que on-premises consulte las zonas privadas de esta VPC"
  enable_inbound_forwarding = true
  enable_logging            = true

  networks {
    network_url = var.network_id
  }
}

resource "google_dns_managed_zone" "onprem" {
  #checkov:skip=CKV_GCP_16:DNSSEC no aplica a una forwarding zone privada (las respuestas las da on-premises).
  project     = var.project_id
  name        = "${var.name}-onprem"
  dns_name    = local.onprem_fqdn
  description = "Reenvía ${var.onprem_domain} a los DNS on-premises"
  visibility  = "private"

  private_visibility_config {
    networks {
      network_url = var.network_id
    }
  }

  forwarding_config {
    dynamic "target_name_servers" {
      for_each = var.onprem_dns_servers
      content {
        ipv4_address = target_name_servers.value
        # Siempre por la VPC, aunque el destino no sea RFC 1918 (gotcha: forwarding-path-private).
        forwarding_path = "private"
      }
    }
  }
}

resource "google_dns_managed_zone" "cloud" {
  #checkov:skip=CKV_GCP_16:DNSSEC no aplica a zonas privadas en Cloud DNS.
  project     = var.project_id
  name        = "${var.name}-cloud"
  dns_name    = local.zone_fqdn
  description = "Zona privada resoluble desde on-premises vía la inbound policy"
  visibility  = "private"

  private_visibility_config {
    networks {
      network_url = var.network_id
    }
  }
}

# Sin este anuncio, on-premises recibe la consulta pero la respuesta no vuelve
# (gotcha: return-route-35-199-192-0-19).
resource "google_compute_router" "hybrid" {
  project = var.project_id
  name    = "${var.name}-router"
  region  = var.region
  network = var.network_id

  bgp {
    asn               = var.router_asn
    advertise_mode    = "CUSTOM"
    advertised_groups = ["ALL_SUBNETS"]

    advertised_ip_ranges {
      range       = local.cloud_dns_source_range
      description = "Origen de las consultas reenviadas por Cloud DNS"
    }
  }
}
variables.tf
variable "project_id" {
  description = "Proyecto host de la VPC hub."
  type        = string
}

variable "region" {
  description = "Región del Cloud Router y de la VPN/Interconnect."
  type        = string
  default     = "us-central1"
}

variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"dns-hub\")."
  type        = string

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

variable "network_id" {
  description = "ID de la VPC hub (projects/<p>/global/networks/<n>), la que tiene la VPN o el Interconnect."
  type        = string
}

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

  validation {
    condition     = length(var.onprem_dns_servers) >= 1
    error_message = "onprem_dns_servers: al menos uno."
  }
}

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

variable "private_zone_name" {
  description = "Zona privada que on-premises tiene que resolver."
  type        = string
  default     = "gcp.example.internal"
}

variable "router_asn" {
  description = "ASN privado del Cloud Router que habla BGP con on-premises."
  type        = number
  default     = 64514
}
outputs.tf
output "cloud_dns_source_range" {
  description = "Rango a permitir (UDP y TCP 53) en el firewall on-premises y a rutear de vuelta por la VPN/Interconnect."
  value       = local.cloud_dns_source_range
}

output "inbound_forwarder_hint" {
  description = "Las IPs de entrada se ven después del apply con este comando."
  value       = "gcloud compute addresses list --project ${var.project_id} --filter='purpose = \"DNS_RESOLVER\"'"
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.6"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

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.
}