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.

Google Cloud

Zero Trust private web API with passwordless PostgreSQL — GCPHTTPStokenPostgreSQL + TLSClient on the private networkInternal ingressCloud Run ingress"internal"1 gotcha in this roleContainerized APICloud Run (service)1 gotcha in this roleSecretless identityThe service's ServiceAccount3 gotchas in this rolePrivate database access ≈Private servicesaccess (VPC peering)2 gotchas in this roleManaged PostgreSQLCloud SQL forPostgreSQL2 gotchas in this role

Implementation

Internal ingress
Cloud Run ingress "internal"INGRESS_TRAFFIC_INTERNAL_ONLY + roles/run.invokerComing from Azure: there this role is Internal Container Apps environment. "Internal" is a network control, and Cloud Run also requires IAM (roles/run.invoker) by default: the client sends an ID token.Coming from AWS: there this role is Internal Application Load Balancer. "Internal" is a network control, and Cloud Run also requires IAM (roles/run.invoker) by default: the client sends an ID token.
Containerized API
Cloud Run (service)Direct VPC egress to private rangesComing from Azure: there this role is Azure Container Apps.Coming from AWS: there this role is Amazon ECS on Fargate.
Secretless identity
The service's Service Accountroles/cloudsql.client + roles/cloudsql.instanceUserComing from Azure: there this role is User-assigned Managed Identity.Coming from AWS: there this role is IAM Role (ECS task role).
Private database access
Private services access (VPC peering)≈ipv4_enabled = falseThe private IP comes over a VPC peering that isn't transitive; in hub-and-spoke, Private Service Connect is a better fit.Coming from Azure: there this role is Private Endpoint (subresource postgresqlServer). It isn't a Private Endpoint: it's a VPC peering to Google's network, which isn't transitive.Coming from AWS: there this role is RDS in private subnets + security groups.
Managed PostgreSQL
Cloud SQL for PostgreSQLcloudsql.iam_authentication = on + connector requiredComing from Azure: there this role is Azure Database for PostgreSQL – Flexible Server. The IAM user is created via API, without SQL, and a Service Account's user drops ".gserviceaccount.com".Coming from AWS: there this role is Amazon RDS for PostgreSQL. The IAM user is created via API, without SQL, and a Service Account's user drops ".gserviceaccount.com".

Cost model

Partially scales to zero

Fixed cost even with zero traffic:

  • Cloud SQL instance (compute + storage)

Cloud Run scales to zero and Direct VPC egress has no charge of its own (traffic does). The floor is Cloud SQL, which bills while the instance is active.

Monthly floor with no traffic

USD 51.01/ mo

With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.

List price in us-east1 as of 2026-10-06, in USD, with no discounts or taxes.

Some prices were entered by hand from the official page: that cloud’s API needs a key.

⚖ Compare SKUs: Managed PostgreSQL →

Fine print · 5 gotchas

Security● verified 34 days ago◌ no external reviewer

Enforce connectors so nobody connects "around" them

Automatic IAM authentication (without handling tokens manually) is done by Cloud SQL Auth Proxy or the Cloud SQL Language Connectors. connector_enforcement = REQUIRED rejects every direct connection to the IP, so the only path is with managed identity and TLS.

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

In Postgres, a Service Account's IAM user drops ".gserviceaccount.com"

The database user name is the SA email without the .gserviceaccount.com suffix (e.g. api@project.iam), due to PostgreSQL's name length limit. If you create it with the full email, login fails with an error that looks like a permissions problem.

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

Unlike Azure and AWS, the IAM login is created via API, without SQL

google_sql_user with type CLOUD_IAM_SERVICE_ACCOUNT creates the user from Terraform, and you also need roles/cloudsql.client and roles/cloudsql.instanceUser on the SA. What remains SQL are table permissions (GRANT), same as on the other clouds.

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

"Internal" is a network control; authentication is separate IAM

With internal ingress, Cloud Run only accepts requests from sources such as VPCs in the same project, a VPC SC perimeter or an internal Application Load Balancer, and from products such as Cloud Scheduler, Cloud Tasks, Eventarc, Pub/Sub or Workflows if they're in the same project or perimeter and use the default run.app URL. Other sources, including the internet, can't reach it. But IAM authentication still applies: the client must present an ID token and its service account needs roles/run.invoker.

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

Cloud SQL's private IP goes over a VPC peering that isn't transitive

Private services access is implemented with VPC Network Peering to Google's network. It only allows connections from one VPC, and peering doesn't provide transitive routing: another VPC peered with yours can't reach the database. If your topology is hub-and-spoke, consider Private Service Connect instead of PSA.

⚑ Report outdated

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Web API privada Zero Trust — GCP
#
# Cloud Run con ingress interno e IAM invoker, saliendo por Direct VPC egress
# hacia Cloud SQL for PostgreSQL con IP privada (PSA) y autenticación IAM.

# --- Red ----------------------------------------------------------------------

resource "google_compute_network" "this" {
  name                    = "vpc-${var.name}"
  auto_create_subnetworks = false
}

resource "google_compute_firewall" "deny_all_ingress" {
  name      = "fw-${var.name}-deny-all-ingress"
  network   = google_compute_network.this.id
  direction = "INGRESS"
  priority  = 65534

  deny {
    protocol = "all"
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_subnetwork" "run" {
  name                     = "snet-${var.name}-run"
  region                   = var.region
  network                  = google_compute_network.this.id
  ip_cidr_range            = var.subnet_cidr
  private_ip_google_access = true

  log_config {
    aggregation_interval = "INTERVAL_10_MIN"
    flow_sampling        = 0.5
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

# Private services access: peering hacia la red de Google (gotcha: psa-peering-not-transitive).
resource "google_compute_global_address" "psa" {
  name          = "psa-${var.name}"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = var.psa_prefix_length
  network       = google_compute_network.this.id
}

resource "google_service_networking_connection" "psa" {
  network                 = google_compute_network.this.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.psa.name]
}

# --- Cloud SQL (solo IP privada, IAM auth, connectors obligatorios) -----------

resource "google_sql_database_instance" "this" {
  #checkov:skip=CKV_GCP_110:pgAudit agrega volumen y costo de logs; habilitar si hay requisito de auditoría.
  #checkov:skip=CKV_GCP_79:Major version alineada entre las tres nubes del patrón (PostgreSQL 17); se sube en conjunto.
  #checkov:skip=CKV2_GCP_13:log_duration en todas las sentencias es ruidoso; se usa log_min_duration_statement.
  name                = "pg-${var.name}"
  region              = var.region
  database_version    = "POSTGRES_17"
  deletion_protection = var.deletion_protection

  settings {
    edition           = "ENTERPRISE"
    tier              = var.db_tier
    availability_type = "ZONAL"

    # Solo connectors (Auth Proxy / Language Connectors) — gotcha: connector-enforcement.
    connector_enforcement = "REQUIRED"

    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.this.id
      # Los connectors usan mTLS con certificados efímeros, así que se puede exigir
      # certificado de cliente sin distribuir certificados a mano.
      ssl_mode = "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"
    }

    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
    }

    database_flags {
      name  = "cloudsql.iam_authentication"
      value = "on"
    }
    database_flags {
      name  = "log_connections"
      value = "on"
    }
    database_flags {
      name  = "log_disconnections"
      value = "on"
    }
    database_flags {
      name  = "log_checkpoints"
      value = "on"
    }
    database_flags {
      name  = "log_lock_waits"
      value = "on"
    }
    database_flags {
      name  = "log_temp_files"
      value = "0"
    }
    database_flags {
      name  = "log_min_duration_statement"
      value = "-1"
    }
    database_flags {
      name  = "log_hostname"
      value = "on"
    }
    database_flags {
      name  = "log_min_messages"
      value = "error"
    }
    database_flags {
      name  = "log_min_error_statement"
      value = "error"
    }
    database_flags {
      name  = "log_statement"
      value = "ddl"
    }

    user_labels = var.labels
  }

  depends_on = [google_service_networking_connection.psa]
}

resource "google_sql_database" "app" {
  name     = var.database_name
  instance = google_sql_database_instance.this.name
}

# --- Identidad de la API --------------------------------------------------------

resource "google_service_account" "api" {
  account_id   = "${var.name}-api"
  display_name = "API privada (${var.name})"
}

resource "google_project_iam_member" "api" {
  for_each = toset(["roles/cloudsql.client", "roles/cloudsql.instanceUser"])
  project  = var.project_id
  role     = each.value
  member   = google_service_account.api.member
}

# Login IAM creado por API, sin SQL. El nombre va SIN ".gserviceaccount.com"
# (gotchas: iam-sa-username-trim, iam-user-created-by-api).
resource "google_sql_user" "api" {
  name     = trimsuffix(google_service_account.api.email, ".gserviceaccount.com")
  instance = google_sql_database_instance.this.name
  type     = "CLOUD_IAM_SERVICE_ACCOUNT"
}

# --- Cloud Run (ingress interno + IAM invoker) --------------------------------

resource "google_cloud_run_v2_service" "api" {
  name                = "${var.name}-api"
  location            = var.region
  ingress             = "INGRESS_TRAFFIC_INTERNAL_ONLY"
  deletion_protection = var.deletion_protection
  labels              = var.labels

  template {
    service_account = google_service_account.api.email

    scaling {
      min_instance_count = 0
      max_instance_count = var.max_instances
    }

    vpc_access {
      egress = "PRIVATE_RANGES_ONLY"
      network_interfaces {
        network    = google_compute_network.this.id
        subnetwork = google_compute_subnetwork.run.id
      }
    }

    containers {
      image = var.container_image

      ports {
        container_port = var.container_port
      }

      # La app usa el Cloud SQL Language Connector con IAM auth automática:
      # no hay contraseña ni token manual.
      env {
        name  = "INSTANCE_CONNECTION_NAME"
        value = google_sql_database_instance.this.connection_name
      }
      env {
        name  = "DB_IAM_USER"
        value = google_sql_user.api.name
      }
      env {
        name  = "DB_NAME"
        value = var.database_name
      }
      env {
        name  = "DB_IP_TYPE"
        value = "PRIVATE"
      }
    }
  }
}

# Sin allUsers: cada caller necesita roles/run.invoker (validado en la variable).
resource "google_cloud_run_v2_service_iam_member" "invoker" {
  for_each = var.invoker_members
  name     = google_cloud_run_v2_service.api.name
  location = google_cloud_run_v2_service.api.location
  role     = "roles/run.invoker"
  member   = each.value
}
variables.tf
variable "project_id" {
  description = "ID del proyecto de GCP."
  type        = string
}

variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"api-demo\")."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,19}$", var.name))
    error_message = "name: 3-20 caracteres, empieza con letra, minúsculas, números o guiones."
  }
}

variable "region" {
  description = "Región de GCP."
  type        = string
  default     = "us-central1"
}

variable "subnet_cidr" {
  description = "CIDR de la subnet usada por Direct VPC egress."
  type        = string
  default     = "10.60.0.0/24"
}

variable "psa_prefix_length" {
  description = "Tamaño del rango reservado para private services access (Cloud SQL)."
  type        = number
  default     = 20
}

variable "container_image" {
  description = "Imagen de la API (Artifact Registry)."
  type        = string
}

variable "container_port" {
  description = "Puerto en el que escucha la API."
  type        = number
  default     = 8080
}

variable "max_instances" {
  description = "Máximo de instancias de Cloud Run."
  type        = number
  default     = 10
}

variable "invoker_members" {
  description = "Principals con roles/run.invoker (ej. \"serviceAccount:caller@proyecto.iam.gserviceaccount.com\"). Gotcha: run-internal-is-not-auth."
  type        = set(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.invoker_members : !contains(["allUsers", "allAuthenticatedUsers"], m)])
    error_message = "Una API privada no puede ser invocable por allUsers ni allAuthenticatedUsers."
  }
}

variable "db_tier" {
  description = "Tier de Cloud SQL (edición Enterprise)."
  type        = string
  default     = "db-custom-1-3840"
}

variable "database_name" {
  description = "Base de datos de la aplicación."
  type        = string
  default     = "app"
}

variable "deletion_protection" {
  description = "Protección contra borrado de Cloud SQL y Cloud Run."
  type        = bool
  default     = true
}

variable "labels" {
  description = "Labels aplicados a los recursos que los soportan."
  type        = map(string)
  default     = {}
}
outputs.tf
output "service_uri" {
  description = "URL run.app del servicio (solo alcanzable con ingress interno + ID token)."
  value       = google_cloud_run_v2_service.api.uri
}

output "instance_connection_name" {
  description = "Connection name de Cloud SQL para el connector."
  value       = google_sql_database_instance.this.connection_name
}

output "db_iam_user" {
  description = "Usuario de base de la Service Account (sin .gserviceaccount.com)."
  value       = google_sql_user.api.name
}

output "app_grants_sql" {
  description = "Permisos sobre tablas: siguen siendo SQL (gotcha: iam-user-created-by-api)."
  value       = <<-SQL
    -- Conectado a la base '${var.database_name}' como un usuario con privilegios:
    GRANT USAGE ON SCHEMA public TO "${google_sql_user.api.name}";
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${google_sql_user.api.name}";
  SQL
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.6"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

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