Saltar al contenido
Kevo Rojas

Arquitectura privada en Azure Container Apps con Terraform: el código completo, probado en local con Floci

KR
Kevo Rojas
9 jul 2026 · 18 min de lectura
Video del tutorial

Al reproducir se carga YouTube (Google), que puede guardar datos en tu navegador y registrar la reproducción. PrivacidadVer en YouTube (se abre en pestaña nueva)

Una app en Azure que nadie ve desde internet: VNet, Private Endpoints, identidad sin contraseñas y un WAF como única puerta. Todo en Terraform y probado gratis en local con Floci.

En resumen: Búnker es una app en Azure que nadie puede ver desde internet. Corre en Azure Container Apps dentro de una red privada, llega al registry, al Key Vault y al Storage por Private Endpoints, se autentica con una Managed Identity (ninguna contraseña en la app) y tiene una sola puerta pública: un Application Gateway con WAF. En este capítulo, el primero de tres, te muestro la app, la arquitectura y toda la infraestructura en Terraform, capa por capa. Y la valido en local con Floci, un emulador de Azure: 33 recursos aplicados, gratis, sin tocar Azure real.

Qué es Búnker

Búnker es el proyecto que vas a seguir conmigo en esta serie: una arquitectura privada en Azure, construida con Terraform, donde los servicios se comunican solo por la red interna. La idea cabe en una frase: nada entra ni sale sin permiso. Todo es privado por defecto (la red, el registry de imágenes, los secretos, el storage) y lo público es la excepción, declarada a propósito.

«Seguro» aquí significa dos cosas concretas:

  1. Solo se llega por donde decidimos. Hay una única entrada desde internet y todo lo demás vive dentro de la red privada.
  2. Ningún servicio guarda una contraseña para hablar con otro. Nada de connection strings ni SAS tokens: cada app se presenta con una identidad que le da Azure, con los permisos justos.

La app que corre adentro es mínima a propósito. Lo que importa es la infraestructura que la rodea.

La serie: tres capítulos

  1. Capítulo 1 (este): la app, la arquitectura y toda la infraestructura en Terraform, validada en local con Floci.
  2. Capítulo 2: el mismo código, desplegado en Azure real.
  3. Capítulo 3: el pipeline con OIDC que publica la app, la prueba final y el terraform destroy.

En el video de este capítulo recorro todo esto en pantalla. Aquí lo tienes por escrito, con el código y algunos detalles que en el video pasan rápido.

La app: DocSafe, primero en local

Antes de tocar Azure, la app corre entera en tu máquina con Docker Compose. Se llama DocSafe: subes un documento desde la web y aparece en un catálogo. Son cuatro servicios en Python con FastAPI:

  • web: la interfaz (server-rendered con HTMX). Es el único servicio al que entra el usuario.
  • api: el backend. Recibe los pedidos de web y los reparte.
  • processor: valida el documento (hash SHA-256, tamaño y tipo) y lo escribe en el storage. Después le avisa a catalog.
  • catalog: el índice con la metadata de cada documento.

El flujo para subir es web → api → processor → storage, y para listar, web → api → catalog. El flujo no pretende tener sentido de negocio: existe para que haya varios servicios hablando entre sí y uno solo expuesto.

El código está en el repo público github.com/kevorojas/container-apps-demo. Copias el .env.example, completas la connection string de Azurite (el emulador de Storage) como explica el README y levantas todo con docker compose up --build.

Un aviso importante: lo que corre en local es la app, no la seguridad. En Docker Compose no hay VNet, ni Managed Identity, ni Private Endpoints, ni Application Gateway. Esos mismos cuatro servicios son los que Búnker hospeda en Azure; cambia el hosting, la app es la misma.

La arquitectura, de un vistazo

Así se lee el diagrama, de afuera hacia adentro.

La red. Todo vive en una VNet (Virtual Network): una red privada tuya dentro de Azure, con direcciones que no existen en internet. Está partida en tres subnets, cada una con su función: snet-gateway para la puerta de entrada, snet-apps para las aplicaciones y snet-endpoints para los servicios privados. Cada subnet tiene su NSG (Network Security Group), que es la lista de reglas de quién puede hablar con quién.

La entrada. El usuario llega a un Application Gateway con WAF. El gateway es la puerta: recibe el tráfico y lo reparte. El WAF es el guardia que revisa lo que entra; aquí usa las reglas OWASP 3.2 en modo «Prevention», o sea, bloquea, no solo avisa. Es lo único con IP pública en toda la arquitectura.

Las apps. Las cuatro corren en un Container Apps Environment interno, dentro de snet-apps. Interno quiere decir que no tiene ingress público: ni siquiera nosotros podemos abrir web desde el navegador si no estamos dentro de la VNet. web es la única app con ingress, y ese ingress solo es visible dentro de la red privada; el gateway la alcanza porque está en la misma VNet. api, processor y catalog son internas: solo las alcanzan las otras apps del entorno. Si vienes de Kubernetes, piénsalo como servicios ClusterIP.

Los servicios privados. El Container Registry (ACR, donde viven las imágenes), el Key Vault (los secretos) y el Storage (los documentos) tienen el acceso público apagado. Se alcanzan por Private Endpoints: una tarjeta de red con IP privada que mete el servicio dentro de tu VNet. Para que el nombre de siempre (por ejemplo, mi-vault.vault.azure.net) apunte a esa IP privada y no a la pública, hay zonas de DNS privadas (privatelink.*) enlazadas a la VNet.

La identidad. Una Managed Identity es una identidad que Azure le da a tu app: no hay contraseña que guardar ni rotar. Búnker usa una sola, compartida por las cuatro apps, con tres roles y ninguno más:

  • AcrPull sobre el registry: puede descargar imágenes, no subirlas.
  • Key Vault Secrets User sobre el Key Vault: puede leer secretos, no crearlos ni borrarlos.
  • Storage Blob Data Contributor sobre el Storage: puede escribir y leer blobs.

Observabilidad. Los logs del entorno van a Log Analytics, y en el diagrama completan la parte de observabilidad Azure Monitor y Managed Grafana para los dashboards.

CI/CD. El pipeline de la app corre en GitHub Actions y se autentica con OIDC, sin secretos guardados: build, scan, push de la imagen al ACR y deploy de la nueva revisión. Ese pipeline completo es el capítulo 3.

¿Por qué Azure Container Apps y no AKS?

Azure Container Apps es una plataforma de contenedores serverless gestionada por Azure. La forma más corta de explicarlo: es como un clúster de Kubernetes al que le sacaron el clúster. Tú defines las imágenes, las pruebas de salud, el escalado, los secretos y las variables de entorno. Azure se encarga de los nodos, del ingress y de lo demás.

Un clúster (AKS o el que sea) trae una carga operativa grande: upgrades de versión, node pools, add-ons, networking, RBAC. Tiene sentido cuando corres decenas de servicios y hay un equipo de plataforma detrás. Para hospedar tres o cuatro servicios como DocSafe, esa carga no se paga sola. Container Apps te da lo que esta arquitectura necesita sin operar el clúster:

  1. VNet e ingress privado nativos. Un entorno interno, sin ingress público y con apps que solo se ven dentro de la red, es una opción del recurso, no algo que armas a mano.
  2. Serverless. No administras nodos y el entorno escala solo, incluso a cero cuando no hay tráfico.
  3. Revisions y autoscaling incluidos. Traffic splitting entre revisiones y escalado por eventos (KEDA integrado) vienen con la plataforma. En Kubernetes eso se instala y se mantiene aparte.

No es «Container Apps le gana a AKS». Es elegir la herramienta para el tamaño del problema. Cuando el clúster sí se justifica, la respuesta cambia.

El repo: un módulo, dos raíces

La infraestructura vive en su propio repositorio, github.com/kevorojas/bunker-terraform, separada de la app. Tiene tres carpetas:

bunker/   el código del Búnker (un solo módulo)
floci/    lo despliega en Floci, el emulador local de Azure
azure/    lo despliega en Azure real

Todo el Búnker está en bunker/. Las otras dos carpetas son raíces: cada una configura un provider, llama al mismo módulo y guarda su propio state. La diferencia entre las dos es el bloque provider y una línea:

# floci/main.tf
provider "azurerm" {
  features {}

  metadata_host   = "localhost:4577"
  use_cli         = false
  subscription_id = "00000000-0000-0000-0000-000000000001"
  tenant_id       = "00000000-0000-0000-0000-000000000002"
  client_id       = "00000000-0000-0000-0000-000000000003"
  client_secret   = "fake-secret"
}

module "bunker" {
  source = "../bunker"
  floci  = true
}
# azure/main.tf
provider "azurerm" {
  features {}
}

module "bunker" {
  source = "../bunker"
}

Las credenciales de floci/ son falsas a propósito: Floci no las valida. En azure/, el provider usa tu sesión de Azure CLI y la suscripción de ARM_SUBSCRIPTION_ID.

¿Por qué dos raíces y no una carpeta con dos workspaces? Porque así el state de Floci, lleno de IDs inventados, nunca se mezcla con el de Azure. Lo que valida Floci es exactamente el mismo código que después se aplica en Azure.

Floci: Azure en tu máquina

Floci es un emulador de Azure que corre en Docker. Terraform le habla como si fuera Azure y no hace falta cuenta ni tarjeta. Para Búnker sirve como primer filtro: si el plan y el apply pasan en Floci, el código es coherente (referencias entre recursos, for_each, bloques dinámicos, dependencias) antes de pagar un minuto de Azure.

Lo que no hace es la red. En Floci, las cuatro Container Apps corren de verdad como contenedores y el Key Vault guarda secretos de verdad. Pero la VNet, los NSG, la DNS privada, los Private Endpoints y el Application Gateway son de cartón: Terraform los crea, Floci los guarda y nada filtra ni enruta tráfico. Floci valida el código, no la seguridad.

Lo que Floci no soporta y cómo se degrada

Floci (la versión 0.13 que uso) no puede crear tres cosas que Búnker necesita:

  • Los role assignments. Floci no implementa el RBAC de Azure, así que los roles de la identidad fallan.
  • Log Analytics. El provider consulta un endpoint de workspaces borrados que Floci no tiene, y el entorno de Container Apps pide unas claves del workspace que tampoco existen.
  • Los registros de las zonas de DNS privadas, como el * del entorno. Floci rechaza los encabezados vacíos que manda el provider.

Para que el mismo código aplique en los dos lados, el módulo tiene una variable floci (por defecto false) que apaga solo esas piezas:

resource "azurerm_log_analytics_workspace" "logs" {
  count = var.floci ? 0 : 1
  # ...
}

resource "azurerm_role_assignment" "apps" {
  for_each = var.floci ? {} : {
    "AcrPull"                       = azurerm_container_registry.bunker.id
    "Key Vault Secrets User"        = azurerm_key_vault.bunker.id
    "Storage Blob Data Contributor" = azurerm_storage_account.documents.id
  }
  # ...
}

En Azure, floci vale false y se crea todo. En Floci se saltan esas tres piezas y el resto aplica igual.

¿Por qué no usar lifecycle { ignore_changes } para que Floci deje de quejarse? Porque ignore_changes también actuaría en Azure y escondería drift real. Con var.floci, el camino de Azure queda intacto: lo que no se valida en Floci se ve en Azure, no se tapa.

El proxy fail-closed

Hay un riesgo con cualquier emulador: Floci devuelve URLs con el formato real de Azure (por ejemplo, kv-algo.vault.azure.net) y el provider las llama. Sin ninguna protección, una de esas llamadas podría salir a internet o al Azure real.

Para eso armé dos piezas chicas en floci/:

  • ./tf, un envoltorio de Terraform. Lo corre con un entorno limpio (sin variables ARM_* ni AZURE_* y sin tu sesión de az), comprueba que localhost:4577 responde como Floci y manda todo lo que no es localhost a un proxy.
  • proxy.py, el proxy. Si el host es de Azure (*.core.windows.net, *.azure.net, *.azurecr.io, *.azurecontainerapps.io), lo atiende Floci. Cualquier otro host recibe un 403.

Es fail-closed: ante la duda, bloquea. Nada de lo que hace Terraform en esta carpeta llega a Azure real. El patrón sirve para cualquier emulador.

El código, capa por capa

Cinco archivos, uno por capa. Las líneas de cada fragmento son las del repo.

1. network.tf: la red

La VNet usa 10.10.0.0/16 y se parte en tres subnets:

SubnetRangoPara qué
snet-apps10.10.0.0/24El Container Apps Environment. Delegada a Microsoft.App/environments
snet-gateway10.10.1.0/24El Application Gateway
snet-endpoints10.10.2.0/24Los Private Endpoints de ACR, Key Vault y Storage

La delegación le dice a Azure que esa subnet es de Container Apps: el servicio puede administrarla.

Los NSG:

  • nsg-apps deja entrar desde snet-gateway solo a los puertos 80, 443, 31080 y 31443, permite el tráfico dentro de la propia subnet y deniega el resto de la VNet. O sea: a las apps solo llega el gateway.
  • nsg-gateway abre el 80 desde internet y el rango 65200-65535 para GatewayManager, que Azure exige para operar el Application Gateway.

Las tres zonas de DNS privadas son iguales salvo el nombre, así que salen de un solo bloque con for_each:

locals {
  private_zones = {
    acr   = "privatelink.azurecr.io"
    vault = "privatelink.vaultcore.azure.net"
    blob  = "privatelink.blob.core.windows.net"
  }
}

resource "azurerm_private_dns_zone" "private" {
  for_each            = local.private_zones
  name                = each.value
  resource_group_name = azurerm_resource_group.bunker.name
}

Cada zona tiene su link con la VNet. Sin el link, la VNet no consulta esa zona y los nombres siguen resolviendo a la IP pública, que está bloqueada.

2. identity.tf: una identidad, tres roles

Una identidad asignada por el usuario (id-bunker-apps) y sus tres roles, en el mapa nombre del rol → recurso que viste más arriba. Las cuatro apps la usan. El rol más amplio es Storage Blob Data Contributor, porque processor tiene que escribir; los otros dos son de solo lectura.

3. private-services.tf: registry, secretos y storage

Tres recursos, cada uno con su Private Endpoint en snet-endpoints y su zona DNS:

  • ACR en SKU Premium, con el usuario admin apagado y sin acceso público. Premium no es un lujo: es el SKU que admite Private Endpoints.
  • Key Vault con RBAC (rbac_authorization_enabled = true) en vez de access policies, para que los permisos sean los roles de la identidad. Acceso público apagado.
  • Storage con public_network_access = "Disabled" y shared_access_key_enabled = false. Ese segundo detalle importa: sin claves compartidas, una connection string no sirve aunque alguien la consiga. La única forma de entrar es con identidad.

4. gateway.tf: la única puerta

Una IP pública estática, una política WAF (OWASP 3.2, «Prevention») y el Application Gateway en SKU WAF_v2. El recorrido del tráfico:

  • Listener en HTTP, puerto 80. Un listener 443 pide un certificado, y para esta demo no lo uso: HTTP es solo para la demo.
  • Backend en HTTPS, puerto 443, contra el FQDN de web. Container Apps ya sirve web con un certificado gestionado por Azure, así que el tramo gateway → app va cifrado. El gateway usa el host del backend (pick_host_name_from_backend_address) para que el certificado coincida.
  • Un health probe HTTPS sobre / para saber si web está viva.

5. container-apps.tf: el entorno y las cuatro apps

El entorno se integra a snet-apps con internal_load_balancer_enabled = true (eso lo hace interno), usa el perfil Consumption y manda los logs a Log Analytics, salvo en Floci.

Aquí hay una decisión de diseño: la zona DNS del entorno vive en esta capa, no en network.tf. El entorno interno tiene un dominio propio, y para que la VNet lo resuelva hace falta una zona privada con ese nombre y un registro * que apunte a la IP del entorno:

resource "azurerm_private_dns_zone" "apps" {
  name                = azurerm_container_app_environment.bunker.default_domain
  resource_group_name = azurerm_resource_group.bunker.name
}

resource "azurerm_private_dns_a_record" "apps" {
  count               = var.floci ? 0 : 1
  name                = "*"
  private_dns_zone_id = azurerm_private_dns_zone.apps.id
  ttl                 = 300
  records             = [azurerm_container_app_environment.bunker.static_ip_address]
}

La zona depende del entorno, no de la red. Si mañana el código se parte en capas (red, identidad, apps), crear una app no debería obligarte a tocar la capa de red.

Las cuatro apps salen de un solo bloque azurerm_container_app con for_each sobre un locals. Cada entrada dice si la app tiene ingress externo y qué variables de entorno lleva:

locals {
  apps = {
    web       = { external = true,  env = { API_URL = "http://api" } }
    api       = { external = false, env = { PROCESSOR_URL = "http://processor", CATALOG_URL = "http://catalog" } }
    processor = { external = false, env = { CATALOG_URL = "http://catalog", STORAGE_MODE = "managed_identity", STORAGE_ACCOUNT_URL = "..." } }
    catalog   = { external = false, env = {} }
  }
}

(Resumido; en el repo cada app ocupa varias líneas y STORAGE_ACCOUNT_URL sale del Storage.)

Las variables se cargan con un bloque dynamic "env", que es un bucle: un bloque env por cada variable. A todas se les suma AZURE_CLIENT_ID con el client ID de la identidad, que es lo que necesita DefaultAzureCredential para saber qué identidad asignada usar. external = true en web dentro de un entorno interno significa «visible en toda la VNet», no «visible en internet».

Un detalle honesto: en este capítulo las cuatro apps corren una imagen de ejemplo de Microsoft (mcr.microsoft.com/k8se/quickstart). Las imágenes de DocSafe llegan al registry privado con el pipeline del capítulo 3.

plan y apply en Floci: 33 recursos, ninguno existe

Con Floci levantado, el ciclo completo es:

cd floci
docker compose up -d
./tf init
./tf plan
./tf apply

El plan dice 33 to add y el apply termina en algo menos de dos minutos con Apply complete! Resources: 33 added. Los outputs se ven como los de Azure, hasta que los miras de cerca: environment_ip = "127.0.0.1" y public_url = "http://20.0.0.4", una IP sintética que no responde. Esto no debería funcionar: es una simulación. Y justamente eso es lo útil: el código entero aplicó sin gastar un centavo.

Para dejar todo limpio:

./tf destroy
docker compose down
docker rm -f floci-az-acr-registry

El último comando hace falta porque el registry que levanta Floci para el ACR no se borra con el destroy.

Qué validó Floci y qué queda para Azure

Floci validó:

  • Que el código aplica completo: 33 recursos, referencias, for_each, bloques dinámicos y dependencias.
  • Que la separación en dos raíces funciona y que la variable floci apaga solo lo que tiene que apagar.
  • Que las cuatro Container Apps arrancan con sus variables de entorno.

Queda para Azure real:

  • Que desde afuera no se llega a nada salvo por el gateway, y que el WAF bloquea.
  • Los Private Endpoints con IP de snet-endpoints y la DNS privada resolviendo.
  • Los role assignments, Log Analytics y el registro * del entorno, que en Floci se apagaron.
  • El acceso público realmente apagado en ACR, Key Vault y Storage.

¿Qué necesitas para seguir la serie?

Para este capítulo:

  • Docker y Terraform (1.9 o más nuevo).
  • Floci con su certificado de confianza en el sistema. Si nunca lo usaste, empieza por el artículo de Floci.
  • Nociones básicas de redes (qué es una subred, qué hace un DNS) y de contenedores (una imagen, un registry). No hace falta saber Kubernetes.

Para el capítulo 2 vas a sumar una cuenta de Azure y la Azure CLI. La cuenta gratuita de Azure trae crédito para empezar. Algunos recursos de Búnker cobran por hora (el Application Gateway es el más caro), así que conviene llegar con el código ya probado en Floci, aplicar, revisar y destruir.

El código de la app está en github.com/kevorojas/container-apps-demo. La infraestructura está en github.com/kevorojas/bunker-terraform.


Siguiente capítulo

Capítulo 2: Búnker en Azure real. Aplico este mismo código en Azure y recorro lo que Floci no puede mostrar: cómo se ven los recursos, cómo se comunican entre ellos y las configuraciones reales de red, DNS e identidad.


Búnker son tres capítulos. Si quieres que te avise cuando salga el siguiente, déjame tu correo. Sin spam, solo el aviso cuando hay algo nuevo.

Sígueme

Si te sirvió, ayúdame a llegar a 500 suscriptores en YouTube

Subo tutoriales de herramientas DevOps que voy probando.

Ver el video de este tutorial en YouTube(se abre en pestaña nueva)

¿Prefieres el correo?

Recibe el próximo tutorial en tu bandeja cuando salga.

Uso tu email solo para el newsletter. Política de privacidad