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