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
AWS
Application Load Balancer interno
Listener HTTPS con certificado de ACM
Venís de Azure: allá este rol es Container Apps environment 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
AWS
Amazon ECS on Fargate
Subnets privadas, sin IP pública, sin NAT
Venís de Azure: allá este rol es Azure Container Apps.
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
AWS
IAM Role (ECS task role)
rds-db:connect sobre un usuario de base concreto
Venís de Azure: allá este rol es User-assigned Managed Identity.
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
AWS
RDS en subnets privadas + security groups
Nativo de la VPC (no requiere endpoint)
Venís de Azure: allá este rol es Private Endpoint (subresource postgresqlServer).
Venís de GCP: allá este rol es Private services access (VPC peering).
Sin letra chica 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
AWS
Amazon RDS for PostgreSQL
iam_database_authentication_enabled
Venís de Azure: allá este rol es Azure Database for PostgreSQL – Flexible Server. El "token" de IAM es una firma generada localmente que dura 15 minutos y solo sirve para abrir la conexión, con un límite de conexiones nuevas por segundo.
Venís de GCP: allá este rol es Cloud SQL for PostgreSQL.
3 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
Amazon Web Services
Implementación
- Ingress interno
- Application Load Balancer internoListener HTTPS con certificado de ACMVenís de Azure: allá este rol es Container Apps environment interno.Venís de GCP: allá este rol es Cloud Run ingress "internal".
- API en contenedor
- Amazon ECS on FargateSubnets privadas, sin IP pública, sin NATVenís de Azure: allá este rol es Azure Container Apps.Venís de GCP: allá este rol es Cloud Run (service).
- Identidad sin secretos
- IAM Role (ECS task role)rds-db:connect sobre un usuario de base concretoVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de GCP: allá este rol es Service Account del servicio.
- Acceso privado a la base
- RDS en subnets privadas + security groupsNativo de la VPC (no requiere endpoint)Venís de Azure: allá este rol es Private Endpoint (subresource postgresqlServer).Venís de GCP: allá este rol es Private services access (VPC peering).
- PostgreSQL gestionado
- Amazon RDS for PostgreSQLiam_database_authentication_enabledVenís de Azure: allá este rol es Azure Database for PostgreSQL – Flexible Server. El "token" de IAM es una firma generada localmente que dura 15 minutos y solo sirve para abrir la conexión, con un límite de conexiones nuevas por segundo.Venís de GCP: allá este rol es Cloud SQL for PostgreSQL.
Modelo de costo
No escala a cero
Costo fijo aunque no haya tráfico:
- Application Load Balancer (cargo por hora + LCU)
- Interface endpoints de ECR api, ECR dkr y CloudWatch Logs (por hora, por AZ)
- RDS (instancia + storage)
Es el patrón con más costo fijo de las tres nubes: ALB y endpoints cobran aunque no haya requests, y el servicio de ECS mantiene al menos una tarea corriendo.
Piso mensual sin tráfico
USD 74,21/ mes
Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.
- Application Load Balancer (hora, sin LCU) × 1 · 0.0225 USD/hUSD 16,43
- Interface VPC endpoint (por AZ) × 6 · 0.01 USD/hUSD 43,80
- RDS PostgreSQL db.t4g.micro (Single-AZ) × 1 · 0.016 USD/hUSD 11,68
- Storage gp2 de RDS PostgreSQL (GB, Single-AZ) × 20 · 0.115 USD/GB-mesUSD 2,30
Precio de lista en us-east-1 al 2026-10-24, en USD, sin descuentos ni impuestos.
Letra chica · 5 gotchas
HTTPS en un ALB interno igual necesita un certificado de servidor SSL
Para crear un listener HTTPS hay que desplegar al menos un certificado de servidor SSL (de ACM o de IAM). ACM no puede validar recursos en una private hosted zone de VPC ni en ningún otro dominio privado, aunque el ALB sea interno. ACM no valida la propiedad del dominio en certificados importados ni firmados por una CA privada.
Que la contraseña del master user la gestione Secrets Manager
Con manage_master_user_password = true, RDS genera la contraseña del master y la guarda en Secrets Manager. El provider de Terraform no permite combinarlo con password, y RDS no lo soporta en algunos escenarios (por ejemplo Blue/Green deployments), así que decidilo de entrada.
El usuario IAM de la base se crea con SQL, y con rds_iam deja de aceptar contraseña
Habilitar IAM auth en la instancia no crea ningún usuario. Hay que ejecutar CREATE USER app; GRANT rds_iam TO app; y además los GRANT sobre tablas. En PostgreSQL, a un usuario con rds_iam (incluido el master) la autenticación IAM le gana a la contraseña: no se lo asignes al master si lo necesitás como break-glass.
Fargate en subnet privada sin NAT necesita varios VPC endpoints pagos
Para bajar la imagen sin salida a internet, la tarea necesita interface endpoints de ECR (api y dkr), más el gateway endpoint de S3 (donde ECR guarda las capas). Si usa el driver de logs awslogs, también necesita el interface endpoint de CloudWatch Logs. Cada interface endpoint se cobra por hora por AZ. La alternativa (NAT Gateway) también tiene cargo por hora y por GB.
El token de IAM auth dura 15 minutos y solo sirve para abrir la conexión
El token vence a los 15 minutos y se usa solo para autenticar: no afecta la sesión una vez establecida, pero si intentás conectarte con un token vencido, la conexión se rechaza.
Terraform
main.tf
# Web API privada Zero Trust — AWS
#
# ECS Fargate en subnets privadas (sin NAT) detrás de un ALB interno HTTPS,
# conectándose a RDS for PostgreSQL con IAM database authentication.
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
locals {
private_subnets = { for i, az in var.availability_zones : az => cidrsubnet(var.vpc_cidr, 8, i) }
# Endpoints que necesita Fargate sin salida a internet (gotcha: fargate-private-needs-endpoints).
interface_endpoints = toset(["ecr.api", "ecr.dkr", "logs"])
# Sin contraseña: la app genera el token con las credenciales del task role
# (gotcha: rds-iam-token-lifetime → usar pool de conexiones).
app_environment = [
{ name = "PGHOST", value = aws_db_instance.this.address },
{ name = "PGPORT", value = "5432" },
{ name = "PGUSER", value = var.db_app_user },
{ name = "PGDATABASE", value = var.database_name },
{ name = "PGSSLMODE", value = "verify-full" },
{ name = "AWS_REGION", value = var.region },
]
}
# --- Red ----------------------------------------------------------------------
resource "aws_vpc" "this" {
#checkov:skip=CKV2_AWS_11:Flow logs fuera de alcance del patrón (se cubren en el patrón de networking base).
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "vpc-${var.name}" }
}
resource "aws_default_security_group" "this" {
vpc_id = aws_vpc.this.id
}
resource "aws_subnet" "private" {
for_each = local.private_subnets
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = each.value
tags = { Name = "snet-${var.name}-${each.key}" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
tags = { Name = "rt-${var.name}-private" }
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private.id
}
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
tags = { Name = "vpce-${var.name}-s3" }
}
resource "aws_vpc_endpoint" "interface" {
for_each = local.interface_endpoints
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${var.region}.${each.key}"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [for s in aws_subnet.private : s.id]
security_group_ids = [aws_security_group.endpoints.id]
tags = { Name = "vpce-${var.name}-${each.key}" }
}
# --- Security groups (mínimo privilegio entre capas) --------------------------
resource "aws_security_group" "alb" {
name = "${var.name}-alb"
description = "ALB interno de la API"
vpc_id = aws_vpc.this.id
}
resource "aws_security_group" "app" {
name = "${var.name}-app"
description = "Tareas de ECS de la API"
vpc_id = aws_vpc.this.id
}
resource "aws_security_group" "db" {
name = "${var.name}-db"
description = "RDS PostgreSQL"
vpc_id = aws_vpc.this.id
}
resource "aws_security_group" "endpoints" {
name = "${var.name}-endpoints"
description = "Interface VPC endpoints"
vpc_id = aws_vpc.this.id
}
resource "aws_vpc_security_group_ingress_rule" "alb_from_clients" {
for_each = toset(var.client_cidrs)
security_group_id = aws_security_group.alb.id
description = "HTTPS desde la red privada"
ip_protocol = "tcp"
from_port = 443
to_port = 443
cidr_ipv4 = each.value
}
resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
security_group_id = aws_security_group.alb.id
description = "Hacia las tareas"
ip_protocol = "tcp"
from_port = var.container_port
to_port = var.container_port
referenced_security_group_id = aws_security_group.app.id
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
description = "Desde el ALB"
ip_protocol = "tcp"
from_port = var.container_port
to_port = var.container_port
referenced_security_group_id = aws_security_group.alb.id
}
resource "aws_vpc_security_group_egress_rule" "app_to_db" {
security_group_id = aws_security_group.app.id
description = "PostgreSQL"
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
referenced_security_group_id = aws_security_group.db.id
}
resource "aws_vpc_security_group_egress_rule" "app_to_endpoints" {
security_group_id = aws_security_group.app.id
description = "ECR y CloudWatch Logs por interface endpoints"
ip_protocol = "tcp"
from_port = 443
to_port = 443
referenced_security_group_id = aws_security_group.endpoints.id
}
resource "aws_vpc_security_group_egress_rule" "app_to_s3" {
security_group_id = aws_security_group.app.id
description = "Capas de imagen de ECR en S3 (gateway endpoint)"
ip_protocol = "tcp"
from_port = 443
to_port = 443
prefix_list_id = aws_vpc_endpoint.s3.prefix_list_id
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
description = "PostgreSQL desde la API"
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
referenced_security_group_id = aws_security_group.app.id
}
resource "aws_vpc_security_group_ingress_rule" "endpoints_from_app" {
security_group_id = aws_security_group.endpoints.id
description = "HTTPS desde las tareas"
ip_protocol = "tcp"
from_port = 443
to_port = 443
referenced_security_group_id = aws_security_group.app.id
}
# --- ALB interno --------------------------------------------------------------
resource "aws_lb" "this" {
#checkov:skip=CKV_AWS_91:Access logs fuera de alcance; requieren un bucket S3 dedicado.
#checkov:skip=CKV2_AWS_28:WAF opcional en un ALB interno; evaluar según el modelo de amenazas.
name = "${var.name}-alb"
internal = true
load_balancer_type = "application"
subnets = [for s in aws_subnet.private : s.id]
security_groups = [aws_security_group.alb.id]
drop_invalid_header_fields = true
enable_deletion_protection = true
}
resource "aws_lb_target_group" "api" {
#checkov:skip=CKV_AWS_378:TLS termina en el ALB; el tramo ALB→tarea queda dentro de la VPC y acotado por security groups.
name = "${var.name}-api"
port = var.container_port
protocol = "HTTP"
target_type = "ip"
vpc_id = aws_vpc.this.id
health_check {
path = var.health_check_path
matcher = "200"
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.api.arn
}
}
# --- ECS Fargate --------------------------------------------------------------
resource "aws_ecs_cluster" "this" {
name = "${var.name}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_cloudwatch_log_group" "api" {
#checkov:skip=CKV_AWS_158:Cifrado con la clave administrada por CloudWatch; CMK opcional.
name = "/ecs/${var.name}-api"
retention_in_days = 365
}
resource "aws_iam_role" "execution" {
name = "${var.name}-ecs-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Principal = { Service = "ecs-tasks.amazonaws.com" }, Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy_attachment" "execution" {
role = aws_iam_role.execution.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role" "task" {
name = "${var.name}-api-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Principal = { Service = "ecs-tasks.amazonaws.com" }, Action = "sts:AssumeRole" }]
})
}
# Permiso para generar el token de UN usuario de base, en UNA instancia.
resource "aws_iam_role_policy" "task_rds_connect" {
name = "rds-iam-connect"
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "rds-db:connect"
Resource = "arn:${data.aws_partition.current.partition}:rds-db:${var.region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_db_instance.this.resource_id}/${var.db_app_user}"
}]
})
}
resource "aws_ecs_task_definition" "api" {
family = "${var.name}-api"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([{
name = "api"
image = var.container_image
essential = true
readonlyRootFilesystem = true
portMappings = [{ containerPort = var.container_port, protocol = "tcp" }]
environment = local.app_environment
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.api.name
awslogs-region = var.region
awslogs-stream-prefix = "api"
}
}
}])
}
resource "aws_ecs_service" "api" {
name = "${var.name}-api"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.api.arn
launch_type = "FARGATE"
desired_count = var.desired_count
network_configuration {
subnets = [for s in aws_subnet.private : s.id]
security_groups = [aws_security_group.app.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = var.container_port
}
deployment_circuit_breaker {
enable = true
rollback = true
}
depends_on = [aws_lb_listener.https, aws_vpc_endpoint.interface]
}
# --- RDS PostgreSQL con IAM auth ----------------------------------------------
resource "aws_db_subnet_group" "this" {
name = "${var.name}-db"
subnet_ids = [for s in aws_subnet.private : s.id]
}
resource "aws_db_instance" "this" {
#checkov:skip=CKV_AWS_157:Multi-AZ configurable (db_multi_az); desactivado por default por costo.
#checkov:skip=CKV_AWS_118:Enhanced monitoring requiere un rol extra; Performance Insights cubre el diagnóstico básico.
#checkov:skip=CKV_AWS_354:Performance Insights con la clave administrada por AWS.
#checkov:skip=CKV2_AWS_30:Logging de queries vía parameter group, fuera de alcance del patrón.
identifier = "${var.name}-db"
engine = "postgres"
engine_version = "17"
instance_class = var.db_instance_class
allocated_storage = 20
storage_encrypted = true
db_name = var.database_name
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false
multi_az = var.db_multi_az
iam_database_authentication_enabled = true
# Master user siempre existe: que la contraseña la genere y guarde Secrets Manager
# (gotcha: master-password-in-secrets-manager).
username = "dbadmin"
manage_master_user_password = true
backup_retention_period = 7
copy_tags_to_snapshot = true
deletion_protection = true
final_snapshot_identifier = "${var.name}-db-final"
auto_minor_version_upgrade = true
performance_insights_enabled = true
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
}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 "region" {
description = "Región de AWS."
type = string
default = "us-east-1"
}
variable "availability_zones" {
description = "Dos AZs para las subnets privadas (RDS exige un subnet group en al menos dos)."
type = list(string)
default = ["us-east-1a", "us-east-1b"]
validation {
condition = length(var.availability_zones) == 2
error_message = "Indicá exactamente dos AZs."
}
}
variable "vpc_cidr" {
description = "CIDR de la VPC."
type = string
default = "10.50.0.0/16"
}
variable "client_cidrs" {
description = "CIDRs que pueden llamar a la API (red corporativa, VPCs peereadas)."
type = list(string)
default = ["10.0.0.0/8"]
}
variable "certificate_arn" {
description = "ARN del certificado de ACM para el listener HTTPS (gotcha: internal-alb-certificate)."
type = string
}
variable "container_image" {
description = "Imagen de la API (en ECR, alcanzable por los VPC endpoints)."
type = string
}
variable "container_port" {
description = "Puerto en el que escucha la API."
type = number
default = 8080
}
variable "health_check_path" {
description = "Path del health check del target group."
type = string
default = "/healthz"
}
variable "desired_count" {
description = "Tareas de ECS. Mínimo 1: el servicio no escala a cero."
type = number
default = 1
}
variable "db_instance_class" {
description = "Clase de instancia de RDS."
type = string
default = "db.t4g.micro"
}
variable "db_multi_az" {
description = "Multi-AZ para RDS (duplica el costo de cómputo)."
type = bool
default = false
}
variable "database_name" {
description = "Base de datos de la aplicación."
type = string
default = "app"
}
variable "db_app_user" {
description = "Usuario de base que usa la app con IAM auth (se crea con SQL, ver output app_user_sql)."
type = string
default = "app_iam"
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "alb_dns_name" {
description = "DNS interno del ALB (apuntá un registro de tu zona privada a este nombre)."
value = aws_lb.this.dns_name
}
output "db_address" {
description = "Endpoint de RDS."
value = aws_db_instance.this.address
}
output "master_user_secret_arn" {
description = "Secreto de Secrets Manager con la contraseña del master (solo para administración)."
value = aws_db_instance.this.master_user_secret[0].secret_arn
}
output "app_user_sql" {
description = "SQL que hay que ejecutar como master para crear el usuario IAM de la app (gotcha: rds-iam-user-needs-sql)."
value = <<-SQL
CREATE USER ${var.db_app_user};
GRANT rds_iam TO ${var.db_app_user};
GRANT CONNECT ON DATABASE ${var.database_name} TO ${var.db_app_user};
-- Conectado a la base '${var.database_name}':
GRANT USAGE ON SCHEMA public TO ${var.db_app_user};
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${var.db_app_user};
SQL
}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
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
}