Web API privada Zero Trust con PostgreSQL sin contraseñas
Un contenedor HTTP alcanzable solo desde la red privada, que se conecta a PostgreSQL con su identidad de workload en vez de usuario y contraseña.
Problema
Necesitás publicar una API interna (consumida por otras apps o por usuarios en la red corporativa) con una base relacional detrás. Requisitos: sin IP pública en ningún componente, base de datos sin acceso público, sin contraseñas de aplicación guardadas en ningún lado (ni en variables, ni en secretos, ni en el estado de Terraform) y permisos mínimos para la identidad de la app.
La topología es la misma en todas las nubes; lo que cambia es con qué se implementa cada rol.
Ingress interno
Azure
Container Apps environment interno
internal_load_balancer_enabled + zona DNS privada del default domain
Venís de AWS: allá este rol es Application Load Balancer interno.
Venís de GCP: allá este rol es Cloud Run ingress "internal".
1 gotcha en este rol
GCP
Cloud Run ingress "internal"
INGRESS_TRAFFIC_INTERNAL_ONLY + roles/run.invoker
Venís de Azure: allá este rol es Container Apps environment interno. "Internal" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.
Venís de AWS: allá este rol es Application Load Balancer interno. "Internal" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.
1 gotcha en este rol
API en contenedor
Azure
Azure Container Apps
Workload profiles (Consumption), ingress limitado a la VNet
Venís de AWS: allá este rol es Amazon ECS on Fargate.
Venís de GCP: allá este rol es Cloud Run (service).
1 gotcha en este rol
GCP
Cloud Run (service)
Direct VPC egress hacia rangos privados
Venís de Azure: allá este rol es Azure Container Apps.
Venís de AWS: allá este rol es Amazon ECS on Fargate.
1 gotcha en este rol
Identidad sin secretos
Azure
User-assigned Managed Identity
Rol en PostgreSQL creado con pgaadauth_create_principal
Venís de AWS: allá este rol es IAM Role (ECS task role).
Venís de GCP: allá este rol es Service Account del servicio.
2 gotchas en este rol
GCP
Service Account del servicio
roles/cloudsql.client + roles/cloudsql.instanceUser
Venís de Azure: allá este rol es User-assigned Managed Identity.
Venís de AWS: allá este rol es IAM Role (ECS task role).
3 gotchas en este rol
Acceso privado a la base
Azure
Private Endpoint (subresource postgresqlServer)
+ zona privatelink.postgres.database.azure.com
Venís de AWS: allá este rol es RDS en subnets privadas + security groups.
Venís de GCP: allá este rol es Private services access (VPC peering).
1 gotcha en este rol
GCP
Private services access (VPC peering)≈ Equivalente parcial
ipv4_enabled = false
La IP privada llega por un VPC peering que no es transitivo; en hub-and-spoke conviene Private Service Connect.
Venís de Azure: allá este rol es Private Endpoint (subresource postgresqlServer). No es un Private Endpoint: es un VPC peering hacia la red de Google, que no es transitivo.
Venís de AWS: allá este rol es RDS en subnets privadas + security groups.
2 gotchas en este rol
PostgreSQL gestionado
Azure
Azure Database for PostgreSQL – Flexible Server
Solo Entra ID (password_auth_enabled = false)
Venís de AWS: allá este rol es Amazon RDS for PostgreSQL.
Venís de GCP: allá este rol es Cloud SQL for PostgreSQL.
4 gotchas en este rol
GCP
Cloud SQL for PostgreSQL
cloudsql.iam_authentication = on + connector obligatorio
Venís de Azure: allá este rol es Azure Database for PostgreSQL – Flexible Server. El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin ".gserviceaccount.com".
Venís de AWS: allá este rol es Amazon RDS for PostgreSQL. El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin ".gserviceaccount.com".
2 gotchas en este rol
Microsoft Azure
Implementación
- Ingress interno
- Container Apps environment internointernal_load_balancer_enabled + zona DNS privada del default domainVenís de AWS: allá este rol es Application Load Balancer interno.Venís de GCP: allá este rol es Cloud Run ingress "internal".
- API en contenedor
- Azure Container AppsWorkload profiles (Consumption), ingress limitado a la VNetVenís de AWS: allá este rol es Amazon ECS on Fargate.Venís de GCP: allá este rol es Cloud Run (service).
- Identidad sin secretos
- User-assigned Managed IdentityRol en PostgreSQL creado con pgaadauth_create_principalVenís de AWS: allá este rol es IAM Role (ECS task role).Venís de GCP: allá este rol es Service Account del servicio.
- Acceso privado a la base
- Private Endpoint (subresource postgresqlServer)+ zona privatelink.postgres.database.azure.comVenís de AWS: allá este rol es RDS en subnets privadas + security groups.Venís de GCP: allá este rol es Private services access (VPC peering).
- PostgreSQL gestionado
- Azure Database for PostgreSQL – Flexible ServerSolo Entra ID (password_auth_enabled = false)Venís de AWS: allá este rol es Amazon RDS for PostgreSQL.Venís de GCP: allá este rol es Cloud SQL for PostgreSQL.
Modelo de costo
Escala a cero parcial
Costo fijo aunque no haya tráfico:
- PostgreSQL Flexible Server (cómputo + storage)
- Private Endpoint de la base (cargo por hora + GB)
- Zonas DNS privadas
Container Apps (perfil Consumption) escala a cero y no tiene costo de ingress aparte: el ILB del environment está incluido. El piso lo pone la base de datos.
Piso mensual sin tráfico
USD 24,39/ mes
Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.
- PostgreSQL Flexible Server B1ms (Burstable) × 1 · 0.017 USD/hUSD 12,41
- Storage de PostgreSQL Flexible Server (GB) × 32 · 0.115 USD/GB-mesUSD 3,68
- Private Endpoint × 1 · 0.01 USD/hUSD 7,30
- Zona DNS privada × 2 · 0.5 USD/mesUSD 1,00
Precio de lista en eastus al 2026-10-24, en USD, sin descuentos ni impuestos.
Letra chica · 6 gotchas
Por ARM se agregan administradores Entra; el rol de la app se crea con SQL
Desde el portal de Azure o la ARM API se agregan principals Entra como administradores. Con isAdmin=true, pgaadauth_create_principal les da azure_pg_admin, CREATEROLE y CREATEDB. Para que la identidad de la app sea un usuario común, un admin Entra puede ejecutar pgaadauth_create_principal('<nombre>', false, false). Para identidades administradas y service principals el nombre es el display name. El módulo deja el SQL exacto como output.
El token de Entra para PostgreSQL no se pide para el FQDN del servidor
La contraseña es un access token cuyo resource es https://ossrdbms-aad.database.windows.net (en nube pública), no la URL del servidor. Es el error más común al migrar desde RDS, donde el "token" es una firma generada localmente con las credenciales IAM. Las librerías de Azure Identity lo resuelven si pedís el scope correcto.
La subnet de ACA no se puede agrandar y su tamaño mínimo depende del tipo de environment
Un environment con workload profiles (el default) necesita una subnet dedicada de al menos /27 delegada a Microsoft.App/environments; uno Consumption-only (legacy) necesita /23 y NO puede estar delegada. El tamaño no se puede cambiar después de crear el environment, y en despliegues single-revision el consumo de IPs se duplica temporalmente. Dimensioná pensando en el pico de réplicas, no en el día uno.
Un environment interno no resuelve su propio dominio desde la VNet
Con el ILB, las apps quedan en <app>.<default_domain> pero nadie resuelve ese nombre hasta que creás una zona DNS privada con el nombre del default domain, registros A "*" y "@" al static IP del environment, y la vinculás a las VNets cliente. Sin eso, la API "no existe" aunque esté corriendo.
En PostgreSQL Flexible, VNet integration vs. Private Endpoint se elige una sola vez
El modo de conectividad se define al crear el servidor y no se puede cambiar. Un servidor con VNet integration no admite Private Endpoints y no se puede mover a otra subnet o VNet. Este módulo usa modo "acceso público" con public_network_access_enabled = false + Private Endpoint, que es lo que permite consumirlo desde hub-and-spoke, peerings y on-premises con el mismo modelo que el resto de los PaaS.
Detener la base corta el cómputo, pero arranca sola a los 7 días
PostgreSQL Flexible factura cómputo mientras está encendido. Detenerlo corta el cobro de cómputo (no el de storage), pero el servicio lo vuelve a arrancar automáticamente a los 7 días. Para entornos de desarrollo, automatizá el stop en vez de confiar en un stop manual.
Terraform
main.tf
# Web API privada Zero Trust — Azure
#
# Container Apps en un environment interno (ILB) con identidad administrada,
# conectándose por Private Endpoint a PostgreSQL Flexible Server que solo
# acepta autenticación Entra ID (sin contraseñas).
data "azurerm_client_config" "current" {}
resource "azurerm_resource_group" "this" {
name = "rg-${var.name}"
location = var.location
tags = var.tags
}
# --- Red ----------------------------------------------------------------------
resource "azurerm_virtual_network" "this" {
name = "vnet-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = [var.vnet_address_space]
tags = var.tags
}
# Dedicada al environment y delegada (gotcha: aca-subnet-sizing).
resource "azurerm_subnet" "aca" {
name = "snet-aca"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.aca_subnet_prefix]
delegation {
name = "aca"
service_delegation {
name = "Microsoft.App/environments"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
resource "azurerm_subnet" "endpoints" {
name = "snet-private-endpoints"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.endpoints_subnet_prefix]
}
resource "azurerm_network_security_group" "this" {
for_each = { aca = azurerm_subnet.aca.id, endpoints = azurerm_subnet.endpoints.id }
name = "nsg-${var.name}-${each.key}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = { aca = azurerm_subnet.aca.id, endpoints = azurerm_subnet.endpoints.id }
subnet_id = each.value
network_security_group_id = azurerm_network_security_group.this[each.key].id
}
# --- Container Apps (environment interno) -------------------------------------
resource "azurerm_container_app_environment" "this" {
name = "cae-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
infrastructure_subnet_id = azurerm_subnet.aca.id
internal_load_balancer_enabled = true
workload_profile {
name = "Consumption"
workload_profile_type = "Consumption"
}
tags = var.tags
depends_on = [azurerm_subnet_network_security_group_association.this]
}
# Sin esta zona nadie resuelve <app>.<default_domain> (gotcha: aca-internal-needs-private-dns).
resource "azurerm_private_dns_zone" "aca" {
name = azurerm_container_app_environment.this.default_domain
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_private_dns_a_record" "aca" {
for_each = toset(["*", "@"])
name = each.key
private_dns_zone_id = azurerm_private_dns_zone.aca.id
ttl = 300
records = [azurerm_container_app_environment.this.static_ip_address]
}
resource "azurerm_private_dns_zone_virtual_network_link" "aca" {
name = "link-${var.name}-aca"
private_dns_zone_id = azurerm_private_dns_zone.aca.id
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_user_assigned_identity" "api" {
name = "id-${var.name}-api"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_container_app" "api" {
name = "ca-${var.name}-api"
resource_group_name = azurerm_resource_group.this.name
container_app_environment_id = azurerm_container_app_environment.this.id
revision_mode = "Single"
workload_profile_name = "Consumption"
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.api.id]
}
ingress {
# En un environment interno, external_enabled = true significa "toda la VNet";
# false limita el acceso a otras apps del mismo environment.
external_enabled = true
target_port = var.container_port
allow_insecure_connections = false
traffic_weight {
latest_revision = true
percentage = 100
}
}
template {
min_replicas = 0
max_replicas = var.max_replicas
container {
name = "api"
image = var.container_image
cpu = 0.25
memory = "0.5Gi"
# Sin contraseña: la app pide un token para ossrdbms-aad con esta identidad
# (gotcha: pg-entra-token-resource).
env {
name = "AZURE_CLIENT_ID"
value = azurerm_user_assigned_identity.api.client_id
}
env {
name = "PGHOST"
value = azurerm_postgresql_flexible_server.this.fqdn
}
env {
name = "PGUSER"
value = azurerm_user_assigned_identity.api.name
}
env {
name = "PGDATABASE"
value = var.database_name
}
env {
name = "PGSSLMODE"
value = "require"
}
}
}
tags = var.tags
}
# --- PostgreSQL (solo Entra ID, sin acceso público) ---------------------------
resource "azurerm_postgresql_flexible_server" "this" {
#checkov:skip=CKV_AZURE_136:Backup geo-redundante opcional; duplica costo de backup. Activar en producción crítica.
name = var.postgres_server_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
version = "17"
sku_name = var.postgres_sku
storage_mb = 32768
backup_retention_days = 7
geo_redundant_backup_enabled = false
public_network_access_enabled = false
# Modo "acceso público" + Private Endpoint: no se puede cambiar después
# (gotcha: pg-network-mode-is-permanent).
authentication {
active_directory_auth_enabled = true
password_auth_enabled = false
tenant_id = data.azurerm_client_config.current.tenant_id
}
tags = var.tags
lifecycle {
ignore_changes = [zone]
}
}
resource "azurerm_postgresql_flexible_server_active_directory_administrator" "this" {
server_name = azurerm_postgresql_flexible_server.this.name
resource_group_name = azurerm_resource_group.this.name
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = var.entra_admin.object_id
principal_name = var.entra_admin.principal_name
principal_type = var.entra_admin.principal_type
}
resource "azurerm_postgresql_flexible_server_database" "app" {
name = var.database_name
server_id = azurerm_postgresql_flexible_server.this.id
collation = "en_US.utf8"
charset = "UTF8"
}
resource "azurerm_private_dns_zone" "postgres" {
name = "privatelink.postgres.database.azure.com"
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
name = "link-${var.name}-postgres"
private_dns_zone_id = azurerm_private_dns_zone.postgres.id
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_private_endpoint" "postgres" {
name = "pe-${var.name}-postgres"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
subnet_id = azurerm_subnet.endpoints.id
private_service_connection {
name = "psc-${var.name}-postgres"
private_connection_resource_id = azurerm_postgresql_flexible_server.this.id
subresource_names = ["postgresqlServer"]
is_manual_connection = false
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.postgres.id]
}
tags = var.tags
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"api-demo\")."
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 de Azure."
type = string
default = "eastus2"
}
variable "postgres_server_name" {
description = "Nombre global único del servidor PostgreSQL (3-63, minúsculas, números y guiones)."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.postgres_server_name))
error_message = "postgres_server_name: 3-63 caracteres, minúsculas, números o guiones."
}
}
variable "postgres_sku" {
description = "SKU de PostgreSQL Flexible Server."
type = string
default = "B_Standard_B1ms"
}
variable "database_name" {
description = "Base de datos de la aplicación."
type = string
default = "app"
}
variable "entra_admin" {
description = "Administrador Entra de PostgreSQL (recomendado: un grupo). Es quien ejecuta el SQL que crea el rol de la app."
type = object({
object_id = string
principal_name = string
principal_type = string
})
validation {
condition = contains(["Group", "User", "ServicePrincipal"], var.entra_admin.principal_type)
error_message = "principal_type: Group, User o ServicePrincipal."
}
}
variable "container_image" {
description = "Imagen de la API."
type = string
default = "mcr.microsoft.com/k8se/quickstart:latest"
}
variable "container_port" {
description = "Puerto en el que escucha la API."
type = number
default = 80
}
variable "vnet_address_space" {
description = "Espacio de direcciones de la VNet."
type = string
default = "10.40.0.0/16"
}
variable "aca_subnet_prefix" {
description = "Subnet dedicada al environment (mínimo /27 con workload profiles; no se puede agrandar después)."
type = string
default = "10.40.0.0/24"
}
variable "endpoints_subnet_prefix" {
description = "Subnet para Private Endpoints."
type = string
default = "10.40.1.0/24"
}
variable "max_replicas" {
description = "Máximo de réplicas de la API."
type = number
default = 10
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "api_fqdn" {
description = "FQDN interno de la API (resuelve vía la zona DNS privada del environment)."
value = azurerm_container_app.api.ingress[0].fqdn
}
output "postgres_fqdn" {
description = "FQDN del servidor PostgreSQL (resuelve a la IP del Private Endpoint desde la VNet)."
value = azurerm_postgresql_flexible_server.this.fqdn
}
output "app_role_sql" {
description = "SQL que un admin Entra tiene que ejecutar para crear el rol de la app con permisos mínimos (gotcha: pg-entra-app-role-needs-sql)."
value = <<-SQL
-- Conectado a la base 'postgres' como administrador Entra:
SELECT * FROM pgaadauth_create_principal('${azurerm_user_assigned_identity.api.name}', false, false);
-- Conectado a la base '${var.database_name}':
GRANT CONNECT ON DATABASE "${var.database_name}" TO "${azurerm_user_assigned_identity.api.name}";
GRANT USAGE ON SCHEMA public TO "${azurerm_user_assigned_identity.api.name}";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${azurerm_user_assigned_identity.api.name}";
SQL
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# Con shared_access_key_enabled = false el provider tiene que usar Entra ID
# para las llamadas de data plane (gotcha: shared-key-needs-azuread-provider).
storage_use_azuread = true
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}Google Cloud
Implementación
- Ingress interno
- Cloud Run ingress "internal"INGRESS_TRAFFIC_INTERNAL_ONLY + roles/run.invokerVenís de Azure: allá este rol es Container Apps environment interno. "Internal" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.Venís de AWS: allá este rol es Application Load Balancer interno. "Internal" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.
- API en contenedor
- Cloud Run (service)Direct VPC egress hacia rangos privadosVenís de Azure: allá este rol es Azure Container Apps.Venís de AWS: allá este rol es Amazon ECS on Fargate.
- Identidad sin secretos
- Service Account del servicioroles/cloudsql.client + roles/cloudsql.instanceUserVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de AWS: allá este rol es IAM Role (ECS task role).
- Acceso privado a la base
- Private services access (VPC peering)≈ipv4_enabled = falseLa IP privada llega por un VPC peering que no es transitivo; en hub-and-spoke conviene Private Service Connect.Venís de Azure: allá este rol es Private Endpoint (subresource postgresqlServer). No es un Private Endpoint: es un VPC peering hacia la red de Google, que no es transitivo.Venís de AWS: allá este rol es RDS en subnets privadas + security groups.
- PostgreSQL gestionado
- Cloud SQL for PostgreSQLcloudsql.iam_authentication = on + connector obligatorioVenís de Azure: allá este rol es Azure Database for PostgreSQL – Flexible Server. El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin ".gserviceaccount.com".Venís de AWS: allá este rol es Amazon RDS for PostgreSQL. El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin ".gserviceaccount.com".
Modelo de costo
Escala a cero parcial
Costo fijo aunque no haya tráfico:
- Instancia de Cloud SQL (cómputo + storage)
Cloud Run escala a cero y Direct VPC egress no tiene cargo propio (sí el tráfico). El piso es Cloud SQL, que factura mientras la instancia está activa.
Piso mensual sin tráfico
USD 51,01/ mes
Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.
- Cloud SQL PostgreSQL Enterprise, zonal (por vCPU) × 1 · 0.0413 USD/hUSD 30,15
- Cloud SQL PostgreSQL Enterprise, zonal (por GiB de RAM) × 3.75 · 0.0070 USD/hUSD 19,16
- Storage SSD de Cloud SQL, zonal (GiB) × 10 · 0.17 USD/GB-mesUSD 1,70
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 · 5 gotchas
Forzá el uso de connectors para que nadie se conecte "por fuera"
La autenticación IAM automática (sin manejar tokens a mano) la hacen Cloud SQL Auth Proxy o los Cloud SQL Language Connectors. connector_enforcement = REQUIRED rechaza toda conexión directa a la IP, así el único camino es con identidad y TLS gestionados.
En Postgres, el usuario IAM de una Service Account va sin ".gserviceaccount.com"
El nombre del usuario de base es el email de la SA sin el sufijo .gserviceaccount.com (ej. api@proyecto.iam), por el límite de largo de nombres de PostgreSQL. Si lo creás con el email completo, el login falla con un error que parece de permisos.
A diferencia de Azure y AWS, el login IAM se crea por API, sin SQL
google_sql_user con type CLOUD_IAM_SERVICE_ACCOUNT crea el usuario desde Terraform, y además necesitás roles/cloudsql.client y roles/cloudsql.instanceUser en la SA. Lo que sigue siendo SQL son los permisos sobre tablas (GRANT), igual que en las otras nubes.
"Internal" es un control de red; la autenticación es IAM aparte
Con ingress internal, Cloud Run solo acepta requests de fuentes como las VPCs del mismo proyecto, un perímetro de VPC SC o un internal Application Load Balancer, y de productos como Cloud Scheduler, Cloud Tasks, Eventarc, Pub/Sub o Workflows si están en el mismo proyecto o perímetro y usan la URL run.app por defecto. Las demás fuentes, incluida internet, no llegan. Pero la autenticación IAM sigue aplicando: el cliente tiene que presentar un ID token y su cuenta de servicio necesita roles/run.invoker.
La IP privada de Cloud SQL viaja por un VPC peering que no es transitivo
Private services access se implementa con VPC Network Peering hacia la red de Google. Solo permite conexiones desde una VPC, y el peering no ofrece ruteo transitivo: otra VPC peereada con la tuya no llega a la base. Si tu topología es hub-and-spoke, evaluá Private Service Connect en lugar de PSA.
Terraform
main.tf
# Web API privada Zero Trust — GCP
#
# Cloud Run con ingress interno e IAM invoker, saliendo por Direct VPC egress
# hacia Cloud SQL for PostgreSQL con IP privada (PSA) y autenticación IAM.
# --- Red ----------------------------------------------------------------------
resource "google_compute_network" "this" {
name = "vpc-${var.name}"
auto_create_subnetworks = false
}
resource "google_compute_firewall" "deny_all_ingress" {
name = "fw-${var.name}-deny-all-ingress"
network = google_compute_network.this.id
direction = "INGRESS"
priority = 65534
deny {
protocol = "all"
}
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_subnetwork" "run" {
name = "snet-${var.name}-run"
region = var.region
network = google_compute_network.this.id
ip_cidr_range = var.subnet_cidr
private_ip_google_access = true
log_config {
aggregation_interval = "INTERVAL_10_MIN"
flow_sampling = 0.5
metadata = "INCLUDE_ALL_METADATA"
}
}
# Private services access: peering hacia la red de Google (gotcha: psa-peering-not-transitive).
resource "google_compute_global_address" "psa" {
name = "psa-${var.name}"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = var.psa_prefix_length
network = google_compute_network.this.id
}
resource "google_service_networking_connection" "psa" {
network = google_compute_network.this.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.psa.name]
}
# --- Cloud SQL (solo IP privada, IAM auth, connectors obligatorios) -----------
resource "google_sql_database_instance" "this" {
#checkov:skip=CKV_GCP_110:pgAudit agrega volumen y costo de logs; habilitar si hay requisito de auditoría.
#checkov:skip=CKV_GCP_79:Major version alineada entre las tres nubes del patrón (PostgreSQL 17); se sube en conjunto.
#checkov:skip=CKV2_GCP_13:log_duration en todas las sentencias es ruidoso; se usa log_min_duration_statement.
name = "pg-${var.name}"
region = var.region
database_version = "POSTGRES_17"
deletion_protection = var.deletion_protection
settings {
edition = "ENTERPRISE"
tier = var.db_tier
availability_type = "ZONAL"
# Solo connectors (Auth Proxy / Language Connectors) — gotcha: connector-enforcement.
connector_enforcement = "REQUIRED"
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.this.id
# Los connectors usan mTLS con certificados efímeros, así que se puede exigir
# certificado de cliente sin distribuir certificados a mano.
ssl_mode = "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"
}
backup_configuration {
enabled = true
point_in_time_recovery_enabled = true
}
database_flags {
name = "cloudsql.iam_authentication"
value = "on"
}
database_flags {
name = "log_connections"
value = "on"
}
database_flags {
name = "log_disconnections"
value = "on"
}
database_flags {
name = "log_checkpoints"
value = "on"
}
database_flags {
name = "log_lock_waits"
value = "on"
}
database_flags {
name = "log_temp_files"
value = "0"
}
database_flags {
name = "log_min_duration_statement"
value = "-1"
}
database_flags {
name = "log_hostname"
value = "on"
}
database_flags {
name = "log_min_messages"
value = "error"
}
database_flags {
name = "log_min_error_statement"
value = "error"
}
database_flags {
name = "log_statement"
value = "ddl"
}
user_labels = var.labels
}
depends_on = [google_service_networking_connection.psa]
}
resource "google_sql_database" "app" {
name = var.database_name
instance = google_sql_database_instance.this.name
}
# --- Identidad de la API --------------------------------------------------------
resource "google_service_account" "api" {
account_id = "${var.name}-api"
display_name = "API privada (${var.name})"
}
resource "google_project_iam_member" "api" {
for_each = toset(["roles/cloudsql.client", "roles/cloudsql.instanceUser"])
project = var.project_id
role = each.value
member = google_service_account.api.member
}
# Login IAM creado por API, sin SQL. El nombre va SIN ".gserviceaccount.com"
# (gotchas: iam-sa-username-trim, iam-user-created-by-api).
resource "google_sql_user" "api" {
name = trimsuffix(google_service_account.api.email, ".gserviceaccount.com")
instance = google_sql_database_instance.this.name
type = "CLOUD_IAM_SERVICE_ACCOUNT"
}
# --- Cloud Run (ingress interno + IAM invoker) --------------------------------
resource "google_cloud_run_v2_service" "api" {
name = "${var.name}-api"
location = var.region
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"
deletion_protection = var.deletion_protection
labels = var.labels
template {
service_account = google_service_account.api.email
scaling {
min_instance_count = 0
max_instance_count = var.max_instances
}
vpc_access {
egress = "PRIVATE_RANGES_ONLY"
network_interfaces {
network = google_compute_network.this.id
subnetwork = google_compute_subnetwork.run.id
}
}
containers {
image = var.container_image
ports {
container_port = var.container_port
}
# La app usa el Cloud SQL Language Connector con IAM auth automática:
# no hay contraseña ni token manual.
env {
name = "INSTANCE_CONNECTION_NAME"
value = google_sql_database_instance.this.connection_name
}
env {
name = "DB_IAM_USER"
value = google_sql_user.api.name
}
env {
name = "DB_NAME"
value = var.database_name
}
env {
name = "DB_IP_TYPE"
value = "PRIVATE"
}
}
}
}
# Sin allUsers: cada caller necesita roles/run.invoker (validado en la variable).
resource "google_cloud_run_v2_service_iam_member" "invoker" {
for_each = var.invoker_members
name = google_cloud_run_v2_service.api.name
location = google_cloud_run_v2_service.api.location
role = "roles/run.invoker"
member = each.value
}variables.tf
variable "project_id" {
description = "ID del proyecto de GCP."
type = string
}
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"api-demo\")."
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 "region" {
description = "Región de GCP."
type = string
default = "us-central1"
}
variable "subnet_cidr" {
description = "CIDR de la subnet usada por Direct VPC egress."
type = string
default = "10.60.0.0/24"
}
variable "psa_prefix_length" {
description = "Tamaño del rango reservado para private services access (Cloud SQL)."
type = number
default = 20
}
variable "container_image" {
description = "Imagen de la API (Artifact Registry)."
type = string
}
variable "container_port" {
description = "Puerto en el que escucha la API."
type = number
default = 8080
}
variable "max_instances" {
description = "Máximo de instancias de Cloud Run."
type = number
default = 10
}
variable "invoker_members" {
description = "Principals con roles/run.invoker (ej. \"serviceAccount:caller@proyecto.iam.gserviceaccount.com\"). Gotcha: run-internal-is-not-auth."
type = set(string)
default = []
validation {
condition = alltrue([for m in var.invoker_members : !contains(["allUsers", "allAuthenticatedUsers"], m)])
error_message = "Una API privada no puede ser invocable por allUsers ni allAuthenticatedUsers."
}
}
variable "db_tier" {
description = "Tier de Cloud SQL (edición Enterprise)."
type = string
default = "db-custom-1-3840"
}
variable "database_name" {
description = "Base de datos de la aplicación."
type = string
default = "app"
}
variable "deletion_protection" {
description = "Protección contra borrado de Cloud SQL y Cloud Run."
type = bool
default = true
}
variable "labels" {
description = "Labels aplicados a los recursos que los soportan."
type = map(string)
default = {}
}outputs.tf
output "service_uri" {
description = "URL run.app del servicio (solo alcanzable con ingress interno + ID token)."
value = google_cloud_run_v2_service.api.uri
}
output "instance_connection_name" {
description = "Connection name de Cloud SQL para el connector."
value = google_sql_database_instance.this.connection_name
}
output "db_iam_user" {
description = "Usuario de base de la Service Account (sin .gserviceaccount.com)."
value = google_sql_user.api.name
}
output "app_grants_sql" {
description = "Permisos sobre tablas: siguen siendo SQL (gotcha: iam-user-created-by-api)."
value = <<-SQL
-- Conectado a la base '${var.database_name}' como un usuario con privilegios:
GRANT USAGE ON SCHEMA public TO "${google_sql_user.api.name}";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${google_sql_user.api.name}";
SQL
}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
}