Zero Trust private web API with passwordless PostgreSQL
An HTTP container reachable only from the private network, connecting to PostgreSQL with its workload identity instead of a username and password.
Problem
You need to expose an internal API (consumed by other apps or by users on the corporate network) with a relational database behind it. Requirements: no public IP on any component, no public access to the database, no application passwords stored anywhere (not in variables, secrets or Terraform state) and least privilege for the app's identity.
The topology is the same on every cloud; what changes is what implements each role.
Internal ingress
AWS
Internal Application Load Balancer
HTTPS listener with an ACM certificate
Coming from Azure: there this role is Internal Container Apps environment.
Coming from GCP: there this role is Cloud Run ingress "internal".
1 gotcha in this role
Azure
Internal Container Apps environment
internal_load_balancer_enabled + private DNS zone for the default domain
Coming from AWS: there this role is Internal Application Load Balancer.
Coming from GCP: there this role is Cloud Run ingress "internal".
1 gotcha in this role
Containerized API
AWS
Amazon ECS on Fargate
Private subnets, no public IP, no NAT
Coming from Azure: there this role is Azure Container Apps.
Coming from GCP: there this role is Cloud Run (service).
1 gotcha in this role
Azure
Azure Container Apps
Workload profiles (Consumption), ingress limited to the VNet
Coming from AWS: there this role is Amazon ECS on Fargate.
Coming from GCP: there this role is Cloud Run (service).
1 gotcha in this role
Secretless identity
AWS
IAM Role (ECS task role)
rds-db:connect on one specific database user
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from GCP: there this role is The service's Service Account.
2 gotchas in this role
Azure
User-assigned Managed Identity
PostgreSQL role created with pgaadauth_create_principal
Coming from AWS: there this role is IAM Role (ECS task role).
Coming from GCP: there this role is The service's Service Account.
2 gotchas in this role
Private database access
AWS
RDS in private subnets + security groups
VPC-native (no endpoint needed)
Coming from Azure: there this role is Private Endpoint (subresource postgresqlServer).
Coming from GCP: there this role is Private services access (VPC peering).
No fine print in this role.
Azure
Private Endpoint (subresource postgresqlServer)
+ privatelink.postgres.database.azure.com zone
Coming from AWS: there this role is RDS in private subnets + security groups.
Coming from GCP: there this role is Private services access (VPC peering).
1 gotcha in this role
Managed PostgreSQL
AWS
Amazon RDS for PostgreSQL
iam_database_authentication_enabled
Coming from Azure: there this role is Azure Database for PostgreSQL – Flexible Server. The IAM "token" is a locally generated signature that lasts 15 minutes and only opens the connection, with a new-connections-per-second limit.
Coming from GCP: there this role is Cloud SQL for PostgreSQL.
3 gotchas in this role
Azure
Azure Database for PostgreSQL – Flexible Server
Entra ID only (password_auth_enabled = false)
Coming from AWS: there this role is Amazon RDS for PostgreSQL.
Coming from GCP: there this role is Cloud SQL for PostgreSQL.
4 gotchas in this role
Amazon Web Services
Implementation
- Internal ingress
- Internal Application Load BalancerHTTPS listener with an ACM certificateComing from Azure: there this role is Internal Container Apps environment.Coming from GCP: there this role is Cloud Run ingress "internal".
- Containerized API
- Amazon ECS on FargatePrivate subnets, no public IP, no NATComing from Azure: there this role is Azure Container Apps.Coming from GCP: there this role is Cloud Run (service).
- Secretless identity
- IAM Role (ECS task role)rds-db:connect on one specific database userComing from Azure: there this role is User-assigned Managed Identity.Coming from GCP: there this role is The service's Service Account.
- Private database access
- RDS in private subnets + security groupsVPC-native (no endpoint needed)Coming from Azure: there this role is Private Endpoint (subresource postgresqlServer).Coming from GCP: there this role is Private services access (VPC peering).
- Managed PostgreSQL
- Amazon RDS for PostgreSQLiam_database_authentication_enabledComing from Azure: there this role is Azure Database for PostgreSQL – Flexible Server. The IAM "token" is a locally generated signature that lasts 15 minutes and only opens the connection, with a new-connections-per-second limit.Coming from GCP: there this role is Cloud SQL for PostgreSQL.
Cost model
Doesn't scale to zero
Fixed cost even with zero traffic:
- Application Load Balancer (hourly + LCU)
- ECR api, ECR dkr and CloudWatch Logs interface endpoints (hourly, per AZ)
- RDS (instance + storage)
It's the pattern with the highest fixed cost of the three clouds: ALB and endpoints bill with zero requests, and the ECS service keeps at least one task running.
Monthly floor with no traffic
USD 74.21/ mo
With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.
- Application Load Balancer (hourly, no LCU) × 1 · 0.0225 USD/hUSD 16.43
- Interface VPC endpoint (per AZ) × 6 · 0.01 USD/hUSD 43.80
- RDS PostgreSQL db.t4g.micro (Single-AZ) × 1 · 0.016 USD/hUSD 11.68
- RDS PostgreSQL gp2 storage (GB, Single-AZ) × 20 · 0.115 USD/GB-moUSD 2.30
List price in us-east-1 as of 2026-10-24, in USD, with no discounts or taxes.
Fine print · 5 gotchas
HTTPS on an internal ALB still needs an SSL server certificate
To create an HTTPS listener you must deploy at least one SSL server certificate (from ACM or IAM). ACM cannot validate resources in a VPC private hosted zone or any other private domain, even if the ALB is internal. ACM does not validate domain ownership for imported certificates or for certificates signed by a private CA.
Let Secrets Manager manage the master user password
With manage_master_user_password = true, RDS generates the master password and stores it in Secrets Manager. The Terraform provider doesn't allow combining it with password, and RDS doesn't support it in some scenarios (for example Blue/Green deployments), so decide upfront.
The IAM database user is created with SQL, and with rds_iam it stops accepting passwords
Enabling IAM auth on the instance doesn't create any user. You must run CREATE USER app; GRANT rds_iam TO app; plus table GRANTs. In PostgreSQL, for a user with rds_iam (including master) IAM auth takes precedence over the password: don't grant it to master if you need it as break-glass.
Fargate in a private subnet without NAT needs several paid VPC endpoints
To pull the image without internet egress, the task needs ECR (api and dkr) interface endpoints, plus the S3 gateway endpoint (where ECR stores layers). If it uses the awslogs log driver, it also needs the CloudWatch Logs interface endpoint. Each interface endpoint is billed hourly per AZ. The alternative (NAT Gateway) is also billed hourly and per GB.
The IAM auth token lasts 15 minutes and is only used to open the connection
The token expires after 15 minutes and is used only to authenticate: it doesn't affect the session once established, but if you try to connect with an expired token, the connection is rejected.
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
Implementation
- Internal ingress
- Internal Container Apps environmentinternal_load_balancer_enabled + private DNS zone for the default domainComing from AWS: there this role is Internal Application Load Balancer.Coming from GCP: there this role is Cloud Run ingress "internal".
- Containerized API
- Azure Container AppsWorkload profiles (Consumption), ingress limited to the VNetComing from AWS: there this role is Amazon ECS on Fargate.Coming from GCP: there this role is Cloud Run (service).
- Secretless identity
- User-assigned Managed IdentityPostgreSQL role created with pgaadauth_create_principalComing from AWS: there this role is IAM Role (ECS task role).Coming from GCP: there this role is The service's Service Account.
- Private database access
- Private Endpoint (subresource postgresqlServer)+ privatelink.postgres.database.azure.com zoneComing from AWS: there this role is RDS in private subnets + security groups.Coming from GCP: there this role is Private services access (VPC peering).
- Managed PostgreSQL
- Azure Database for PostgreSQL – Flexible ServerEntra ID only (password_auth_enabled = false)Coming from AWS: there this role is Amazon RDS for PostgreSQL.Coming from GCP: there this role is Cloud SQL for PostgreSQL.
Cost model
Partially scales to zero
Fixed cost even with zero traffic:
- PostgreSQL Flexible Server (compute + storage)
- Database Private Endpoint (hourly + GB)
- Private DNS zones
Container Apps (Consumption profile) scales to zero with no separate ingress cost: the environment's ILB is included. The floor is the database.
Monthly floor with no traffic
USD 24.39/ mo
With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.
- PostgreSQL Flexible Server B1ms (Burstable) × 1 · 0.017 USD/hUSD 12.41
- PostgreSQL Flexible Server storage (GB) × 32 · 0.115 USD/GB-moUSD 3.68
- Private Endpoint × 1 · 0.01 USD/hUSD 7.30
- Private DNS zone × 2 · 0.5 USD/moUSD 1.00
List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.
Fine print · 6 gotchas
ARM adds Entra admins; the app's role is created with SQL
Entra principals are added as administrators from the Azure portal or the ARM API. With isAdmin=true, pgaadauth_create_principal gives them azure_pg_admin, CREATEROLE and CREATEDB. For the app identity to be a regular user, an Entra admin can run pgaadauth_create_principal('<name>', false, false). For managed identities and service principals the name is the display name. The module outputs the exact SQL.
The Entra token for PostgreSQL isn't requested for the server FQDN
The password is an access token whose resource is https://ossrdbms-aad.database.windows.net (public cloud), not the server URL. It's the most common mistake when migrating from RDS, where the "token" is a signature generated locally with IAM credentials. Azure Identity libraries handle it if you request the right scope.
The ACA subnet can't be resized and its minimum size depends on the environment type
A workload profiles environment (the default) needs a dedicated subnet of at least /27 delegated to Microsoft.App/environments; a Consumption-only (legacy) one needs /23 and must NOT be delegated. The size can't be changed after the environment is created, and single-revision deployments temporarily double IP usage. Size for peak replicas, not day one.
An internal environment doesn't resolve its own domain from the VNet
With the ILB, apps live at <app>.<default_domain> but nobody resolves that name until you create a private DNS zone named after the default domain, "*" and "@" A records pointing to the environment's static IP, and link it to the client VNets. Without it, the API "doesn't exist" even though it's running.
In PostgreSQL Flexible, VNet integration vs. Private Endpoint is a one-time choice
The connectivity mode is set at server creation and can't be changed. A VNet-integrated server doesn't support Private Endpoints and can't be moved to another subnet or VNet. This module uses "public access" mode with public_network_access_enabled = false + Private Endpoint, which lets it be consumed from hub-and-spoke, peerings and on-premises with the same model as other PaaS services.
Stopping the database stops compute billing, but it restarts on its own after 7 days
PostgreSQL Flexible bills compute while it's on. Stopping it stops compute billing (not storage), but the service automatically restarts it after 7 days. For dev environments, automate the stop instead of relying on a manual one.
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.
}