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.
Inbound (on-prem → cloud)
AWS
Route 53 Resolver inbound endpoint
ENIs in two AZs; SG with UDP and TCP 53 from on-premises
Coming from Azure: there this role is DNS Private Resolver: inbound endpoint.
Coming from GCP: there this role is Cloud DNS inbound server policy.
4 gotchas in this role
GCP
Cloud DNS inbound server policy
enable_inbound_forwarding: one entry IP per subnet in the VPC
Coming 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.
1 gotcha in this role
Cloud private zone
AWS
Route 53 private hosted zone
Associated with the inbound endpoint's VPC
Coming from Azure: there this role is Azure Private DNS.
Coming from GCP: there this role is Cloud DNS private zone.
2 gotchas in this role
GCP
Cloud DNS private zone
Visible to the hub VPC
Coming from Azure: there this role is Azure Private DNS.
Coming from AWS: there this role is Route 53 private hosted zone.
1 gotcha in this role
Outbound (cloud → on-prem)
AWS
Route 53 Resolver outbound endpoint + FORWARD rule
Rule associated with each VPC; to other accounts via AWS RAM
Coming 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.
2 gotchas in this role
GCP
Cloud DNS forwarding zone
forwarding_path = private + Cloud Router advertising 35.199.192.0/19
Coming 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.
2 gotchas in this role
Amazon Web Services
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.
- Route 53 Resolver — endpoint IP (ENI) × 4 · 0.125 USD/hUSD 365
List price in us-east-1 as of 2026-10-24, in USD, with no discounts or taxes.
Fine print · 5 gotchas
Forwarding from on-premises to the VPC's .2 address isn't supported
AWS states that forwarding private DNS queries to any VPC CIDR base + 2 address from on-premises or other VPC DNS servers isn't supported and can cause unstable results. Instead, AWS recommends using a Resolver inbound endpoint.
The private hosted zone must be associated with the inbound endpoint's VPC
For the VPC Resolver to resolve inbound DNS queries using the records in a private hosted zone, the zone must be associated with the VPC where you created the inbound endpoint.
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.
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.
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%.
Terraform
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
}
}Google Cloud
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.
- Cloud DNS managed zone (private or forwarding) × 2 · 0.2 USD/moUSD 0.40
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.
Fine print · 4 gotchas
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.
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.
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.
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.
Terraform
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
}