Skip to content
Kevo Rojas

Private architecture on Azure Container Apps with Terraform: the full code, tested locally with Floci

KR
Kevo Rojas
Jul 9, 2026 · 18 min read
Tutorial video

Playing loads YouTube (Google), which may store data in your browser and log the playback. PrivacyWatch on YouTube (opens in a new tab)

An Azure app nobody can see from the internet: VNet, Private Endpoints, passwordless identity and a WAF as the only door. All in Terraform, tested for free locally with Floci.

In short: Búnker is an Azure app nobody can see from the internet. It runs on Azure Container Apps inside a private network, reaches the registry, Key Vault and Storage through Private Endpoints, authenticates with a Managed Identity (no passwords in the app) and has a single public door: an Application Gateway with WAF. In this chapter, the first of three, I walk you through the app, the architecture and the whole infrastructure in Terraform, layer by layer. Then I validate it locally with Floci, an Azure emulator: 33 resources applied, for free, without touching real Azure.

What Búnker is

Búnker (Spanish for "bunker") is the project you'll follow with me in this series: a private architecture on Azure, built with Terraform, where services talk to each other only over the internal network. The idea fits in one sentence: nothing gets in or out without permission. Everything is private by default (the network, the image registry, the secrets, the storage), and anything public is a deliberate exception.

"Secure" here means two concrete things:

  1. You can only get in where we decide. There's a single entry point from the internet, and everything else lives inside the private network.
  2. No service stores a password to talk to another one. No connection strings, no SAS tokens: each app shows up with an identity Azure gives it, with exactly the permissions it needs.

The app running inside is minimal on purpose. What matters is the infrastructure around it.

The series: three chapters

  1. Chapter 1 (this one): the app, the architecture and the whole infrastructure in Terraform, validated locally with Floci.
  2. Chapter 2: the same code, deployed to real Azure.
  3. Chapter 3: the OIDC pipeline that ships the app, the final test and the terraform destroy.

The video for this chapter goes through all of this on screen (it's in Spanish). Here you have it in writing, with the code and a few details the video moves through quickly.

The app: DocSafe, local first

Before touching Azure, the whole app runs on your machine with Docker Compose. It's called DocSafe: you upload a document from the web and it shows up in a catalog. It's four Python services built with FastAPI:

  • web: the interface (server-rendered with HTMX). It's the only service users reach.
  • api: the backend. It takes requests from web and hands them out.
  • processor: validates the document (SHA-256 hash, size and type) and writes it to storage. Then it notifies catalog.
  • catalog: the index with each document's metadata.

Uploading goes web → api → processor → storage, and listing goes web → api → catalog. The flow isn't meant to make business sense: it exists so there are several services talking to each other and only one of them exposed.

The code lives in the public repo github.com/kevorojas/container-apps-demo. Copy .env.example, fill in the Azurite connection string (Azurite is the Storage emulator) as the README explains, and bring everything up with docker compose up --build.

One important caveat: what runs locally is the app, not the security. Docker Compose has no VNet, no Managed Identity, no Private Endpoints and no Application Gateway. Those same four services are the ones Búnker hosts on Azure; the hosting changes, the app stays the same.

The architecture at a glance

Here's how to read the diagram, from the outside in.

The network. Everything lives in a VNet (Virtual Network): your own private network inside Azure, with addresses that don't exist on the internet. It's split into three subnets, each with a job: snet-gateway for the front door, snet-apps for the applications and snet-endpoints for the private services. Each subnet has its own NSG (Network Security Group), the list of rules for who can talk to whom.

The way in. Users hit an Application Gateway with a WAF. The gateway is the door: it takes the traffic and routes it. The WAF is the guard checking what comes in; here it uses the OWASP 3.2 rules in "Prevention" mode, meaning it blocks, not just logs. It's the only thing with a public IP in the whole architecture.

The apps. All four run in an internal Container Apps Environment inside snet-apps. Internal means no public ingress: not even we can open web in a browser unless we're inside the VNet. web is the only app with ingress, and that ingress is only visible inside the private network; the gateway reaches it because it sits in the same VNet. api, processor and catalog are internal: only other apps in the environment can reach them. If you come from Kubernetes, think of them as ClusterIP services.

The private services. The Container Registry (ACR, where the images live), the Key Vault (secrets) and Storage (documents) all have public access turned off. They're reached through Private Endpoints: a network interface with a private IP that brings the service into your VNet. To make the usual hostname (say, my-vault.vault.azure.net) point to that private IP instead of the public one, there are private DNS zones (privatelink.*) linked to the VNet.

The identity. A Managed Identity is an identity Azure gives your app: there's no password to store or rotate. Búnker uses just one, shared by all four apps, with three roles and nothing else:

  • AcrPull on the registry: it can pull images, not push them.
  • Key Vault Secrets User on the Key Vault: it can read secrets, not create or delete them.
  • Storage Blob Data Contributor on Storage: it can read and write blobs.

Observability. The environment's logs go to Log Analytics, and in the diagram Azure Monitor and Managed Grafana round out observability with dashboards.

CI/CD. The app pipeline runs on GitHub Actions and authenticates with OIDC, no stored secrets: build, scan, push the image to ACR and deploy the new revision. That full pipeline is chapter 3.

Why Azure Container Apps and not AKS?

Azure Container Apps is a serverless container platform managed by Azure. The shortest way to put it: it's Kubernetes with the cluster taken away. You define the images, health probes, scaling, secrets and environment variables. Azure takes care of the nodes, the ingress and the rest.

A cluster (AKS or any other) comes with a heavy operational load: version upgrades, node pools, add-ons, networking, RBAC. That makes sense when you run dozens of services with a platform team behind them. To host three or four services like DocSafe, that load doesn't pay for itself. Container Apps gives this architecture what it needs without running a cluster:

  1. Native VNet and private ingress. An internal environment, with no public ingress and apps that are only visible inside the network, is a setting on the resource, not something you wire up by hand.
  2. Serverless. You don't manage nodes, and the environment scales on its own, down to zero when there's no traffic.
  3. Revisions and autoscaling built in. Traffic splitting between revisions and event-driven scaling (built-in KEDA) come with the platform. On Kubernetes you install and maintain those separately.

This isn't "Container Apps beats AKS." It's picking the right tool for the size of the problem. When the cluster does pay off, the answer changes.

The repo: one module, two roots

The infrastructure lives in its own repository, github.com/kevorojas/bunker-terraform, separate from the app. It has three folders:

bunker/   the Búnker code (a single module)
floci/    deploys it to Floci, the local Azure emulator
azure/    deploys it to real Azure

All of Búnker lives in bunker/. The other two folders are roots: each one configures a provider, calls the same module and keeps its own state. The difference between them is the provider block and one line:

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

The credentials in floci/ are fake on purpose: Floci doesn't check them. In azure/, the provider uses your Azure CLI session and the subscription in ARM_SUBSCRIPTION_ID.

Why two roots instead of one folder with two workspaces? Because this way Floci's state, full of made-up IDs, never mixes with Azure's. What Floci validates is exactly the same code you later apply to Azure.

Floci: Azure on your machine

Floci is an Azure emulator that runs in Docker. Terraform talks to it as if it were Azure, with no account or credit card needed. For Búnker it works as a first filter: if plan and apply pass on Floci, the code holds together (references between resources, for_each, dynamic blocks, dependencies) before you pay for a single minute of Azure.

What it doesn't do is networking. On Floci, the four Container Apps really run as containers and the Key Vault really stores secrets. But the VNet, NSGs, private DNS, Private Endpoints and Application Gateway are cardboard: Terraform creates them, Floci stores them, and nothing filters or routes traffic. Floci validates the code, not the security.

What Floci doesn't support, and how the code degrades

Floci (version 0.13, the one I use) can't create three things Búnker needs:

  • Role assignments. Floci doesn't implement Azure RBAC, so the identity's roles fail.
  • Log Analytics. The provider queries a deleted-workspaces endpoint Floci doesn't have, and the Container Apps environment asks for workspace keys that don't exist either.
  • Records in private DNS zones, like the environment's * record. Floci rejects the empty headers the provider sends.

So the same code can apply on both sides, the module has a floci variable (default false) that switches off just those pieces:

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

On Azure, floci is false and everything gets created. On Floci those three pieces are skipped and the rest applies the same way.

Why not use lifecycle { ignore_changes } to keep Floci quiet? Because ignore_changes would also apply on Azure and hide real drift. With var.floci, the Azure path stays untouched: whatever Floci can't validate shows up on Azure instead of being papered over.

The fail-closed proxy

Any emulator carries a risk: Floci returns URLs in the real Azure format (for example, kv-something.vault.azure.net) and the provider calls them. With no guardrail, one of those calls could go out to the internet or to real Azure.

To prevent that, I built two small pieces in floci/:

  • ./tf, a Terraform wrapper. It runs Terraform in a clean environment (no ARM_* or AZURE_* variables and no az session), checks that localhost:4577 answers as Floci, and sends everything that isn't localhost to a proxy.
  • proxy.py, the proxy. If the host is an Azure one (*.core.windows.net, *.azure.net, *.azurecr.io, *.azurecontainerapps.io), Floci handles it. Any other host gets a 403.

It's fail-closed: when in doubt, it blocks. Nothing Terraform does in this folder reaches real Azure. The pattern works for any emulator.

The code, layer by layer

Five files, one per layer. The snippets are taken from the repo.

1. network.tf: the network

The VNet uses 10.10.0.0/16 and is split into three subnets:

SubnetRangePurpose
snet-apps10.10.0.0/24The Container Apps Environment. Delegated to Microsoft.App/environments
snet-gateway10.10.1.0/24The Application Gateway
snet-endpoints10.10.2.0/24The Private Endpoints for ACR, Key Vault and Storage

The delegation tells Azure that subnet belongs to Container Apps, so the service can manage it.

The NSGs:

  • nsg-apps only lets traffic in from snet-gateway on ports 80, 443, 31080 and 31443, allows traffic within its own subnet and denies the rest of the VNet. In other words: only the gateway reaches the apps.
  • nsg-gateway opens port 80 to the internet and the 65200-65535 range to GatewayManager, which Azure requires to run the Application Gateway.

The three private DNS zones are identical except for the name, so they come from a single block with 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
}

Each zone has its link to the VNet. Without the link, the VNet never looks at that zone and hostnames keep resolving to the public IP, which is blocked.

2. identity.tf: one identity, three roles

A user-assigned identity (id-bunker-apps) and its three roles, in the role name → resource map you saw above. All four apps use it. The broadest role is Storage Blob Data Contributor, because processor has to write; the other two are read-only.

3. private-services.tf: registry, secrets and storage

Three resources, each with its Private Endpoint in snet-endpoints and its DNS zone:

  • ACR on the Premium SKU, with the admin user disabled and no public access. Premium isn't a luxury here: it's the SKU that supports Private Endpoints.
  • Key Vault with RBAC (rbac_authorization_enabled = true) instead of access policies, so permissions are the identity's roles. Public access off.
  • Storage with public_network_access = "Disabled" and shared_access_key_enabled = false. That second setting matters: with shared keys disabled, a connection string is useless even if someone gets hold of one. Identity is the only way in.

4. gateway.tf: the only door

A static public IP, a WAF policy (OWASP 3.2, "Prevention") and the Application Gateway on the WAF_v2 SKU. How traffic flows:

  • HTTP listener on port 80. A 443 listener needs a certificate, and I skip it for this demo: HTTP is for the demo only.
  • HTTPS backend on port 443, pointing at web's FQDN. Container Apps already serves web with an Azure-managed certificate, so the gateway → app hop is encrypted. The gateway uses the backend's hostname (pick_host_name_from_backend_address) so the certificate matches.
  • An HTTPS health probe on / to know whether web is alive.

5. container-apps.tf: the environment and the four apps

The environment plugs into snet-apps with internal_load_balancer_enabled = true (that's what makes it internal), uses the Consumption profile and ships logs to Log Analytics, except on Floci.

There's a design decision here: the environment's DNS zone lives in this layer, not in network.tf. The internal environment gets its own domain, and for the VNet to resolve it you need a private zone with that name and a * record pointing to the environment's IP:

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

The zone depends on the environment, not on the network. If the code is later split into layers (network, identity, apps), creating an app shouldn't force you to touch the network layer.

All four apps come from a single azurerm_container_app block with for_each over a locals map. Each entry says whether the app has external ingress and which environment variables it gets:

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

(Condensed; in the repo each app spans several lines and STORAGE_ACCOUNT_URL comes from the Storage account.)

The variables are loaded with a dynamic "env" block, which is a loop: one env block per variable. Every app also gets AZURE_CLIENT_ID with the identity's client ID, which is what DefaultAzureCredential needs to know which user-assigned identity to use. external = true on web inside an internal environment means "visible across the VNet", not "visible on the internet".

To be upfront about it: in this chapter all four apps run a Microsoft sample image (mcr.microsoft.com/k8se/quickstart). The DocSafe images land in the private registry with the chapter 3 pipeline.

plan and apply on Floci: 33 resources, none of them real

With Floci running, the full cycle is:

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

The plan says 33 to add, and the apply finishes in just under two minutes with Apply complete! Resources: 33 added. The outputs look like Azure's until you look closely: environment_ip = "127.0.0.1" and public_url = "http://20.0.0.4", a synthetic IP that doesn't answer. This shouldn't work: it's a simulation. And that's exactly the point: the whole codebase applied without spending a cent.

To clean everything up:

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

The last command is needed because the registry Floci spins up for ACR isn't removed by destroy.

What Floci validated and what's left for Azure

Floci validated:

  • That the code applies end to end: 33 resources, references, for_each, dynamic blocks and dependencies.
  • That the two-root split works and that the floci variable turns off only what it should.
  • That the four Container Apps start with their environment variables.

Left for real Azure:

  • That nothing is reachable from outside except through the gateway, and that the WAF blocks.
  • Private Endpoints with snet-endpoints IPs and private DNS actually resolving.
  • Role assignments, Log Analytics and the environment's * record, which were switched off on Floci.
  • Public access actually turned off on ACR, Key Vault and Storage.

What you need to follow along

For this chapter:

  • Docker and Terraform (1.9 or newer).
  • Floci, with its certificate trusted by your system. If you've never used it, start with the Floci article.
  • Basic networking (what a subnet is, what DNS does) and containers (an image, a registry). No Kubernetes required.

For chapter 2 you'll add an Azure account and the Azure CLI. Azure's free account comes with credit to get started. Some Búnker resources bill by the hour (the Application Gateway is the priciest), so it pays to arrive with the code already tested on Floci: apply, check, destroy.

The app's code is at github.com/kevorojas/container-apps-demo. The infrastructure is at github.com/kevorojas/bunker-terraform.


Next chapter

Chapter 2: Búnker on real Azure. I apply this same code to Azure and go through what Floci can't show: what the resources look like, how they talk to each other, and the real network, DNS and identity settings.


Búnker is three chapters. If you'd like a heads-up when the next one is out, leave me your email. No spam, just a note when there's something new.

Follow me

If this helped, help me get to 500 YouTube subscribers

I upload tutorials on DevOps tools as I try them.

Watch this tutorial's video on YouTube(opens in a new tab)

Prefer email?

Get the next tutorial in your inbox when it's out.

I only use your email for the newsletter. Privacy policy