DNS privado híbrido (on-premises ↔ nube)
On-premises resuelve las zonas privadas de la nube (Private Endpoints incluidos) y la nube resuelve el dominio corporativo, sin servidores DNS propios en la nube.
Problema
Con un enlace VPN o dedicado ya hay conectividad IP, pero los nombres no cruzan: on-premises no puede consultar el resolver interno de la nube y los workloads de la nube no conocen corp.example.com. La solución clásica son VMs con DNS en la nube, que hay que parchear y escalar. Los requisitos son: reenvío condicional en las dos direcciones, alta disponibilidad, que los spokes resuelvan sin configuración propia y sin loops de reenvío.
La topología es la misma en todas las nubes; lo que cambia es con qué se implementa cada rol.
Entrada (on-prem → nube)
GCP
Cloud DNS inbound server policy
enable_inbound_forwarding: una IP de entrada por subnet de la VPC
Venís de Azure: allá este rol es DNS Private Resolver: inbound endpoint. No hay endpoints ni subnets dedicadas: la policy toma una IP de cada subnet de la VPC.
Venís de AWS: allá este rol es Route 53 Resolver inbound endpoint. No hay endpoints que dimensionar ni ENIs por hora: la policy toma una IP de cada subnet de la VPC.
1 gotcha en este rol
AWS
Route 53 Resolver inbound endpoint
ENIs en dos AZs; SG con UDP y TCP 53 desde on-premises
Venís de Azure: allá este rol es DNS Private Resolver: inbound endpoint.
Venís de GCP: allá este rol es Cloud DNS inbound server policy.
4 gotchas en este rol
Zona privada de la nube
GCP
Cloud DNS private zone
Visible para la VPC hub
Venís de Azure: allá este rol es Azure Private DNS.
Venís de AWS: allá este rol es Route 53 private hosted zone.
1 gotcha en este rol
AWS
Route 53 private hosted zone
Asociada a la VPC del inbound endpoint
Venís de Azure: allá este rol es Azure Private DNS.
Venís de GCP: allá este rol es Cloud DNS private zone.
2 gotchas en este rol
Salida (nube → on-prem)
GCP
Cloud DNS forwarding zone
forwarding_path = private + Cloud Router anunciando 35.199.192.0/19
Venís de Azure: allá este rol es DNS Private Resolver: outbound endpoint + forwarding ruleset. Las consultas reenviadas salen desde 35.199.192.0/19, no desde IPs de tu VPC: on-premises tiene que permitir ese rango y tener la ruta de vuelta.
Venís de AWS: allá este rol es Route 53 Resolver outbound endpoint + regla FORWARD. Las consultas reenviadas salen desde 35.199.192.0/19, no desde IPs de tu VPC: on-premises tiene que permitir ese rango y tener la ruta de vuelta.
2 gotchas en este rol
AWS
Route 53 Resolver outbound endpoint + regla FORWARD
Regla asociada a cada VPC; a otras cuentas con AWS RAM
Venís de Azure: allá este rol es DNS Private Resolver: outbound endpoint + forwarding ruleset. La regla FORWARD le gana a una private hosted zone con el mismo nombre; en Azure la zona vinculada gana sobre el ruleset.
Venís de GCP: allá este rol es Cloud DNS forwarding zone.
2 gotchas en este rol
Google Cloud
Implementación
- Entrada (on-prem → nube)
- Cloud DNS inbound server policyenable_inbound_forwarding: una IP de entrada por subnet de la VPCVenís de Azure: allá este rol es DNS Private Resolver: inbound endpoint. No hay endpoints ni subnets dedicadas: la policy toma una IP de cada subnet de la VPC.Venís de AWS: allá este rol es Route 53 Resolver inbound endpoint. No hay endpoints que dimensionar ni ENIs por hora: la policy toma una IP de cada subnet de la VPC.
- Zona privada de la nube
- Cloud DNS private zoneVisible para la VPC hubVenís de Azure: allá este rol es Azure Private DNS.Venís de AWS: allá este rol es Route 53 private hosted zone.
- Salida (nube → on-prem)
- Cloud DNS forwarding zoneforwarding_path = private + Cloud Router anunciando 35.199.192.0/19Venís de Azure: allá este rol es DNS Private Resolver: outbound endpoint + forwarding ruleset. Las consultas reenviadas salen desde 35.199.192.0/19, no desde IPs de tu VPC: on-premises tiene que permitir ese rango y tener la ruta de vuelta.Venís de AWS: allá este rol es Route 53 Resolver outbound endpoint + regla FORWARD. Las consultas reenviadas salen desde 35.199.192.0/19, no desde IPs de tu VPC: on-premises tiene que permitir ese rango y tener la ruta de vuelta.
Modelo de costo
Escala a cero parcial
Costo fijo aunque no haya tráfico:
- Zonas administradas (privada y de forwarding), por mes
No hay cargo por hora de endpoints. Pagás cada zona por mes, se use o no, y las consultas por millón. El Cloud Router y la VPN se pagan aparte y suelen existir ya por la conectividad.
Piso mensual sin tráfico
USD 0,40/ mes
Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.
- Zona administrada de Cloud DNS (privada o de forwarding) × 2 · 0.2 USD/mesUSD 0,40
Precio de lista en us-east1 al 2026-10-06, en USD, sin descuentos ni impuestos.
Algún precio se cargó a mano desde la página oficial: la API de esa nube pide key.
Letra chica · 4 gotchas
Con routing estándar, un destino que no es RFC 1918 se consulta por internet
El default de una forwarding zone decide la ruta según la IP del destino: RFC 1918 va por la VPC, cualquier otra va por internet. Si tu DNS on-premises usa IPs públicas reutilizadas internamente o rangos no RFC 1918, las consultas salen a internet y no llegan. forwarding_path = "private" fuerza siempre la VPC.
Hacia targets Type 1 y 2 las consultas salen desde 35.199.192.0/19; con Type 2, on-premises necesita ruta
Cloud DNS usa el rango 35.199.192.0/19 para todos los clientes, y es el origen de las consultas a targets Type 1 y Type 2 (IP interna de VPC y on-premises por VPN o Interconnect). Los targets Type 3 usan los rangos de Google Public DNS, y en Type 4 el rango depende de las IPs a las que resuelve el nombre. Para un target Type 2, on-premises necesita una ruta a 35.199.192.0/19 cuyo next hop esté en la misma VPC, por un túnel Cloud VPN o un attachment de Interconnect. Con BGP eso es un anuncio de ruta custom en la sesión BGP del Cloud Router.
La inbound policy toma una IP de cada subnet, no una por región ni dos por endpoint
No hay endpoints que dimensionar: la policy reserva una IP interna del rango primario de cada subnet de la VPC (menos las de propósito especial), así que una VPC con muchas subnets tiene muchas IPs de entrada. Lo que cuenta para la respuesta es la región del túnel o attachment por donde llegó la consulta, no la de la subnet de la IP. Poné la policy en la VPC que tiene la VPN o el Interconnect.
VPC Network Peering no comparte las zonas privadas; hacen falta peering zones
VPC Network Peering no comparte automáticamente la información de DNS, así que una VPC peereada no resuelve por sí sola las zonas de la VPC hub. En hub-and-spoke, cada spoke necesita una DNS peering zone hacia el hub, y el DNS peering transitivo llega a un solo salto. Es la diferencia con Azure, donde un ruleset se linkea a VNets que ni siquiera están peereadas.
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
}Amazon Web Services
Implementación
- Entrada (on-prem → nube)
- Route 53 Resolver inbound endpointENIs en dos AZs; SG con UDP y TCP 53 desde on-premisesVenís de Azure: allá este rol es DNS Private Resolver: inbound endpoint.Venís de GCP: allá este rol es Cloud DNS inbound server policy.
- Zona privada de la nube
- Route 53 private hosted zoneAsociada a la VPC del inbound endpointVenís de Azure: allá este rol es Azure Private DNS.Venís de GCP: allá este rol es Cloud DNS private zone.
- Salida (nube → on-prem)
- Route 53 Resolver outbound endpoint + regla FORWARDRegla asociada a cada VPC; a otras cuentas con AWS RAMVenís de Azure: allá este rol es DNS Private Resolver: outbound endpoint + forwarding ruleset. La regla FORWARD le gana a una private hosted zone con el mismo nombre; en Azure la zona vinculada gana sobre el ruleset.Venís de GCP: allá este rol es Cloud DNS forwarding zone.
Modelo de costo
No escala a cero
Costo fijo aunque no haya tráfico:
- 4 ENIs de Resolver (2 inbound + 2 outbound) por hora
El costo fijo lo ponen las ENIs de los endpoints; las consultas reenviadas se cobran por millón. Las consultas que resuelve el resolver de la VPC sin pasar por un endpoint no se cobran.
Piso mensual sin tráfico
USD 365/ mes
Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.
- Route 53 Resolver — IP de endpoint (ENI) × 4 · 0.125 USD/hUSD 365
Precio de lista en us-east-1 al 2026-10-24, en USD, sin descuentos ni impuestos.
Letra chica · 5 gotchas
Reenviar desde on-premises a la IP .2 de la VPC no está soportado
AWS indica que reenviar consultas DNS privadas a cualquier dirección base del CIDR de la VPC + 2 desde on-premises u otros servidores DNS de VPC no está soportado y puede dar resultados inestables. En su lugar, AWS recomienda usar un inbound endpoint de Resolver.
La private hosted zone tiene que estar asociada a la VPC del inbound endpoint
Para que el Resolver de la VPC resuelva las consultas entrantes con los registros de una private hosted zone, la zona tiene que estar asociada a la VPC donde creaste el inbound endpoint.
Una regla de forward le gana a una private hosted zone con el mismo nombre
Si en una VPC hay una regla FORWARD para corp.example.com y también una private hosted zone corp.example.com, gana la regla. Entre reglas gana la más específica, y con una regla SYSTEM excluís un subdominio del reenvío.
Cada IP de un endpoint es una ENI que se paga por hora, y el mínimo son dos por endpoint
Route 53 Resolver cobra por ENI por hora, además de las consultas, y cada endpoint necesita al menos dos IPs en AZs distintas. Entrada más salida son cuatro ENIs facturando las 24 horas aunque no haya una sola consulta. En GCP la inbound policy y las forwarding zones no tienen cargo por hora. Si tenés varias cuentas, centralizá los endpoints en el hub y compartí las reglas con RAM en lugar de repetirlos.
Son 10.000 QPS por IP, pero con connection tracking pueden ser 1.500
Cada IP de un endpoint procesa hasta 10.000 consultas UDP por segundo. Si las reglas restrictivas del security group fuerzan connection tracking (o las consultas pasan por un NLB), el inbound endpoint puede bajar a 1.500 QPS por IP. Este módulo limita el origen a los rangos on-premises, así que contá con el número bajo: monitoreá la métrica por ENI y agregá IPs cuando pase el 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
}
}