Zero Trust private web API with passwordless PostgreSQL
An HTTP container reachable only from the private network, connecting to PostgreSQL with its workload identity instead of a username and password.
Problem
You need to expose an internal API (consumed by other apps or by users on the corporate network) with a relational database behind it. Requirements: no public IP on any component, no public access to the database, no application passwords stored anywhere (not in variables, secrets or Terraform state) and least privilege for the app's identity.
The topology is the same on every cloud; what changes is what implements each role.
Internal ingress
GCP
Cloud Run ingress "internal"
INGRESS_TRAFFIC_INTERNAL_ONLY + roles/run.invoker
Coming 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.
1 gotcha in this role
Azure
Internal Container Apps environment
internal_load_balancer_enabled + private DNS zone for the default domain
Coming from AWS: there this role is Internal Application Load Balancer.
Coming from GCP: there this role is Cloud Run ingress "internal".
1 gotcha in this role
Containerized API
GCP
Cloud Run (service)
Direct VPC egress to private ranges
Coming from Azure: there this role is Azure Container Apps.
Coming from AWS: there this role is Amazon ECS on Fargate.
1 gotcha in this role
Azure
Azure Container Apps
Workload profiles (Consumption), ingress limited to the VNet
Coming from AWS: there this role is Amazon ECS on Fargate.
Coming from GCP: there this role is Cloud Run (service).
1 gotcha in this role
Secretless identity
GCP
The service's Service Account
roles/cloudsql.client + roles/cloudsql.instanceUser
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from AWS: there this role is IAM Role (ECS task role).
3 gotchas in this role
Azure
User-assigned Managed Identity
PostgreSQL role created with pgaadauth_create_principal
Coming from AWS: there this role is IAM Role (ECS task role).
Coming from GCP: there this role is The service's Service Account.
2 gotchas in this role
Private database access
GCP
Private services access (VPC peering)≈ Partial equivalent
ipv4_enabled = false
The 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.
2 gotchas in this role
Azure
Private Endpoint (subresource postgresqlServer)
+ privatelink.postgres.database.azure.com zone
Coming from AWS: there this role is RDS in private subnets + security groups.
Coming from GCP: there this role is Private services access (VPC peering).
1 gotcha in this role
Managed PostgreSQL
GCP
Cloud SQL for PostgreSQL
cloudsql.iam_authentication = on + connector required
Coming 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".
2 gotchas in this role
Azure
Azure Database for PostgreSQL – Flexible Server
Entra ID only (password_auth_enabled = false)
Coming from AWS: there this role is Amazon RDS for PostgreSQL.
Coming from GCP: there this role is Cloud SQL for PostgreSQL.
4 gotchas in this role
Google Cloud
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.
- Cloud SQL PostgreSQL Enterprise, zonal (per vCPU) × 1 · 0.0413 USD/hUSD 30.15
- Cloud SQL PostgreSQL Enterprise, zonal (per GiB of RAM) × 3.75 · 0.0070 USD/hUSD 19.16
- Cloud SQL SSD storage, zonal (GiB) × 10 · 0.17 USD/GB-moUSD 1.70
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.
Fine print · 5 gotchas
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.
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.
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.
"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.
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.
Terraform
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
}Microsoft Azure
Implementation
- Internal ingress
- Internal Container Apps environmentinternal_load_balancer_enabled + private DNS zone for the default domainComing from AWS: there this role is Internal Application Load Balancer.Coming from GCP: there this role is Cloud Run ingress "internal".
- Containerized API
- Azure Container AppsWorkload profiles (Consumption), ingress limited to the VNetComing from AWS: there this role is Amazon ECS on Fargate.Coming from GCP: there this role is Cloud Run (service).
- Secretless identity
- User-assigned Managed IdentityPostgreSQL role created with pgaadauth_create_principalComing from AWS: there this role is IAM Role (ECS task role).Coming from GCP: there this role is The service's Service Account.
- Private database access
- Private Endpoint (subresource postgresqlServer)+ privatelink.postgres.database.azure.com zoneComing from AWS: there this role is RDS in private subnets + security groups.Coming from GCP: there this role is Private services access (VPC peering).
- Managed PostgreSQL
- Azure Database for PostgreSQL – Flexible ServerEntra ID only (password_auth_enabled = false)Coming from AWS: there this role is Amazon RDS for PostgreSQL.Coming from GCP: there this role is Cloud SQL for PostgreSQL.
Cost model
Partially scales to zero
Fixed cost even with zero traffic:
- PostgreSQL Flexible Server (compute + storage)
- Database Private Endpoint (hourly + GB)
- Private DNS zones
Container Apps (Consumption profile) scales to zero with no separate ingress cost: the environment's ILB is included. The floor is the database.
Monthly floor with no traffic
USD 24.39/ mo
With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.
- PostgreSQL Flexible Server B1ms (Burstable) × 1 · 0.017 USD/hUSD 12.41
- PostgreSQL Flexible Server storage (GB) × 32 · 0.115 USD/GB-moUSD 3.68
- Private Endpoint × 1 · 0.01 USD/hUSD 7.30
- Private DNS zone × 2 · 0.5 USD/moUSD 1.00
List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.
Fine print · 6 gotchas
ARM adds Entra admins; the app's role is created with SQL
Entra principals are added as administrators from the Azure portal or the ARM API. With isAdmin=true, pgaadauth_create_principal gives them azure_pg_admin, CREATEROLE and CREATEDB. For the app identity to be a regular user, an Entra admin can run pgaadauth_create_principal('<name>', false, false). For managed identities and service principals the name is the display name. The module outputs the exact SQL.
The Entra token for PostgreSQL isn't requested for the server FQDN
The password is an access token whose resource is https://ossrdbms-aad.database.windows.net (public cloud), not the server URL. It's the most common mistake when migrating from RDS, where the "token" is a signature generated locally with IAM credentials. Azure Identity libraries handle it if you request the right scope.
The ACA subnet can't be resized and its minimum size depends on the environment type
A workload profiles environment (the default) needs a dedicated subnet of at least /27 delegated to Microsoft.App/environments; a Consumption-only (legacy) one needs /23 and must NOT be delegated. The size can't be changed after the environment is created, and single-revision deployments temporarily double IP usage. Size for peak replicas, not day one.
An internal environment doesn't resolve its own domain from the VNet
With the ILB, apps live at <app>.<default_domain> but nobody resolves that name until you create a private DNS zone named after the default domain, "*" and "@" A records pointing to the environment's static IP, and link it to the client VNets. Without it, the API "doesn't exist" even though it's running.
In PostgreSQL Flexible, VNet integration vs. Private Endpoint is a one-time choice
The connectivity mode is set at server creation and can't be changed. A VNet-integrated server doesn't support Private Endpoints and can't be moved to another subnet or VNet. This module uses "public access" mode with public_network_access_enabled = false + Private Endpoint, which lets it be consumed from hub-and-spoke, peerings and on-premises with the same model as other PaaS services.
Stopping the database stops compute billing, but it restarts on its own after 7 days
PostgreSQL Flexible bills compute while it's on. Stopping it stops compute billing (not storage), but the service automatically restarts it after 7 days. For dev environments, automate the stop instead of relying on a manual one.
Terraform
main.tf
# Web API privada Zero Trust — Azure
#
# Container Apps en un environment interno (ILB) con identidad administrada,
# conectándose por Private Endpoint a PostgreSQL Flexible Server que solo
# acepta autenticación Entra ID (sin contraseñas).
data "azurerm_client_config" "current" {}
resource "azurerm_resource_group" "this" {
name = "rg-${var.name}"
location = var.location
tags = var.tags
}
# --- Red ----------------------------------------------------------------------
resource "azurerm_virtual_network" "this" {
name = "vnet-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = [var.vnet_address_space]
tags = var.tags
}
# Dedicada al environment y delegada (gotcha: aca-subnet-sizing).
resource "azurerm_subnet" "aca" {
name = "snet-aca"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.aca_subnet_prefix]
delegation {
name = "aca"
service_delegation {
name = "Microsoft.App/environments"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
resource "azurerm_subnet" "endpoints" {
name = "snet-private-endpoints"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.endpoints_subnet_prefix]
}
resource "azurerm_network_security_group" "this" {
for_each = { aca = azurerm_subnet.aca.id, endpoints = azurerm_subnet.endpoints.id }
name = "nsg-${var.name}-${each.key}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = { aca = azurerm_subnet.aca.id, endpoints = azurerm_subnet.endpoints.id }
subnet_id = each.value
network_security_group_id = azurerm_network_security_group.this[each.key].id
}
# --- Container Apps (environment interno) -------------------------------------
resource "azurerm_container_app_environment" "this" {
name = "cae-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
infrastructure_subnet_id = azurerm_subnet.aca.id
internal_load_balancer_enabled = true
workload_profile {
name = "Consumption"
workload_profile_type = "Consumption"
}
tags = var.tags
depends_on = [azurerm_subnet_network_security_group_association.this]
}
# Sin esta zona nadie resuelve <app>.<default_domain> (gotcha: aca-internal-needs-private-dns).
resource "azurerm_private_dns_zone" "aca" {
name = azurerm_container_app_environment.this.default_domain
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_private_dns_a_record" "aca" {
for_each = toset(["*", "@"])
name = each.key
private_dns_zone_id = azurerm_private_dns_zone.aca.id
ttl = 300
records = [azurerm_container_app_environment.this.static_ip_address]
}
resource "azurerm_private_dns_zone_virtual_network_link" "aca" {
name = "link-${var.name}-aca"
private_dns_zone_id = azurerm_private_dns_zone.aca.id
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_user_assigned_identity" "api" {
name = "id-${var.name}-api"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_container_app" "api" {
name = "ca-${var.name}-api"
resource_group_name = azurerm_resource_group.this.name
container_app_environment_id = azurerm_container_app_environment.this.id
revision_mode = "Single"
workload_profile_name = "Consumption"
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.api.id]
}
ingress {
# En un environment interno, external_enabled = true significa "toda la VNet";
# false limita el acceso a otras apps del mismo environment.
external_enabled = true
target_port = var.container_port
allow_insecure_connections = false
traffic_weight {
latest_revision = true
percentage = 100
}
}
template {
min_replicas = 0
max_replicas = var.max_replicas
container {
name = "api"
image = var.container_image
cpu = 0.25
memory = "0.5Gi"
# Sin contraseña: la app pide un token para ossrdbms-aad con esta identidad
# (gotcha: pg-entra-token-resource).
env {
name = "AZURE_CLIENT_ID"
value = azurerm_user_assigned_identity.api.client_id
}
env {
name = "PGHOST"
value = azurerm_postgresql_flexible_server.this.fqdn
}
env {
name = "PGUSER"
value = azurerm_user_assigned_identity.api.name
}
env {
name = "PGDATABASE"
value = var.database_name
}
env {
name = "PGSSLMODE"
value = "require"
}
}
}
tags = var.tags
}
# --- PostgreSQL (solo Entra ID, sin acceso público) ---------------------------
resource "azurerm_postgresql_flexible_server" "this" {
#checkov:skip=CKV_AZURE_136:Backup geo-redundante opcional; duplica costo de backup. Activar en producción crítica.
name = var.postgres_server_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
version = "17"
sku_name = var.postgres_sku
storage_mb = 32768
backup_retention_days = 7
geo_redundant_backup_enabled = false
public_network_access_enabled = false
# Modo "acceso público" + Private Endpoint: no se puede cambiar después
# (gotcha: pg-network-mode-is-permanent).
authentication {
active_directory_auth_enabled = true
password_auth_enabled = false
tenant_id = data.azurerm_client_config.current.tenant_id
}
tags = var.tags
lifecycle {
ignore_changes = [zone]
}
}
resource "azurerm_postgresql_flexible_server_active_directory_administrator" "this" {
server_name = azurerm_postgresql_flexible_server.this.name
resource_group_name = azurerm_resource_group.this.name
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = var.entra_admin.object_id
principal_name = var.entra_admin.principal_name
principal_type = var.entra_admin.principal_type
}
resource "azurerm_postgresql_flexible_server_database" "app" {
name = var.database_name
server_id = azurerm_postgresql_flexible_server.this.id
collation = "en_US.utf8"
charset = "UTF8"
}
resource "azurerm_private_dns_zone" "postgres" {
name = "privatelink.postgres.database.azure.com"
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
name = "link-${var.name}-postgres"
private_dns_zone_id = azurerm_private_dns_zone.postgres.id
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_private_endpoint" "postgres" {
name = "pe-${var.name}-postgres"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
subnet_id = azurerm_subnet.endpoints.id
private_service_connection {
name = "psc-${var.name}-postgres"
private_connection_resource_id = azurerm_postgresql_flexible_server.this.id
subresource_names = ["postgresqlServer"]
is_manual_connection = false
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.postgres.id]
}
tags = var.tags
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"api-demo\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,20}$", var.name))
error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
}
}
variable "location" {
description = "Región de Azure."
type = string
default = "eastus2"
}
variable "postgres_server_name" {
description = "Nombre global único del servidor PostgreSQL (3-63, minúsculas, números y guiones)."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.postgres_server_name))
error_message = "postgres_server_name: 3-63 caracteres, minúsculas, números o guiones."
}
}
variable "postgres_sku" {
description = "SKU de PostgreSQL Flexible Server."
type = string
default = "B_Standard_B1ms"
}
variable "database_name" {
description = "Base de datos de la aplicación."
type = string
default = "app"
}
variable "entra_admin" {
description = "Administrador Entra de PostgreSQL (recomendado: un grupo). Es quien ejecuta el SQL que crea el rol de la app."
type = object({
object_id = string
principal_name = string
principal_type = string
})
validation {
condition = contains(["Group", "User", "ServicePrincipal"], var.entra_admin.principal_type)
error_message = "principal_type: Group, User o ServicePrincipal."
}
}
variable "container_image" {
description = "Imagen de la API."
type = string
default = "mcr.microsoft.com/k8se/quickstart:latest"
}
variable "container_port" {
description = "Puerto en el que escucha la API."
type = number
default = 80
}
variable "vnet_address_space" {
description = "Espacio de direcciones de la VNet."
type = string
default = "10.40.0.0/16"
}
variable "aca_subnet_prefix" {
description = "Subnet dedicada al environment (mínimo /27 con workload profiles; no se puede agrandar después)."
type = string
default = "10.40.0.0/24"
}
variable "endpoints_subnet_prefix" {
description = "Subnet para Private Endpoints."
type = string
default = "10.40.1.0/24"
}
variable "max_replicas" {
description = "Máximo de réplicas de la API."
type = number
default = 10
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "api_fqdn" {
description = "FQDN interno de la API (resuelve vía la zona DNS privada del environment)."
value = azurerm_container_app.api.ingress[0].fqdn
}
output "postgres_fqdn" {
description = "FQDN del servidor PostgreSQL (resuelve a la IP del Private Endpoint desde la VNet)."
value = azurerm_postgresql_flexible_server.this.fqdn
}
output "app_role_sql" {
description = "SQL que un admin Entra tiene que ejecutar para crear el rol de la app con permisos mínimos (gotcha: pg-entra-app-role-needs-sql)."
value = <<-SQL
-- Conectado a la base 'postgres' como administrador Entra:
SELECT * FROM pgaadauth_create_principal('${azurerm_user_assigned_identity.api.name}', false, false);
-- Conectado a la base '${var.database_name}':
GRANT CONNECT ON DATABASE "${var.database_name}" TO "${azurerm_user_assigned_identity.api.name}";
GRANT USAGE ON SCHEMA public TO "${azurerm_user_assigned_identity.api.name}";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${azurerm_user_assigned_identity.api.name}";
SQL
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# Con shared_access_key_enabled = false el provider tiene que usar Entra ID
# para las llamadas de data plane (gotcha: shared-key-needs-azuread-provider).
storage_use_azuread = true
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}