ESEN

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.

Amazon Web Services

Zero Trust private web API with passwordless PostgreSQL — AWSHTTPStokenPostgreSQL + TLSClient on the private networkInternal ingressInternal ApplicationLoad Balancer1 gotcha in this roleContainerized APIAmazon ECS on Fargate1 gotcha in this roleSecretless identityIAM Role (ECS taskrole)2 gotchas in this rolePrivate database accessRDS in private subnets+ security groupsManaged PostgreSQLAmazon RDS forPostgreSQL3 gotchas in this role

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.

List price in us-east-1 as of 2026-10-24, in USD, with no discounts or taxes.

⚖ Compare SKUs: Managed PostgreSQL →

Fine print · 5 gotchas

Security● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Security● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Cost● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated

Terraform

aws ~> 6.67✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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

Zero Trust private web API with passwordless PostgreSQL — AzureHTTPStokenPostgreSQL + TLSClient on the private networkInternal ingressInternal ContainerApps environment1 gotcha in this roleContainerized APIAzure Container Apps1 gotcha in this roleSecretless identityUser-assigned ManagedIdentity2 gotchas in this rolePrivate database accessPrivate Endpoint(subresourcepostgresqlServer)1 gotcha in this roleManaged PostgreSQLAzure Database forPostgreSQL – FlexibleServer4 gotchas in this role

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.

List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.

⚖ Compare SKUs: Managed PostgreSQL →

Fine print · 6 gotchas

IAM● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Networking● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Networking● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Networking● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Cost● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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.
}