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.

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

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
}