Provision GraphWarden infrastructure with Terraform

A Terraform module that provisions the GraphWarden proxy as Azure Container Apps alongside its Key Vault, user-assigned managed identity, and required permissions. This page provides a runnable HCL module with variables for the proxy's configuration. The module is a starting point — adapt to your organization's naming conventions.

Terraform 1.5+ with azurerm 3.80+

Prerequisites

- Terraform 1.5 or later - `azurerm` provider 3.80 or later - Azure subscription with rights to create Container Apps, Key Vault, and role assignments - The GraphWarden proxy image pushed to Azure Container Registry (see [Docker](../deployment/docker)) - A remote state backend (Azure Storage account is recommended — see "State management" below)
This module is a starting reference. Production usage should align with your organization's Terraform conventions: naming, tagging, module registry, state backend, and pipeline integration. Do not apply the module into a shared subscription without reviewing resource names and role assignments.

Generate a GraphWarden Terraform module with an AI assistant

Generate a Terraform module for provisioning the GraphWarden proxy on Azure Container Apps.

I need the module to create:
1. An Azure Key Vault with access policies for the proxy's managed identity.
2. A user-assigned managed identity with Secret — Get on the vault.
3. An Azure Container Apps environment and container app pulling the proxy image from ACR.
4. All env vars wired from Terraform variables (tenant id, client id, vault uri).
5. Outputs for the proxy FQDN and the identity's principal id.

Reference documentation: https://graphwarden.com/llms.txt

Ask me for my ACR name, my subscription and tenant ids, whether Key Vault already exists, and the Graph tenant and client id the proxy should authenticate as.

Reference: llms.txt

Module structure

Keep the module under an infra/modules/graphwarden/ directory in your repository:

infra/
  modules/
    graphwarden/
      main.tf         # Resources
      variables.tf    # Input variables
      outputs.tf      # Output values
      versions.tf     # Required providers
      README.md       # Usage notes (module-local, not published docs)
  environments/
    production/
      main.tf         # Calls the module with production values
      backend.tf      # Remote state

The environments/production/main.tf file is the root module for one environment; it calls modules/graphwarden with production-specific variables. Duplicate environments/production for staging, dev, or per-tenant deployments.

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.80.0"
    }
  }
}

variables.tf

All of the module's inputs are declared here. Required variables have no default; optional variables have conservative defaults.

variable "resource_group_name" {
  type        = string
  description = "Resource group to deploy into. Must already exist."
}

variable "location" {
  type        = string
  description = "Azure region (for example 'eastus2' or 'westeurope')."
}

variable "name_prefix" {
  type        = string
  description = "Prefix for resource names (for example 'gw-prod'). Lowercase alphanumeric only."
}

variable "proxy_image_uri" {
  type        = string
  description = "Full image URI, for example 'myacr.azurecr.io/graphwarden-proxy:0.1.0'."
}

variable "acr_resource_id" {
  type        = string
  description = "Resource id of the Azure Container Registry holding the proxy image."
}

variable "log_analytics_workspace_id" {
  type        = string
  description = "Resource id of an existing Log Analytics workspace for the ACA environment."
}

variable "graph_tenant_id" {
  type        = string
  description = "Azure AD tenant id the proxy authenticates against for Microsoft Graph."
}

variable "graph_client_id" {
  type        = string
  description = "Azure AD app client id used by the proxy for Graph calls."
}

variable "graph_client_secret_name" {
  type        = string
  description = "Key Vault secret name holding the Graph client secret (for example 'graph-client-secret')."
  default     = "graph-client-secret"
}

variable "jwt_signing_key_secret_name" {
  type        = string
  description = "Key Vault secret name holding the ProxyJwt:SigningKey (32+ characters)."
  default     = "proxy-jwt-signing-key"
}

variable "cpu" {
  type        = number
  description = "vCPU allocation per replica. See the sizing guide."
  default     = 1.0
}

variable "memory" {
  type        = string
  description = "Memory allocation per replica (e.g. '2.0Gi'). See the sizing guide."
  default     = "2.0Gi"
}

variable "min_replicas" {
  type        = number
  description = "Minimum replica count. Use 0 for non-production scale-to-zero."
  default     = 1
}

variable "max_replicas" {
  type        = number
  description = "Maximum replica count."
  default     = 10
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to every resource in the module."
  default     = {}
}

main.tf — Key Vault and managed identity

The vault stores the Graph client secret and the JWT signing key. The user-assigned identity has Secret — Get on the vault and AcrPull on the container registry.

data "azurerm_client_config" "current" {}

resource "azurerm_user_assigned_identity" "proxy" {
  name                = "${var.name_prefix}-proxy-identity"
  resource_group_name = var.resource_group_name
  location            = var.location
  tags                = var.tags
}

resource "azurerm_key_vault" "proxy" {
  name                      = "${var.name_prefix}-kv"
  resource_group_name       = var.resource_group_name
  location                  = var.location
  tenant_id                 = data.azurerm_client_config.current.tenant_id
  sku_name                  = "standard"
  enable_rbac_authorization = false
  purge_protection_enabled  = true
  tags                      = var.tags
}

resource "azurerm_key_vault_access_policy" "proxy_read" {
  key_vault_id = azurerm_key_vault.proxy.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = azurerm_user_assigned_identity.proxy.principal_id

  secret_permissions = ["Get"]
}

resource "azurerm_role_assignment" "acr_pull" {
  scope                = var.acr_resource_id
  role_definition_name = "AcrPull"
  principal_id         = azurerm_user_assigned_identity.proxy.principal_id
}

The azurerm_key_vault_access_policy uses vault access policies rather than RBAC for broad compatibility. Switch to RBAC (enable_rbac_authorization = true and azurerm_role_assignment with Key Vault Secrets User) if your organization's vaults are RBAC-only.

main.tf — Container Apps environment and container app

resource "azurerm_container_app_environment" "proxy" {
  name                       = "${var.name_prefix}-env"
  resource_group_name        = var.resource_group_name
  location                   = var.location
  log_analytics_workspace_id = var.log_analytics_workspace_id
  tags                       = var.tags
}

resource "azurerm_container_app" "proxy" {
  name                         = "${var.name_prefix}-proxy"
  resource_group_name          = var.resource_group_name
  container_app_environment_id = azurerm_container_app_environment.proxy.id
  revision_mode                = "Single"
  tags                         = var.tags

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.proxy.id]
  }

  registry {
    server   = split("/", var.proxy_image_uri)[0]
    identity = azurerm_user_assigned_identity.proxy.id
  }

  ingress {
    external_enabled = true
    target_port      = 5000
    transport        = "auto"

    traffic_weight {
      percentage      = 100
      latest_revision = true
    }
  }

  template {
    min_replicas = var.min_replicas
    max_replicas = var.max_replicas

    container {
      name   = "proxy"
      image  = var.proxy_image_uri
      cpu    = var.cpu
      memory = var.memory

      env {
        name  = "GraphClient__TenantId"
        value = var.graph_tenant_id
      }
      env {
        name  = "GraphClient__ClientId"
        value = var.graph_client_id
      }
      env {
        name  = "KeyVault__VaultUri"
        value = azurerm_key_vault.proxy.vault_uri
      }
      env {
        name  = "KeyVault__UseManagedIdentity"
        value = "true"
      }
      env {
        name  = "AZURE_CLIENT_ID"
        value = azurerm_user_assigned_identity.proxy.client_id
      }
      env {
        name  = "Audit__Enabled"
        value = "true"
      }
      env {
        name  = "Audit__DeploymentType"
        value = "hosted"
      }

      liveness_probe {
        transport = "HTTP"
        port      = 5000
        path      = "/health"
        interval_seconds  = 30
        timeout           = 5
        failure_count_threshold = 3
      }
    }

    http_scale_rule {
      name              = "http-concurrency"
      concurrent_requests = 50
    }
  }

  depends_on = [
    azurerm_key_vault_access_policy.proxy_read,
    azurerm_role_assignment.acr_pull,
  ]
}

The depends_on block is load-bearing: if the container app is created before the identity has AcrPull on the registry, the image pull fails and the revision enters a retry loop. Explicit ordering avoids the race.

outputs.tf

output "proxy_fqdn" {
  description = "External FQDN of the GraphWarden proxy. Clients rewrite Microsoft Graph base URL to this host."
  value       = azurerm_container_app.proxy.ingress[0].fqdn
}

output "proxy_identity_principal_id" {
  description = "Object id of the user-assigned managed identity. Grant Graph API permissions to this id."
  value       = azurerm_user_assigned_identity.proxy.principal_id
}

output "proxy_identity_client_id" {
  description = "Client id of the user-assigned managed identity. Matches the AZURE_CLIENT_ID env var in the container."
  value       = azurerm_user_assigned_identity.proxy.client_id
}

output "key_vault_id" {
  description = "Resource id of the Key Vault. Use to seed secrets with azurerm_key_vault_secret."
  value       = azurerm_key_vault.proxy.id
}

The module does not write secrets into the vault — you provision the vault and its identity permissions, then add the Graph client secret and JWT signing key in a separate pipeline step (or via a separate azurerm_key_vault_secret resource outside the module). Mixing secret material into a shared module is a common source of state-file leakage.

Usage example

From an environment root module:

module "graphwarden_proxy" {
  source = "../../modules/graphwarden"

  resource_group_name        = "rg-graphwarden-prod"
  location                   = "eastus2"
  name_prefix                = "gw-prod"
  proxy_image_uri            = "gwprodacr.azurecr.io/graphwarden-proxy:0.1.0"
  acr_resource_id            = data.azurerm_container_registry.main.id
  log_analytics_workspace_id = data.azurerm_log_analytics_workspace.main.id

  graph_tenant_id = "00000000-0000-0000-0000-000000000000"
  graph_client_id = "11111111-1111-1111-1111-111111111111"

  cpu          = 1.0
  memory       = "2.0Gi"
  min_replicas = 2
  max_replicas = 10

  tags = {
    environment = "production"
    owner       = "platform-team"
  }
}

output "proxy_fqdn" {
  value = module.graphwarden_proxy.proxy_fqdn
}

After terraform init && terraform plan reports no errors, run terraform apply. The first apply creates the identity, vault, environment, and container app in that order; subsequent applies produce no changes unless inputs change.

State management

Store Terraform state in Azure Storage with a lock-capable backend. Do not commit state to git — the state file contains resource identifiers and sometimes sensitive values.

# backend.tf — in the environment root module, not the reusable module.
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "tfstateorg"
    container_name       = "graphwarden"
    key                  = "production.tfstate"
    use_azuread_auth     = true
  }
}

The use_azuread_auth = true option authenticates state reads and writes with the user's (or pipeline's) Azure AD identity rather than a shared storage account key. Grant Storage Blob Data Contributor on the state container to every identity that runs Terraform against the environment.

Troubleshooting

Error: failed to create container app with ACR pull failed

The azurerm_role_assignment.acr_pull was created but role propagation has not completed (Azure IAM propagation can take 60-120 seconds). Wait a minute and rerun terraform apply. If the error persists, verify the identity's principal id is correctly bound to AcrPull in the Azure portal and that the registry's resource id is correct.

Error: authorization failed on azurerm_key_vault_access_policy

The identity running Terraform does not have rights to mutate Key Vault access policies. Grant Key Vault Administrator on the vault's resource group to the Terraform service principal, or switch to RBAC authorization (see the RBAC note in "main.tf — Key Vault" above) and pre-grant the Terraform identity Key Vault Secrets Officer.

State file contains the Graph tenant id in plaintext

This is expected. Terraform state captures every resource attribute including variable values. Protect the state file at the storage layer (Azure Storage blob-level encryption + network ACLs + Azure AD auth as above). If you need to redact specific values, mark them sensitive = true in the variable declaration — Terraform output hides them from CLI logs but they remain in state.

Plan shows changes to traffic_weight on every apply

The azurerm_container_app resource sometimes reports spurious diffs on traffic_weight because Azure normalizes the percentage to match the revision count. Setting latest_revision = true and leaving revision_mode = "Single" minimizes the drift. If diffs persist, add a lifecycle { ignore_changes = [ingress[0].traffic_weight] } block after confirming you are not using blue-green deployments.

Module apply hangs on azurerm_container_app creation

Container app creation polls until the first revision is healthy. If the first revision fails liveness (for example Key Vault access is not yet propagated), the apply waits up to 30 minutes before failing. Run az containerapp logs show against the provisioned app in a separate shell to see real-time logs; common causes are missing AZURE_CLIENT_ID, wrong vault URI, or a typo in the image tag.

Last reviewed:

Last reviewed: .