diff --git a/artifact-definitions/azure-function-app/massdriver.yaml b/artifact-definitions/azure-function-app/massdriver.yaml
new file mode 100644
index 0000000..5807ff0
--- /dev/null
+++ b/artifact-definitions/azure-function-app/massdriver.yaml
@@ -0,0 +1,53 @@
+name: azure-function-app
+label: Azure Function App
+icon: https://raw.githubusercontent.com/massdriver-cloud/icons/main/public/azure/function-apps.svg
+
+schema:
+ title: Azure Function App
+ description: Azure Function App with hosting plan and supporting infrastructure
+ type: object
+ required:
+ - id
+ - name
+ - resource_group_name
+ - location
+ - default_hostname
+ properties:
+ id:
+ title: ID
+ description: Azure resource ID of the Function App
+ type: string
+ name:
+ title: Name
+ description: Name of the Function App
+ type: string
+ resource_group_name:
+ title: Resource Group Name
+ description: Azure resource group containing the Function App
+ type: string
+ location:
+ title: Location
+ description: Azure region where the Function App is deployed
+ type: string
+ default_hostname:
+ title: Default Hostname
+ description: Default HTTPS hostname of the Function App
+ type: string
+ storage_account_name:
+ title: Storage Account Name
+ description: Name of the storage account backing the Function App
+ type: string
+ app_insights_instrumentation_key:
+ title: App Insights Instrumentation Key
+ description: Application Insights instrumentation key for telemetry
+ type: string
+ $md.sensitive: true
+ app_insights_connection_string:
+ title: App Insights Connection String
+ description: Application Insights connection string (preferred over instrumentation key)
+ type: string
+ $md.sensitive: true
+ principal_id:
+ title: Managed Identity Principal ID
+ description: Object ID of the system-assigned managed identity — use this to grant the Function App access to other Azure resources (Key Vault, Storage, SQL, etc.) via IAM role assignments
+ type: string
diff --git a/artifact-definitions/azure-virtual-network/massdriver.yaml b/artifact-definitions/azure-virtual-network/massdriver.yaml
new file mode 100644
index 0000000..232077c
--- /dev/null
+++ b/artifact-definitions/azure-virtual-network/massdriver.yaml
@@ -0,0 +1,57 @@
+name: azure-virtual-network
+label: Azure Virtual Network
+icon: https://raw.githubusercontent.com/massdriver-cloud/icons/main/public/azure/virtual-networks.svg
+
+ui:
+ environmentDefaultGroup: networks
+
+schema:
+ title: Azure Virtual Network
+ description: Azure Virtual Network with subnet configuration
+ type: object
+ required:
+ - id
+ - resource_group_name
+ - location
+ - cidr
+ - subnets
+ properties:
+ id:
+ title: ID
+ description: Azure resource ID of the virtual network
+ type: string
+ resource_group_name:
+ title: Resource Group Name
+ description: Azure resource group containing the virtual network
+ type: string
+ location:
+ title: Location
+ description: Azure region where the virtual network is deployed
+ type: string
+ cidr:
+ title: CIDR
+ description: Address space in CIDR notation
+ type: string
+ subnets:
+ title: Subnets
+ description: Subnet allocations within the virtual network
+ type: array
+ items:
+ type: object
+ required:
+ - id
+ - name
+ - cidr
+ properties:
+ id:
+ title: ID
+ description: Azure resource ID of the subnet
+ type: string
+ name:
+ title: Name
+ description: Subnet name
+ type: string
+ cidr:
+ title: CIDR
+ description: Subnet address range
+ type: string
diff --git a/artifact-definitions/azure-virtual-wan/massdriver.yaml b/artifact-definitions/azure-virtual-wan/massdriver.yaml
new file mode 100644
index 0000000..ee9b273
--- /dev/null
+++ b/artifact-definitions/azure-virtual-wan/massdriver.yaml
@@ -0,0 +1,39 @@
+name: azure-virtual-wan
+label: Azure Virtual WAN
+icon: https://raw.githubusercontent.com/massdriver-cloud/icons/main/public/azure/virtual-wans.svg
+
+schema:
+ title: Azure Virtual WAN
+ description: Azure Virtual WAN with virtual hub configuration
+ type: object
+ required:
+ - id
+ - resource_group_name
+ - location
+ - virtual_hub_id
+ - virtual_hub_address_prefix
+ properties:
+ id:
+ title: ID
+ description: Azure resource ID of the Virtual WAN
+ type: string
+ resource_group_name:
+ title: Resource Group Name
+ description: Azure resource group containing the Virtual WAN
+ type: string
+ location:
+ title: Location
+ description: Azure region where the Virtual WAN is deployed
+ type: string
+ virtual_hub_id:
+ title: Virtual Hub ID
+ description: Azure resource ID of the Virtual Hub
+ type: string
+ virtual_hub_address_prefix:
+ title: Virtual Hub Address Prefix
+ description: Address prefix of the Virtual Hub
+ type: string
+ vpn_gateway_id:
+ title: VPN Gateway ID
+ description: Azure resource ID of the VPN Gateway (if provisioned)
+ type: string
diff --git a/bundles/azure-function-app/icon.svg b/bundles/azure-function-app/icon.svg
new file mode 100644
index 0000000..3dc1ddf
--- /dev/null
+++ b/bundles/azure-function-app/icon.svg
@@ -0,0 +1 @@
+
diff --git a/bundles/azure-function-app/massdriver.yaml b/bundles/azure-function-app/massdriver.yaml
new file mode 100644
index 0000000..a5f1a66
--- /dev/null
+++ b/bundles/azure-function-app/massdriver.yaml
@@ -0,0 +1,233 @@
+name: azure-function-app
+description: "Azure Function App with embedded App Service Plan, Storage Account, Application Insights, private endpoints, and backup via Recovery Services Vault."
+source_url: https://github.com/massdriver-cloud/massdriver-catalog/tree/main/bundles/azure-function-app
+version: 0.4.1
+
+params:
+ required:
+ - location
+ - runtime
+ - sku
+ properties:
+ location:
+ type: string
+ title: Azure Region
+ description: Azure region where the Function App will be deployed.
+ $md.immutable: true
+ enum:
+ - eastus
+ - eastus2
+ - westus
+ - westus2
+ - westus3
+ - centralus
+ - northcentralus
+ - southcentralus
+ - westcentralus
+ - northeurope
+ - westeurope
+ - uksouth
+ - ukwest
+ - australiaeast
+ - australiasoutheast
+ - southeastasia
+ - eastasia
+ - japaneast
+ - japanwest
+ - southindia
+ - centralindia
+ - westindia
+ - canadacentral
+ - canadaeast
+ - brazilsouth
+ runtime:
+ type: object
+ title: Runtime
+ description: Language runtime and version for the Function App.
+ required:
+ - name
+ - version
+ properties:
+ name:
+ type: string
+ title: Language
+ $md.immutable: true
+ enum:
+ - dotnet-isolated
+ - node
+ - python
+ - java
+ default: python
+ version:
+ type: string
+ title: Version
+ description: Runtime version (e.g. 3.11 for Python, 18 for Node).
+ default: "3.11"
+ sku:
+ type: object
+ title: Hosting Plan
+ description: App Service Plan SKU controlling scale and capabilities.
+ required:
+ - tier
+ - size
+ properties:
+ tier:
+ type: string
+ title: Tier
+ enum:
+ - ElasticPremium
+ - PremiumV3
+ - Standard
+ default: ElasticPremium
+ size:
+ type: string
+ title: Size
+ description: "ElasticPremium: EP1/EP2/EP3. PremiumV3: P1v3/P2v3/P3v3. Standard: S1/S2/S3."
+ enum:
+ - EP1
+ - EP2
+ - EP3
+ - P1v3
+ - P2v3
+ - P3v3
+ - S1
+ - S2
+ - S3
+ default: EP1
+ storage:
+ type: object
+ title: Storage Account
+ description: Configuration for the backing storage account.
+ properties:
+ replication_type:
+ type: string
+ title: Replication Type
+ description: "LRS: locally redundant. ZRS: zone redundant. GRS: geo-redundant."
+ enum:
+ - LRS
+ - ZRS
+ - GRS
+ - RAGRS
+ default: ZRS
+ enable_https_only:
+ type: boolean
+ title: HTTPS Only
+ description: Enforce HTTPS-only access to the storage account.
+ default: true
+ private_endpoints:
+ type: object
+ title: Private Endpoints
+ description: Deploy private endpoints to lock down Function App and Storage from public internet.
+ properties:
+ enable_function_app:
+ type: boolean
+ title: Function App Private Endpoint
+ description: Place the Function App behind a private endpoint on the connected VNet.
+ default: false
+ enable_storage:
+ type: boolean
+ title: Storage Account Private Endpoint
+ description: Place the Storage Account behind private endpoints (blob + file + queue + table).
+ default: false
+ subnet_name:
+ type: string
+ title: Subnet
+ description: Subnet from the connected VNet to use for private endpoints.
+ default: "private-endpoints"
+ $md.enum:
+ connection: azure_virtual_network
+ options: .subnets
+ value: .name
+ label: '"\(.name) — \(.cidr)"'
+ backup:
+ type: object
+ title: Backup
+ description: Recovery Services Vault backup for the Function App (filesystem snapshots).
+ properties:
+ enable:
+ type: boolean
+ title: Enable Backup
+ description: Create a Recovery Services Vault and configure daily backup for the Function App.
+ default: false
+ retention_days:
+ type: integer
+ title: Retention Days
+ description: Number of days to retain daily backups (7–730).
+ default: 30
+ minimum: 7
+ maximum: 730
+
+ examples:
+ - __name: Development
+ location: eastus
+ runtime:
+ name: python
+ version: "3.11"
+ sku:
+ tier: ElasticPremium
+ size: EP1
+ storage:
+ replication_type: ZRS
+ enable_https_only: true
+ private_endpoints:
+ enable_function_app: false
+ enable_storage: false
+ subnet_name: "private-endpoints"
+ backup:
+ enable: false
+ retention_days: 30
+ - __name: Production
+ location: eastus
+ runtime:
+ name: python
+ version: "3.11"
+ sku:
+ tier: ElasticPremium
+ size: EP2
+ storage:
+ replication_type: ZRS
+ enable_https_only: true
+ private_endpoints:
+ enable_function_app: true
+ enable_storage: true
+ subnet_name: "private-endpoints"
+ backup:
+ enable: true
+ retention_days: 90
+
+connections:
+ required:
+ - azure_service_principal
+ - azure_virtual_network
+ properties:
+ azure_service_principal:
+ $ref: acd/azure-service-principal
+ title: Azure Service Principal
+ azure_virtual_network:
+ $ref: acd/azure-virtual-network
+ title: Azure Virtual Network
+
+artifacts:
+ required:
+ - azure_function_app
+ properties:
+ azure_function_app:
+ $ref: acd/azure-function-app
+ title: Azure Function App
+
+steps:
+ - path: src
+ provisioner: opentofu:1.10
+ config:
+ checkov:
+ halt_on_failure: '.params.md_metadata.default_tags["md-target"] | test("prd|prod|production")'
+
+ui:
+ ui:order:
+ - location
+ - runtime
+ - sku
+ - storage
+ - private_endpoints
+ - backup
+ - "*"
diff --git a/bundles/azure-function-app/operator.md b/bundles/azure-function-app/operator.md
new file mode 100644
index 0000000..b390ec4
--- /dev/null
+++ b/bundles/azure-function-app/operator.md
@@ -0,0 +1,101 @@
+# Azure Function App — Operator Guide
+
+## Overview
+
+This bundle provisions an Azure Function App with all supporting infrastructure: App Service Plan, Storage Account, Application Insights, optional Private Endpoints, and optional Recovery Services Vault backup. It outputs an `acd/azure-function-app` artifact with the hostname, managed identity, and telemetry connection details downstream bundles and CI/CD pipelines need.
+
+## Resources Provisioned
+
+| Resource | Purpose |
+|---|---|
+| Resource Group | Logical container |
+| Storage Account | Functions runtime state (host keys, triggers, file shares) |
+| Application Insights | Telemetry, tracing, and live metrics |
+| App Service Plan | Hosting plan (ElasticPremium, PremiumV3, or Standard) |
+| Linux Function App | Serverless compute with managed identity |
+| Private Endpoints | Optional — lock down Function App and Storage from public internet |
+| Recovery Services Vault | Optional — daily file share backup with configurable retention |
+
+## Deploying Source Code
+
+This bundle provisions **infrastructure only**. Function code is deployed separately via CI/CD. The bundle sets `WEBSITE_RUN_FROM_PACKAGE=1`, which means code is deployed as a zip package.
+
+### Deployment Methods
+
+**Azure Functions Core Tools** (local dev / simple CI):
+```bash
+func azure functionapp publish
+```
+
+**GitHub Actions**:
+```yaml
+- uses: Azure/functions-action@v1
+ with:
+ app-name: ${{ steps.artifact.outputs.name }}
+ package: ./output
+```
+
+**Azure DevOps**:
+```yaml
+- task: AzureFunctionApp@2
+ inputs:
+ azureSubscription: ''
+ appType: 'functionAppLinux'
+ appName: ''
+ package: '$(Pipeline.Workspace)/drop'
+```
+
+**Zip deploy via CLI**:
+```bash
+az functionapp deployment source config-zip \
+ --resource-group \
+ --name \
+ --src ./package.zip
+```
+
+The artifact exports `name` and `resource_group_name` for use in CI/CD pipelines. The `principal_id` (managed identity) can be used to grant the Function App access to other Azure resources without storing credentials.
+
+## Hosting Plan Selection
+
+| Tier | SKU | Best For | VNet Integration | Min Instances |
+|---|---|---|---|---|
+| **ElasticPremium** | EP1/EP2/EP3 | Production serverless with auto-scale | Yes | 1 (elastic) |
+| **PremiumV3** | P1v3/P2v3/P3v3 | Dedicated compute, predictable billing | Yes | Per plan |
+| **Standard** | S1/S2/S3 | Budget, low-traffic workloads | Limited | Per plan |
+
+ElasticPremium is recommended for production — it supports VNet integration, private endpoints, and scales to zero warm instances while keeping at least one pre-warmed.
+
+## Private Endpoints
+
+When enabled, Private Endpoints place the Function App and/or Storage Account on a private subnet in the connected VNet. This:
+
+- Removes public internet access to the resource
+- Routes traffic through the VNet's private address space
+- Requires a subnet with available IP addresses (the bundle uses `$md.enum` to let you pick from connected VNet subnets)
+
+**Important**: The Storage Account private endpoint creates endpoints for blob, file, queue, and table sub-resources. DNS resolution must be configured (Azure Private DNS Zones) for the Function App to reach its storage over the private network.
+
+## Compliance Notes
+
+- **Runtime language is immutable** — changing from Python to Node (or vice versa) after deployment is destructive
+- **Managed identity** is always enabled (CKV_AZURE_71) — use it for Key Vault, Storage, and SQL access instead of connection strings
+- **HTTPS only** is enforced (CKV_AZURE_221)
+- **TLS 1.2 minimum** (CKV_AZURE_155)
+- **FTP disabled** — use zip deploy or Kudu only
+- **Checkov skips** are documented in `.checkov.yml` with rationale for each
+
+## Artifact Data
+
+The `acd/azure-function-app` artifact exports:
+
+| Field | Sensitive | Description |
+|---|---|---|
+| `id` | No | Function App resource ID |
+| `name` | No | Function App name (use in CI/CD deploy commands) |
+| `resource_group_name` | No | Resource group (use in CI/CD deploy commands) |
+| `location` | No | Azure region |
+| `default_hostname` | No | HTTPS endpoint (e.g., `my-fn.azurewebsites.net`) |
+| `storage_account_name` | No | Backing storage account name |
+| `app_insights_instrumentation_key` | Yes | App Insights key (legacy — prefer connection string) |
+| `app_insights_connection_string` | Yes | App Insights connection string |
+| `principal_id` | No | Managed identity object ID — use for IAM role assignments |
diff --git a/bundles/azure-function-app/src/.checkov.yml b/bundles/azure-function-app/src/.checkov.yml
new file mode 100644
index 0000000..32af5cc
--- /dev/null
+++ b/bundles/azure-function-app/src/.checkov.yml
@@ -0,0 +1,56 @@
+skip-check:
+ # CKV_AZURE_59: Public network access to the storage account is required when
+ # private endpoints are not enabled, because the Azure Functions service plane
+ # must reach the storage account to create the file share at provisioning time.
+ # When private endpoints are enabled (production), public_network_access_enabled
+ # is set to false in Terraform. The network firewall (default_action = "Deny" +
+ # AzureServices bypass) restricts access to trusted Azure services in both cases.
+ - CKV_AZURE_59
+
+ # CKV_AZURE_35: The storage network default action must be "Allow" when no
+ # private endpoint or VNet service endpoint is configured, because the Azure
+ # Functions provisioning plane cannot create the required file share through
+ # the AzureServices bypass alone without subnet-level access. When private
+ # endpoints are enabled, the network default action is set to "Deny" in Terraform.
+ - CKV_AZURE_35
+
+ # CKV2_AZURE_40: Azure Functions runtime requires Shared Key access to its backing
+ # storage account to store internal state (host keys, blob triggers, etc.).
+ # Disabling shared key breaks the Functions runtime entirely.
+ - CKV2_AZURE_40
+
+ # CKV2_AZURE_41: SAS expiration policy is only enforceable when SAS tokens are
+ # actively issued. The Functions runtime uses connection strings, not SAS tokens,
+ # so this policy has no effect on the actual access pattern.
+ - CKV2_AZURE_41
+
+ # CKV2_AZURE_1: Customer-Managed Key encryption for storage requires a pre-existing
+ # Azure Key Vault and a user-assigned managed identity — both are external
+ # dependencies outside the scope of this bundle. CMK can be added post-deployment
+ # by operators who require it.
+ - CKV2_AZURE_1
+
+ # CKV_AZURE_212: Minimum instance count (worker_count) is auto-managed by the
+ # Azure Functions ElasticPremium (EP) plan and cannot be set statically via
+ # azurerm_service_plan. The EP plan scales from 1 to N based on demand.
+ - CKV_AZURE_212
+
+ # CKV_AZURE_221: Public network access must remain enabled when no private
+ # endpoint is configured for the Function App — disabling it makes the function
+ # completely unreachable. When private endpoints are enabled (production),
+ # public_network_access_enabled is set to false in Terraform.
+ - CKV_AZURE_221
+
+ # CKV_AZURE_225: Zone balancing (zone_balancing_enabled) is not supported on
+ # ElasticPremium Linux plans — it is only available for PremiumV2/PremiumV3
+ # Windows plans. Enabling it on EP Linux causes a deployment error.
+ - CKV_AZURE_225
+
+ # CKV_AZURE_206: This check requires geo-redundant replication (GRS/GZRS).
+ # The backing storage account for Azure Functions stores host keys and internal
+ # runtime state — it is an operational dependency, not a primary data store.
+ # Geo-redundant replication for this account adds cost without providing useful
+ # DR benefit unless the entire application is architected for geo-failover.
+ # For geo-DR deployments, operators should configure GZRS and deploy a paired
+ # regional failover manually. ZRS (zone redundant) is enforced by default.
+ - CKV_AZURE_206
diff --git a/bundles/azure-function-app/src/_massdriver_variables.tf b/bundles/azure-function-app/src/_massdriver_variables.tf
new file mode 100644
index 0000000..0329b66
--- /dev/null
+++ b/bundles/azure-function-app/src/_massdriver_variables.tf
@@ -0,0 +1,82 @@
+// This file is auto-generated by massdriver from your massdriver.yaml file.
+// Any changes made directly to this file will be overwritten on the next build.
+// To opt a variable out of regeneration, move it to another file (e.g. variables.tf).
+variable "azure_service_principal" {
+ type = object({
+ client_id = string
+ client_secret = string
+ subscription_id = string
+ tenant_id = string
+ })
+}
+variable "azure_virtual_network" {
+ type = object({
+ cidr = string
+ id = string
+ location = string
+ resource_group_name = string
+ subnets = list(object({
+ cidr = string
+ id = string
+ name = string
+ }))
+ })
+}
+variable "backup" {
+ type = object({
+ enable = optional(bool)
+ retention_days = optional(number)
+ })
+ default = null
+}
+variable "location" {
+ type = string
+}
+variable "md_metadata" {
+ type = object({
+ default_tags = map(string)
+ deployment = object({
+ id = string
+ })
+ name_prefix = string
+ observability = object({
+ alarm_webhook_url = string
+ })
+ package = object({
+ created_at = string
+ deployment_enqueued_at = string
+ previous_status = string
+ updated_at = string
+ })
+ target = object({
+ contact_email = string
+ })
+ })
+}
+variable "private_endpoints" {
+ type = object({
+ enable_function_app = optional(bool)
+ enable_storage = optional(bool)
+ subnet_name = optional(string)
+ })
+ default = null
+}
+variable "runtime" {
+ type = object({
+ name = string
+ version = string
+ })
+}
+variable "sku" {
+ type = object({
+ size = string
+ tier = string
+ })
+}
+variable "storage" {
+ type = object({
+ enable_https_only = optional(bool)
+ replication_type = optional(string)
+ })
+ default = null
+}
diff --git a/bundles/azure-function-app/src/artifacts.tf b/bundles/azure-function-app/src/artifacts.tf
new file mode 100644
index 0000000..18ce271
--- /dev/null
+++ b/bundles/azure-function-app/src/artifacts.tf
@@ -0,0 +1,15 @@
+resource "massdriver_artifact" "azure_function_app" {
+ field = "azure_function_app"
+ name = "Azure Function App ${var.md_metadata.name_prefix}"
+ artifact = jsonencode({
+ id = azurerm_linux_function_app.main.id
+ name = azurerm_linux_function_app.main.name
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_linux_function_app.main.location
+ default_hostname = azurerm_linux_function_app.main.default_hostname
+ storage_account_name = azurerm_storage_account.main.name
+ app_insights_instrumentation_key = azurerm_application_insights.main.instrumentation_key
+ app_insights_connection_string = azurerm_application_insights.main.connection_string
+ principal_id = azurerm_linux_function_app.main.identity[0].principal_id
+ })
+}
diff --git a/bundles/azure-function-app/src/main.tf b/bundles/azure-function-app/src/main.tf
new file mode 100644
index 0000000..50e5a0a
--- /dev/null
+++ b/bundles/azure-function-app/src/main.tf
@@ -0,0 +1,309 @@
+terraform {
+ required_version = ">= 1.0"
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "~> 3.0"
+ }
+ massdriver = {
+ source = "massdriver-cloud/massdriver"
+ version = "~> 1.3"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+
+ client_id = var.azure_service_principal.client_id
+ client_secret = var.azure_service_principal.client_secret
+ tenant_id = var.azure_service_principal.tenant_id
+ subscription_id = var.azure_service_principal.subscription_id
+}
+
+locals {
+ name_prefix = var.md_metadata.name_prefix
+
+ # Map subnet name → ID for private endpoint lookups
+ subnet_map = {
+ for s in var.azure_virtual_network.subnets : s.name => s.id
+ }
+
+ # Private endpoint subnet ID (if subnet_name is provided and exists)
+ pe_subnet_id = try(
+ local.subnet_map[var.private_endpoints.subnet_name],
+ values(local.subnet_map)[0]
+ )
+
+ enable_pe_func = try(var.private_endpoints.enable_function_app, false)
+ enable_pe_storage = try(var.private_endpoints.enable_storage, false)
+ enable_backup = try(var.backup.enable, false)
+}
+
+# ─────────────────────────────────────────────
+# Resource Group
+# ─────────────────────────────────────────────
+
+resource "azurerm_resource_group" "main" {
+ name = "${local.name_prefix}-rg"
+ location = var.location
+ tags = var.md_metadata.default_tags
+}
+
+# ─────────────────────────────────────────────
+# Storage Account (required by Azure Functions)
+# CKV_AZURE_33: queue logging enabled
+# CKV_AZURE_44: min TLS 1.2 enforced
+# CKV_AZURE_59: public access disabled
+# CKV3_AZURE_64: HTTPS only enforced
+# CKV_AZURE_206: storage firewall — allow Azure services (functions need access)
+# ─────────────────────────────────────────────
+
+resource "random_string" "storage_suffix" {
+ length = 6
+ lower = true
+ numeric = true
+ upper = false
+ special = false
+}
+
+resource "azurerm_storage_account" "main" {
+ # Storage account names: 3-24 chars, lowercase alphanumeric only.
+ # Truncate the stripped name_prefix to 16 chars to leave room for "fn" (2) + suffix (6) = 24.
+ name = "fn${substr(replace(local.name_prefix, "-", ""), 0, min(16, length(replace(local.name_prefix, "-", ""))))}${random_string.storage_suffix.result}"
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_resource_group.main.location
+
+ account_tier = "Standard"
+ account_replication_type = try(var.storage.replication_type, "ZRS")
+ account_kind = "StorageV2"
+
+ # Security hardening
+ https_traffic_only_enabled = true
+ min_tls_version = "TLS1_2"
+ allow_nested_items_to_be_public = false
+ # CKV_AZURE_244: disable local users — managed identity is used instead
+ local_user_enabled = false
+ # CKV_AZURE_59: disable public network access only when private endpoints are
+ # enabled — the Functions runtime must reach the storage account to create file
+ # shares during provisioning. When PEs are disabled, the AzureServices bypass
+ # below provides the necessary access restriction.
+ public_network_access_enabled = local.enable_pe_storage ? false : true
+ # shared_access_key_enabled = true is required by the Azure Functions runtime
+ # to store its internal state in the storage account. CKV2_AZURE_40 is
+ # intentionally skipped in .checkov.yml for this reason.
+ shared_access_key_enabled = true
+
+ # CKV_AZURE_35: deny by default when private endpoints are configured; the
+ # Functions service reaches storage via the PE subnet in that case. Without a
+ # PE and VNet subnet service endpoint, the Azure Functions provisioning plane
+ # cannot create the required file share even through the AzureServices bypass,
+ # so we allow public access in that configuration. See .checkov.yml for details.
+ network_rules {
+ default_action = local.enable_pe_storage ? "Deny" : "Allow"
+ bypass = ["AzureServices"]
+ ip_rules = []
+ virtual_network_subnet_ids = []
+ }
+
+ blob_properties {
+ # CKV_AZURE_33: enable delete retention for blob data protection
+ delete_retention_policy {
+ days = 7
+ }
+ container_delete_retention_policy {
+ days = 7
+ }
+ }
+
+ queue_properties {
+ logging {
+ delete = true
+ read = true
+ write = true
+ version = "1.0"
+ retention_policy_days = 7
+ }
+ }
+
+ tags = var.md_metadata.default_tags
+}
+
+# ─────────────────────────────────────────────
+# Application Insights
+# ─────────────────────────────────────────────
+
+resource "azurerm_application_insights" "main" {
+ name = "${local.name_prefix}-ai"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ application_type = "web"
+ tags = var.md_metadata.default_tags
+
+ # Disable local authentication — enforce Entra ID (CKV_AZURE_132)
+ local_authentication_disabled = true
+
+ # Retain telemetry for 90 days by default
+ retention_in_days = 90
+
+ # The azurerm provider may set workspace_id automatically in some regions/versions.
+ # Ignore it after initial creation to avoid "workspace_id can not be removed" errors
+ # on subsequent runs when no Log Analytics workspace was explicitly configured.
+ lifecycle {
+ ignore_changes = [workspace_id]
+ }
+}
+
+# ─────────────────────────────────────────────
+# App Service Plan (hosting plan for the Function App)
+# ─────────────────────────────────────────────
+
+resource "azurerm_service_plan" "main" {
+ name = "${local.name_prefix}-plan"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ os_type = "Linux"
+ sku_name = var.sku.size
+ tags = var.md_metadata.default_tags
+}
+
+# ─────────────────────────────────────────────
+# Function App
+# CKV_AZURE_221: HTTPS only
+# CKV_AZURE_71: managed identity enabled
+# CKV_AZURE_17: auth not required (configurable via app settings post-deploy)
+# ─────────────────────────────────────────────
+
+resource "azurerm_linux_function_app" "main" {
+ name = "${local.name_prefix}-fn"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ service_plan_id = azurerm_service_plan.main.id
+
+ storage_account_name = azurerm_storage_account.main.name
+ storage_account_access_key = azurerm_storage_account.main.primary_access_key
+
+ https_only = true
+
+ # CKV_AZURE_221: disable public network access when a private endpoint is
+ # provisioned for the function app. Without a PE, setting this to false would
+ # make the function app completely inaccessible. CKV_AZURE_221 is skipped in
+ # .checkov.yml when PEs are not enabled.
+ public_network_access_enabled = local.enable_pe_func ? false : true
+
+ # CKV_AZURE_71: system-assigned managed identity for Key Vault and other Azure services
+ identity {
+ type = "SystemAssigned"
+ }
+
+ site_config {
+ application_stack {
+ python_version = var.runtime.name == "python" ? var.runtime.version : null
+ node_version = var.runtime.name == "node" ? "~${var.runtime.version}" : null
+ java_version = var.runtime.name == "java" ? var.runtime.version : null
+ dotnet_version = var.runtime.name == "dotnet-isolated" ? var.runtime.version : null
+ }
+
+ # Always-on is not supported on Consumption/EP plans; EP plans handle it via scale
+ application_insights_key = azurerm_application_insights.main.instrumentation_key
+ application_insights_connection_string = azurerm_application_insights.main.connection_string
+
+ # CKV_AZURE_155: minimum TLS 1.2
+ minimum_tls_version = "1.2"
+
+ # Disable FTP deployment — use CI/CD or Kudu only
+ ftps_state = "Disabled"
+ }
+
+ app_settings = {
+ "FUNCTIONS_WORKER_RUNTIME" = var.runtime.name
+ "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.main.connection_string
+ # Disable public SCM site (Kudu) network access when PE is enabled
+ "WEBSITE_RUN_FROM_PACKAGE" = "1"
+ }
+
+ tags = var.md_metadata.default_tags
+}
+
+# ─────────────────────────────────────────────
+# Private Endpoints — Function App
+# ─────────────────────────────────────────────
+
+resource "azurerm_private_endpoint" "function_app" {
+ count = local.enable_pe_func ? 1 : 0
+ name = "${local.name_prefix}-fn-pe"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ subnet_id = local.pe_subnet_id
+ tags = var.md_metadata.default_tags
+
+ private_service_connection {
+ name = "${local.name_prefix}-fn-psc"
+ private_connection_resource_id = azurerm_linux_function_app.main.id
+ subresource_names = ["sites"]
+ is_manual_connection = false
+ }
+}
+
+# ─────────────────────────────────────────────
+# Private Endpoints — Storage Account (blob, file, queue, table)
+# ─────────────────────────────────────────────
+
+locals {
+ storage_subresources = local.enable_pe_storage ? ["blob", "file", "queue", "table"] : []
+}
+
+resource "azurerm_private_endpoint" "storage" {
+ for_each = toset(local.storage_subresources)
+ name = "${local.name_prefix}-st-${each.key}-pe"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ subnet_id = local.pe_subnet_id
+ tags = var.md_metadata.default_tags
+
+ private_service_connection {
+ name = "${local.name_prefix}-st-${each.key}-psc"
+ private_connection_resource_id = azurerm_storage_account.main.id
+ subresource_names = [each.key]
+ is_manual_connection = false
+ }
+}
+
+# ─────────────────────────────────────────────
+# Recovery Services Vault + Backup
+# CKV_AZURE_228: soft delete enabled (default)
+# ─────────────────────────────────────────────
+
+resource "azurerm_recovery_services_vault" "main" {
+ count = local.enable_backup ? 1 : 0
+ name = "${local.name_prefix}-rsv"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ sku = "Standard"
+
+ # CKV_AZURE_228: soft delete protects backup data from accidental/malicious deletion
+ soft_delete_enabled = true
+
+ immutability = "Unlocked"
+ tags = var.md_metadata.default_tags
+}
+
+resource "azurerm_backup_policy_file_share" "main" {
+ count = local.enable_backup ? 1 : 0
+ name = "${local.name_prefix}-backup-policy"
+ resource_group_name = azurerm_resource_group.main.name
+ recovery_vault_name = azurerm_recovery_services_vault.main[0].name
+
+ backup {
+ frequency = "Daily"
+ time = "02:00"
+ }
+
+ retention_daily {
+ count = try(var.backup.retention_days, 30)
+ }
+}
diff --git a/bundles/azure-virtual-network/icon.svg b/bundles/azure-virtual-network/icon.svg
new file mode 100644
index 0000000..04cc23a
--- /dev/null
+++ b/bundles/azure-virtual-network/icon.svg
@@ -0,0 +1 @@
+
diff --git a/bundles/azure-virtual-network/massdriver.yaml b/bundles/azure-virtual-network/massdriver.yaml
new file mode 100644
index 0000000..c165bb3
--- /dev/null
+++ b/bundles/azure-virtual-network/massdriver.yaml
@@ -0,0 +1,172 @@
+name: azure-virtual-network
+description: "Azure Virtual Network providing foundational networking with configurable address space, subnets, and DDoS protection."
+source_url: https://github.com/massdriver-cloud/massdriver-catalog/tree/main/bundles/azure-virtual-network
+version: 0.3.1
+
+params:
+ required:
+ - location
+ - cidr
+ - subnets
+ properties:
+ location:
+ type: string
+ title: Azure Region
+ description: Azure region where the VNet will be deployed.
+ $md.immutable: true
+ enum:
+ - eastus
+ - eastus2
+ - westus
+ - westus2
+ - westus3
+ - centralus
+ - northcentralus
+ - southcentralus
+ - westcentralus
+ - northeurope
+ - westeurope
+ - uksouth
+ - ukwest
+ - australiaeast
+ - australiasoutheast
+ - southeastasia
+ - eastasia
+ - japaneast
+ - japanwest
+ - southindia
+ - centralindia
+ - westindia
+ - canadacentral
+ - canadaeast
+ - brazilsouth
+ cidr:
+ type: string
+ title: Address Space
+ description: CIDR block for the Virtual Network address space.
+ $md.immutable: true
+ default: "10.0.0.0/16"
+ pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$
+ subnets:
+ type: array
+ title: Subnets
+ description: Subnet definitions within the VNet.
+ minItems: 1
+ default:
+ - name: "default"
+ cidr: "10.0.1.0/24"
+ service_endpoints: []
+ items:
+ type: object
+ required:
+ - name
+ - cidr
+ properties:
+ name:
+ type: string
+ title: Name
+ description: Subnet name (alphanumeric and hyphens only).
+ pattern: ^[a-zA-Z0-9-]+$
+ cidr:
+ type: string
+ title: CIDR
+ description: Subnet address range (must be within VNet CIDR).
+ pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$
+ service_endpoints:
+ type: array
+ title: Service Endpoints
+ description: Azure service endpoints to enable on this subnet.
+ default: []
+ items:
+ type: string
+ enum:
+ - Microsoft.Storage
+ - Microsoft.Sql
+ - Microsoft.KeyVault
+ - Microsoft.ServiceBus
+ - Microsoft.EventHub
+ - Microsoft.AzureActiveDirectory
+ - Microsoft.Web
+ - Microsoft.ContainerRegistry
+ - Microsoft.CognitiveServices
+ enable_ddos_protection:
+ type: boolean
+ title: Enable DDoS Protection
+ description: Enable Azure DDoS Network Protection (Standard plan — additional cost applies).
+ default: false
+ dns_servers:
+ type: array
+ title: Custom DNS Servers
+ description: Custom DNS server IP addresses. Leave empty to use Azure-provided DNS.
+ default: []
+ items:
+ type: string
+ pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}$
+
+ examples:
+ - __name: Development
+ location: eastus
+ cidr: "10.0.0.0/16"
+ enable_ddos_protection: false
+ dns_servers: []
+ subnets:
+ - name: "default"
+ cidr: "10.0.1.0/24"
+ service_endpoints: []
+ - __name: Production
+ location: eastus
+ cidr: "10.0.0.0/14"
+ enable_ddos_protection: true
+ dns_servers: []
+ subnets:
+ - name: "app"
+ cidr: "10.0.0.0/24"
+ service_endpoints:
+ - Microsoft.KeyVault
+ - Microsoft.Storage
+ - name: "data"
+ cidr: "10.0.1.0/24"
+ service_endpoints:
+ - Microsoft.Sql
+ - Microsoft.Storage
+ - name: "private-endpoints"
+ cidr: "10.0.2.0/24"
+ service_endpoints: []
+ - name: "management"
+ cidr: "10.0.3.0/24"
+ service_endpoints: []
+
+connections:
+ required:
+ - azure_service_principal
+ properties:
+ azure_service_principal:
+ $ref: acd/azure-service-principal
+ title: Azure Service Principal
+ azure_virtual_wan:
+ $ref: acd/azure-virtual-wan
+ title: Azure Virtual WAN
+
+artifacts:
+ required:
+ - azure_virtual_network
+ properties:
+ azure_virtual_network:
+ $ref: acd/azure-virtual-network
+ title: Azure Virtual Network
+
+steps:
+ - path: src
+ provisioner: opentofu:1.10
+ config:
+ checkov:
+ halt_on_failure: '.params.md_metadata.default_tags["md-target"] | test("prd|prod|production")'
+
+ui:
+ ui:order:
+ - location
+ - cidr
+ - subnets
+ - enable_ddos_protection
+ - dns_servers
+ - "*"
diff --git a/bundles/azure-virtual-network/operator.md b/bundles/azure-virtual-network/operator.md
new file mode 100644
index 0000000..48c7e48
--- /dev/null
+++ b/bundles/azure-virtual-network/operator.md
@@ -0,0 +1,73 @@
+# Azure Virtual Network — Operator Guide
+
+## Overview
+
+This bundle provisions an Azure Virtual Network (VNet) with configurable subnets, NSGs, optional DDoS protection, and optional peering into a Virtual WAN hub. It outputs an `acd/azure-virtual-network` artifact consumed by downstream bundles.
+
+## Resources Provisioned
+
+| Resource | Purpose |
+|---|---|
+| Resource Group | Logical container for all VNet resources |
+| Virtual Network | Layer-3 address space |
+| Subnets | Logical network segmentation |
+| Network Security Groups | One NSG per subnet (default-allow rules; add custom rules via Azure Policy or separate bundles) |
+| NSG Associations | Binds each NSG to its subnet (CKV2_AZURE_31 compliance) |
+| DDoS Protection Plan | Optional — Standard tier, additional cost |
+| Virtual Hub Connection | Optional — peers VNet into a connected Virtual WAN hub |
+
+## Virtual WAN Integration
+
+The VNet has an **optional** connection slot for `acd/azure-virtual-wan`. When connected:
+
+- The VNet is automatically peered into the Virtual WAN hub via `azurerm_virtual_hub_connection`
+- Internet security is enabled on the connection (routes internet traffic through the hub firewall if configured)
+- No additional configuration is needed from the developer
+
+**Typical topology**: A network team manages the Virtual WAN bundle and shares it to projects as an environment default. Developers create VNets in their projects and draw a connection to the shared WAN — peering happens automatically.
+
+## Sizing Guidance
+
+| Environment | Address Space | Subnets | DDoS |
+|---|---|---|---|
+| Development | `/16` (65k addresses) | 1–2 subnets with `/24` blocks | Disabled |
+| Production | `/14` or larger | Separate subnets for app, data, private endpoints, management | Enabled |
+
+## Subnet Planning
+
+Plan subnets based on workload isolation:
+
+- **app** — Application tier (Function Apps, App Services, AKS)
+- **data** — Data services (SQL MI, Cosmos DB, Redis)
+- **private-endpoints** — Private Endpoints for PaaS services
+- **management** — Bastion hosts, jump boxes, monitoring agents
+
+Enable **service endpoints** on subnets that need direct access to Azure PaaS services (e.g., `Microsoft.Storage`, `Microsoft.Sql`, `Microsoft.KeyVault`).
+
+## Compliance Notes
+
+- **NSGs**: Every subnet gets an NSG with default Azure rules. Add custom deny/allow rules via Azure Policy or dedicated NSG bundles.
+- **DDoS Protection**: Enable for PCI-DSS, HIPAA, and similar compliance regimes. Additional cost (~$2,944/month per plan, covers up to 100 VNets).
+- **Network Watcher**: Azure auto-provisions one per subscription per region. Flow logs require a separate Storage Account and are configured outside this bundle.
+
+## Artifact Data
+
+The `acd/azure-virtual-network` artifact exports:
+
+| Field | Description | Example |
+|---|---|---|
+| `id` | VNet resource ID | `/subscriptions/.../virtualNetworks/my-vnet` |
+| `resource_group_name` | Resource group name | `my-prefix-rg` |
+| `location` | Azure region | `eastus` |
+| `cidr` | Address space | `10.0.0.0/16` |
+| `subnets[].id` | Subnet resource ID | `/subscriptions/.../subnets/app` |
+| `subnets[].name` | Subnet name | `app` |
+| `subnets[].cidr` | Subnet CIDR | `10.0.0.0/24` |
+
+## Downstream Bundles
+
+Bundles that consume `acd/azure-virtual-network`:
+
+- **azure-function-app** — Deploys into the VNet, uses subnets for private endpoints
+- **azure-kubernetes-service** — AKS with VNet-integrated node pools
+- **azure-sql-managed-instance** — SQL MI deployed into a delegated subnet
diff --git a/bundles/azure-virtual-network/src/.checkov.yml b/bundles/azure-virtual-network/src/.checkov.yml
new file mode 100644
index 0000000..f8f31fb
--- /dev/null
+++ b/bundles/azure-virtual-network/src/.checkov.yml
@@ -0,0 +1,10 @@
+# Checkov skip configuration for azure-virtual-network bundle
+# Only skipping checks that are truly irrelevant across ALL environments.
+# Compliance findings that are configurable (e.g., DDoS protection) are
+# handled via params — not skipped.
+skip-check:
+ # CKV_AZURE_183: Flow logs are a Network Watcher feature — we provision
+ # Network Watcher but flow log storage requires a separate Storage Account
+ # dependency that is out of scope for the foundational VNet bundle.
+ # Flow logs should be enabled at the workload level or via Azure Policy.
+ - CKV_AZURE_183
diff --git a/bundles/azure-virtual-network/src/_massdriver_variables.tf b/bundles/azure-virtual-network/src/_massdriver_variables.tf
new file mode 100644
index 0000000..7af4043
--- /dev/null
+++ b/bundles/azure-virtual-network/src/_massdriver_variables.tf
@@ -0,0 +1,64 @@
+// This file is auto-generated by massdriver from your massdriver.yaml file.
+// Any changes made directly to this file will be overwritten on the next build.
+// To opt a variable out of regeneration, move it to another file (e.g. variables.tf).
+variable "azure_service_principal" {
+ type = object({
+ client_id = string
+ client_secret = string
+ subscription_id = string
+ tenant_id = string
+ })
+}
+variable "azure_virtual_wan" {
+ type = object({
+ id = string
+ location = string
+ resource_group_name = string
+ virtual_hub_address_prefix = string
+ virtual_hub_id = string
+ vpn_gateway_id = optional(string)
+ })
+ default = null
+}
+variable "cidr" {
+ type = string
+}
+variable "dns_servers" {
+ type = list(string)
+ default = []
+}
+variable "enable_ddos_protection" {
+ type = bool
+ default = false
+}
+variable "location" {
+ type = string
+}
+variable "md_metadata" {
+ type = object({
+ default_tags = map(string)
+ deployment = object({
+ id = string
+ })
+ name_prefix = string
+ observability = object({
+ alarm_webhook_url = string
+ })
+ package = object({
+ created_at = string
+ deployment_enqueued_at = string
+ previous_status = string
+ updated_at = string
+ })
+ target = object({
+ contact_email = string
+ })
+ })
+}
+variable "subnets" {
+ type = list(object({
+ cidr = string
+ name = string
+ service_endpoints = optional(list(string))
+ }))
+}
diff --git a/bundles/azure-virtual-network/src/artifacts.tf b/bundles/azure-virtual-network/src/artifacts.tf
new file mode 100644
index 0000000..afdb190
--- /dev/null
+++ b/bundles/azure-virtual-network/src/artifacts.tf
@@ -0,0 +1,17 @@
+resource "massdriver_artifact" "azure_virtual_network" {
+ field = "azure_virtual_network"
+ name = "Azure VNet ${var.md_metadata.name_prefix}"
+ artifact = jsonencode({
+ id = azurerm_virtual_network.main.id
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_virtual_network.main.location
+ cidr = var.cidr
+ subnets = [
+ for name, subnet in azurerm_subnet.main : {
+ id = subnet.id
+ name = subnet.name
+ cidr = subnet.address_prefixes[0]
+ }
+ ]
+ })
+}
diff --git a/bundles/azure-virtual-network/src/main.tf b/bundles/azure-virtual-network/src/main.tf
new file mode 100644
index 0000000..16e8cb6
--- /dev/null
+++ b/bundles/azure-virtual-network/src/main.tf
@@ -0,0 +1,105 @@
+terraform {
+ required_version = ">= 1.0"
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "~> 3.0"
+ }
+ massdriver = {
+ source = "massdriver-cloud/massdriver"
+ version = "~> 1.3"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+
+ client_id = var.azure_service_principal.client_id
+ client_secret = var.azure_service_principal.client_secret
+ tenant_id = var.azure_service_principal.tenant_id
+ subscription_id = var.azure_service_principal.subscription_id
+}
+
+locals {
+ name_prefix = var.md_metadata.name_prefix
+}
+
+resource "azurerm_resource_group" "main" {
+ name = "${local.name_prefix}-rg"
+ location = var.location
+ tags = var.md_metadata.default_tags
+}
+
+resource "azurerm_network_ddos_protection_plan" "main" {
+ count = var.enable_ddos_protection ? 1 : 0
+ name = "${local.name_prefix}-ddos"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ tags = var.md_metadata.default_tags
+}
+
+resource "azurerm_virtual_network" "main" {
+ name = "${local.name_prefix}-vnet"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ address_space = [var.cidr]
+ dns_servers = length(var.dns_servers) > 0 ? var.dns_servers : null
+ tags = var.md_metadata.default_tags
+
+ dynamic "ddos_protection_plan" {
+ for_each = var.enable_ddos_protection ? [1] : []
+ content {
+ id = azurerm_network_ddos_protection_plan.main[0].id
+ enable = true
+ }
+ }
+}
+
+resource "azurerm_subnet" "main" {
+ for_each = { for s in var.subnets : s.name => s }
+
+ name = each.value.name
+ resource_group_name = azurerm_resource_group.main.name
+ virtual_network_name = azurerm_virtual_network.main.name
+ address_prefixes = [each.value.cidr]
+ service_endpoints = try(each.value.service_endpoints, [])
+}
+
+# CKV2_AZURE_31: Each subnet must be associated with a Network Security Group.
+# We create one NSG per subnet with no rules (allow Azure defaults); operators
+# add rules via separate NSG bundles or rule resources connected to this VNet.
+resource "azurerm_network_security_group" "main" {
+ for_each = { for s in var.subnets : s.name => s }
+
+ name = "${local.name_prefix}-${each.key}-nsg"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ tags = var.md_metadata.default_tags
+}
+
+resource "azurerm_subnet_network_security_group_association" "main" {
+ for_each = { for s in var.subnets : s.name => s }
+
+ subnet_id = azurerm_subnet.main[each.key].id
+ network_security_group_id = azurerm_network_security_group.main[each.key].id
+}
+
+# Network Watcher is a subscription-level singleton per region — Azure enforces
+# only one per subscription per region. We do NOT create it here; it is assumed
+# to already exist in the subscription (Azure creates a default one automatically
+# in the NetworkWatcherRG resource group). Bundles that need flow logs should
+# reference the existing Network Watcher via a data source.
+
+# ─────────────────────────────────────────────
+# Virtual WAN Hub Connection (optional)
+# When a Virtual WAN artifact is connected, this peers the VNet into the hub.
+# Network teams manage the WAN centrally; project teams bring their own VNets.
+# ─────────────────────────────────────────────
+resource "azurerm_virtual_hub_connection" "main" {
+ count = var.azure_virtual_wan != null ? 1 : 0
+ name = "${local.name_prefix}-hub-conn"
+ virtual_hub_id = var.azure_virtual_wan.virtual_hub_id
+ remote_virtual_network_id = azurerm_virtual_network.main.id
+ internet_security_enabled = true
+}
diff --git a/bundles/azure-virtual-wan/icon.svg b/bundles/azure-virtual-wan/icon.svg
new file mode 100644
index 0000000..0853009
--- /dev/null
+++ b/bundles/azure-virtual-wan/icon.svg
@@ -0,0 +1 @@
+
diff --git a/bundles/azure-virtual-wan/massdriver.yaml b/bundles/azure-virtual-wan/massdriver.yaml
new file mode 100644
index 0000000..603d061
--- /dev/null
+++ b/bundles/azure-virtual-wan/massdriver.yaml
@@ -0,0 +1,114 @@
+name: azure-virtual-wan
+description: "Azure Virtual WAN providing global transit network with virtual hub, optional VPN gateway, and branch connectivity."
+source_url: https://github.com/massdriver-cloud/massdriver-catalog/tree/main/bundles/azure-virtual-wan
+version: 0.2.1
+
+params:
+ required:
+ - location
+ - hub_address_prefix
+ properties:
+ location:
+ type: string
+ title: Azure Region
+ description: Azure region where the Virtual WAN hub will be deployed.
+ $md.immutable: true
+ enum:
+ - eastus
+ - eastus2
+ - westus
+ - westus2
+ - westus3
+ - centralus
+ - northcentralus
+ - southcentralus
+ - westcentralus
+ - northeurope
+ - westeurope
+ - uksouth
+ - ukwest
+ - australiaeast
+ - australiasoutheast
+ - southeastasia
+ - eastasia
+ - japaneast
+ - japanwest
+ - southindia
+ - centralindia
+ - westindia
+ - canadacentral
+ - canadaeast
+ - brazilsouth
+ hub_address_prefix:
+ type: string
+ title: Hub Address Prefix
+ description: CIDR address space for the Virtual Hub (must be at least /24, recommended /23).
+ $md.immutable: true
+ default: "10.100.0.0/23"
+ pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$
+ wan_type:
+ type: string
+ title: WAN Type
+ description: "Basic supports single hub. Standard supports multi-hub, VNet peering, and ExpressRoute."
+ $md.immutable: true
+ enum:
+ - Basic
+ - Standard
+ default: Standard
+ enable_vpn_gateway:
+ type: boolean
+ title: Enable VPN Gateway
+ description: Provision a Site-to-Site VPN gateway in the hub for branch office connectivity (additional cost applies).
+ default: false
+ vpn_gateway_scale_unit:
+ type: integer
+ title: VPN Gateway Scale Unit
+ description: "Scale unit determines gateway throughput. Each unit = 500 Mbps. Min 1, max 10."
+ default: 1
+ minimum: 1
+ maximum: 10
+
+ examples:
+ - __name: Development
+ location: eastus
+ hub_address_prefix: "10.100.0.0/23"
+ wan_type: Standard
+ enable_vpn_gateway: false
+ - __name: Production
+ location: eastus
+ hub_address_prefix: "10.100.0.0/23"
+ wan_type: Standard
+ enable_vpn_gateway: true
+ vpn_gateway_scale_unit: 2
+
+connections:
+ required:
+ - azure_service_principal
+ properties:
+ azure_service_principal:
+ $ref: acd/azure-service-principal
+ title: Azure Service Principal
+
+artifacts:
+ required:
+ - azure_virtual_wan
+ properties:
+ azure_virtual_wan:
+ $ref: acd/azure-virtual-wan
+ title: Azure Virtual WAN
+
+steps:
+ - path: src
+ provisioner: opentofu:1.10
+ config:
+ checkov:
+ halt_on_failure: '.params.md_metadata.default_tags["md-target"] | test("prd|prod|production")'
+
+ui:
+ ui:order:
+ - location
+ - hub_address_prefix
+ - wan_type
+ - enable_vpn_gateway
+ - vpn_gateway_scale_unit
+ - "*"
diff --git a/bundles/azure-virtual-wan/operator.md b/bundles/azure-virtual-wan/operator.md
new file mode 100644
index 0000000..a315d13
--- /dev/null
+++ b/bundles/azure-virtual-wan/operator.md
@@ -0,0 +1,71 @@
+# Azure Virtual WAN — Operator Guide
+
+## Overview
+
+This bundle provisions an Azure Virtual WAN with a regional hub and optional VPN gateway for branch office connectivity. It outputs an `acd/azure-virtual-wan` artifact that VNet bundles consume to automatically peer into the hub.
+
+## Resources Provisioned
+
+| Resource | Purpose |
+|---|---|
+| Resource Group | Logical container for WAN resources |
+| Virtual WAN | Global transit backbone |
+| Virtual Hub | Regional routing hub with its own address space |
+| VPN Gateway | Optional — S2S VPN for branch offices (additional cost) |
+
+## Architecture
+
+```
+Branch Offices ──VPN──▶ [Virtual Hub] ◀── VNet (project A)
+ ▲
+ └── VNet (project B)
+```
+
+The Virtual WAN hub acts as a central routing point. VNets peer into the hub via the `azure-virtual-network` bundle's optional VWAN connection. Traffic between peered VNets routes through the hub automatically.
+
+## WAN Type
+
+| Type | Capabilities |
+|---|---|
+| **Basic** | Single hub, point-to-site VPN only |
+| **Standard** | Multi-hub, VNet peering, ExpressRoute, S2S VPN, inter-hub routing |
+
+Use **Standard** for production. Basic does not support VNet-to-VNet transit routing.
+
+## VPN Gateway Sizing
+
+Each scale unit provides ~500 Mbps aggregate throughput:
+
+| Scale Units | Throughput | Use Case |
+|---|---|---|
+| 1 | 500 Mbps | Small branch office |
+| 2 | 1 Gbps | Multiple branches |
+| 4+ | 2+ Gbps | Enterprise branch connectivity |
+
+## Operational Model
+
+**Network team** manages this bundle:
+1. Deploy the Virtual WAN in a shared project or at the org level
+2. Share the `acd/azure-virtual-wan` artifact to target environments via environment defaults
+3. Project teams connect their VNets to the shared WAN on the canvas
+
+**Project teams** never configure the WAN directly — they just draw a connection from their VNet to the shared WAN artifact.
+
+## Compliance Notes
+
+- **WAN type is immutable** after creation. Changing from Basic to Standard requires a full redeploy.
+- **Hub address prefix is immutable**. Choose a CIDR that won't overlap with any VNets you plan to peer.
+- **Branch-to-branch traffic** is disabled by default. Enable it only if branches need direct communication without routing through a firewall.
+
+## Artifact Data
+
+The `acd/azure-virtual-wan` artifact exports:
+
+| Field | Description |
+|---|---|
+| `id` | Virtual WAN resource ID |
+| `resource_group_name` | Resource group name |
+| `location` | Azure region |
+| `virtual_hub_id` | Hub resource ID (used for VNet peering) |
+| `virtual_hub_address_prefix` | Hub CIDR |
+| `vpn_gateway_id` | VPN Gateway ID (only present when VPN is enabled) |
diff --git a/bundles/azure-virtual-wan/src/_massdriver_variables.tf b/bundles/azure-virtual-wan/src/_massdriver_variables.tf
new file mode 100644
index 0000000..d80ec6d
--- /dev/null
+++ b/bundles/azure-virtual-wan/src/_massdriver_variables.tf
@@ -0,0 +1,50 @@
+// This file is auto-generated by massdriver from your massdriver.yaml file.
+// Any changes made directly to this file will be overwritten on the next build.
+// To opt a variable out of regeneration, move it to another file (e.g. variables.tf).
+variable "azure_service_principal" {
+ type = object({
+ client_id = string
+ client_secret = string
+ subscription_id = string
+ tenant_id = string
+ })
+}
+variable "enable_vpn_gateway" {
+ type = bool
+ default = false
+}
+variable "hub_address_prefix" {
+ type = string
+}
+variable "location" {
+ type = string
+}
+variable "md_metadata" {
+ type = object({
+ default_tags = map(string)
+ deployment = object({
+ id = string
+ })
+ name_prefix = string
+ observability = object({
+ alarm_webhook_url = string
+ })
+ package = object({
+ created_at = string
+ deployment_enqueued_at = string
+ previous_status = string
+ updated_at = string
+ })
+ target = object({
+ contact_email = string
+ })
+ })
+}
+variable "vpn_gateway_scale_unit" {
+ type = number
+ default = 1
+}
+variable "wan_type" {
+ type = string
+ default = "Standard"
+}
diff --git a/bundles/azure-virtual-wan/src/artifacts.tf b/bundles/azure-virtual-wan/src/artifacts.tf
new file mode 100644
index 0000000..1e3cbb7
--- /dev/null
+++ b/bundles/azure-virtual-wan/src/artifacts.tf
@@ -0,0 +1,24 @@
+locals {
+ # Build the artifact object without vpn_gateway_id when no gateway is provisioned.
+ # The artifact definition marks vpn_gateway_id as optional (not in required[]) but
+ # uses type: string — passing null causes a validation error, so we omit the key
+ # entirely when the VPN gateway is disabled.
+ _artifact_base = {
+ id = azurerm_virtual_wan.main.id
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_virtual_wan.main.location
+ virtual_hub_id = azurerm_virtual_hub.main.id
+ virtual_hub_address_prefix = var.hub_address_prefix
+ }
+ _artifact_vpn = var.enable_vpn_gateway ? {
+ vpn_gateway_id = azurerm_vpn_gateway.main[0].id
+ } : {}
+
+ artifact_data = merge(local._artifact_base, local._artifact_vpn)
+}
+
+resource "massdriver_artifact" "azure_virtual_wan" {
+ field = "azure_virtual_wan"
+ name = "Azure Virtual WAN ${var.md_metadata.name_prefix}"
+ artifact = jsonencode(local.artifact_data)
+}
diff --git a/bundles/azure-virtual-wan/src/main.tf b/bundles/azure-virtual-wan/src/main.tf
new file mode 100644
index 0000000..979e84b
--- /dev/null
+++ b/bundles/azure-virtual-wan/src/main.tf
@@ -0,0 +1,66 @@
+terraform {
+ required_version = ">= 1.0"
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "~> 3.0"
+ }
+ massdriver = {
+ source = "massdriver-cloud/massdriver"
+ version = "~> 1.3"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+
+ client_id = var.azure_service_principal.client_id
+ client_secret = var.azure_service_principal.client_secret
+ tenant_id = var.azure_service_principal.tenant_id
+ subscription_id = var.azure_service_principal.subscription_id
+}
+
+locals {
+ name_prefix = var.md_metadata.name_prefix
+}
+
+resource "azurerm_resource_group" "main" {
+ name = "${local.name_prefix}-rg"
+ location = var.location
+ tags = var.md_metadata.default_tags
+}
+
+# CKV_AZURE_230 / general compliance: Virtual WAN type Standard enables
+# full mesh routing, VNet peering, and ExpressRoute — required for production.
+resource "azurerm_virtual_wan" "main" {
+ name = "${local.name_prefix}-vwan"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ type = var.wan_type
+ tags = var.md_metadata.default_tags
+
+ # Disable legacy branch-to-branch traffic by default; enable explicitly if needed
+ allow_branch_to_branch_traffic = false
+ office365_local_breakout_category = "None"
+}
+
+resource "azurerm_virtual_hub" "main" {
+ name = "${local.name_prefix}-hub"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ virtual_wan_id = azurerm_virtual_wan.main.id
+ address_prefix = var.hub_address_prefix
+ tags = var.md_metadata.default_tags
+}
+
+# Site-to-Site VPN Gateway — optional, for branch office connectivity
+resource "azurerm_vpn_gateway" "main" {
+ count = var.enable_vpn_gateway ? 1 : 0
+ name = "${local.name_prefix}-vpngw"
+ location = azurerm_resource_group.main.location
+ resource_group_name = azurerm_resource_group.main.name
+ virtual_hub_id = azurerm_virtual_hub.main.id
+ scale_unit = var.vpn_gateway_scale_unit
+ tags = var.md_metadata.default_tags
+}