Azure Functions Flex Consumption done properly: keyless identity auth wired end to end, plans and storage that flex with you, and a deploy story that actually works.
Flex consumption is a massive resource with sharp edges, and this module took the live bruises so you do not have to. Fast to get going: an entry with nothing but a runtime gets a dedicated FC1 plan, keyless storage with a deployment container, a user-assigned identity granted the full documented role set BEFORE the app exists (system-assigned plus deploy-during-create is a bootstrap deadlock), and the identity host storage settings wired automatically. Flexible when it matters: every one of those defaults has an explicit override.
- Keyless by default, correctly.
shared_access_key_enabled = falsewith the complete documented recipe: Storage Blob Data Owner (the host's secrets store) plus Blob, Queue, and Table Contributor for the identity, andAzureWebJobsStorage__accountName/__credential = managedidentity/__clientIdapp settings. The module also pins the bareAzureWebJobsStoragesetting to an empty string, because the provider re-injects a key-based connection string even in identity mode (#29149); with shared keys disabled that poisoned setting breaks the host's secret manager, so every host and function key API, and every portal blade built on them (the Functions list, Test/Run, App keys), returns InternalServerError. With the poison neutralized the host stores its secrets through the identity, the key APIs serve, and the portal works, all verified live on a keyless app. (TheazCLI still prints a harmlessFailed to fetch host keywarning during deploys; judge deploys by the endpoint answering, not the CLI's exit chatter.) - Plans as a map, not a straitjacket. Multiple apps can share a plan,
sku_nameis not welded to FC1, andapp_service_environment_idis there for ASE placement. Apps that reference no plan get a dedicated FC1 plan automatically. - Storage in three shapes. Created (default, secure defaults throughout), bring-your-own account by id (the module still builds the container and still grants the roles, because it has the scope), or a raw container endpoint escape hatch where the caller owns all wiring.
- A deploy story that works today. The provider's
zip_deploy_filepublish path is broken upstream for flex (its status poll 404s on healthy apps), and an ARM-native pull deploy cannot work against keyless storage (one-deploy fetchespackageUrianonymously; verified live). The supported pattern is pushing the package with one-deploy from outside the resource, and the complete example shows it end to end: a real FastAPI app zipped by Terraform and pushed withaz functionapp deployment source config-zip, keyed on the package hash, verified by curling the endpoint.zip_deploy_filestays as a passthrough for when upstream fixes it (a check steers you away meanwhile). - Application Insights, AAD-ingestion ready. Pass the connection string and the AI resource id and the module wires the app setting, the AAD ingestion auth string, and the Monitoring Metrics Publisher grant.
- The full resource surface. Both auth_settings trees, site_config with CORS and IP restrictions, always-ready instances, sticky settings, connection strings, client certificates, VNet integration, and plan-time enforcement of the rules ARM only tells you about at apply (storage auth pairings, the flex instance memory sizes, CORS wildcard versus credentials).
Requires Terraform >= 1.9 and azurerm >= 4.0.
module "flex_function_app" {
source = "libre-devops/flex-consumption-function-app/azurerm"
version = "~> 4.0"
resource_group_id = module.rg.ids["rg-ldo-uks-prd-001"]
location = "uksouth"
tags = module.tags.tags
function_apps = {
"func-api-ldo-uks-prd-001" = {
runtime_name = "python"
runtime_version = "3.12"
app_insights_connection_string = module.application_insights.connection_strings["appi-ldo-uks-prd-001"]
app_insights_id = module.application_insights.ids["appi-ldo-uks-prd-001"]
}
}
}examples/minimal- one entry, nothing but a runtime: the whole secure stack arrives by default.examples/complete- the full infrastructure surface: a shared plan hosting two apps (keyless identity auth and the keys-on opt-out side by side), Application Insights with AAD ingestion, scale tuning, and site_config; its FastAPI package deploys in the pipeline's deploy stage.examples/keyless- the total-automated-keyless-deployment showcase: no keys, no SAS, anywhere.examples/offline-package- the vendored-wheels pattern for egress-blocked apps: pip installs into.python_packages/lib/site-packageson the runner, the package ships byte-identical, nothing builds server-side.examples/powershell- the PowerShell worker on the same secure stack (run.ps1+function.json, no build step, an emptyrequirements.psd1so the app needs no gallery access at runtime).
Local work needs PowerShell 7+ and just, because the recipes
wrap the LibreDevOpsHelpers
PowerShell module (the same engine the libre-devops/terraform-azure action runs in CI). Install
just with brew install just, or uv tool add rust-just then uv run just <recipe>.
Run just to list recipes: just update-ldo-pwsh (install or force-update LibreDevOpsHelpers from
PSGallery), just validate, just scan (Trivy only), just pwsh-analyze (PSScriptAnalyzer only),
just plan, just apply, just destroy, just e2e, just test, and just docs (the
plan/apply/destroy recipes mirror the action, including the storage firewall dance; just e2e
applies an example then always destroys it, defaulting to minimal, so nothing is left running).
Releasing is also just:
just increment-release [patch|minor|major] bumps, tags, and publishes a GitHub release, and the
Terraform Registry picks up the tag.
This module is scanned with Trivy; HIGH and CRITICAL
findings fail the build. Any waiver is a deliberate, reviewed decision, never a way to quiet a
finding that should be fixed. Waivers live in a .trivyignore.yaml (the machine-applied source of
truth, passed to Trivy with --ignorefile) and are mirrored in a table here so the reason is
auditable.
| ID | Scope | Reason |
|---|---|---|
| AVD-AZU-0012 (storage network rules) | module-created storage accounts | Not fixable with IP rules, proven live: default-Deny with the app's own possible outbound IPs allow-listed 403s the deployment service and 503s the running host, because flex reaches storage from platform ranges. The working lockdown is VNet integration plus service or private endpoints, expressed through the per-app storage_network_rules input. |
| AVD-AZU-0060 (customer-managed keys) | module-created storage accounts | Deliberate non-goal for deployment package storage; platform-managed keys plus default infrastructure (double) encryption are the accepted posture. |
| AVD-AZU-0057 (storage analytics logging) | module-created storage accounts | Superseded by diagnostic settings, which belong to the caller's observability topology (the diagnostic-settings module). |
The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.
| Name | Version |
|---|---|
| terraform | >= 1.9.0, < 2.0.0 |
| azurerm | >= 4.0.0, < 5.0.0 |
| Name | Version |
|---|---|
| azurerm | >= 4.0.0, < 5.0.0 |
No modules.
| Name | Type |
|---|---|
| azurerm_function_app_flex_consumption.this | resource |
| azurerm_role_assignment.app_insights | resource |
| azurerm_role_assignment.storage | resource |
| azurerm_service_plan.auto | resource |
| azurerm_service_plan.this | resource |
| azurerm_storage_account.this | resource |
| azurerm_storage_container.this | resource |
| azurerm_user_assigned_identity.this | resource |
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| function_apps | Flex consumption function apps keyed by name. Fast to get going: an entry with just runtime_name and runtime_version gets a dedicated FC1 plan, a keyless storage account with a deployment container, a user-assigned identity granted the full documented role set, and the identity-based host storage app settings wired automatically. Flexible when it matters: every default has an explicit override. PLAN: exactly one of service_plan_key (a plan from service_plans), service_plan_id (bring your own), or neither (dedicated FC1 plan created). STORAGE, three shapes: created (default), bring-your-own account via storage_account_id (the module still builds the container and can still grant roles because it has the scope), or the raw storage_container_endpoint escape hatch (no grants, caller owns all wiring). storage_shared_access_key_enabled defaults FALSE (keyless): deploys, runtime, the host and function key APIs, and the portal blades all work with identity auth. When keyless identity auth is active the module wires AzureWebJobsStorage__accountName/__credential/__clientId automatically and pins the bare AzureWebJobsStorage setting to an empty string, because the provider re-injects a key-based connection string even in identity mode (upstream #29149) and that poisoned setting breaks the host's secret manager (and with it every key API and portal blade). Set wire_host_storage_settings = false to opt out of all of it. IDENTITY: the module creates a user-assigned identity per app by default (create_user_assigned_identity), attached as "SystemAssigned, UserAssigned" so both kinds are live, because system-assigned plus deploy-during-create is a bootstrap deadlock (the grant needs the principal id, the deploy needs the grant). Pass identity to bring your own of any type (the module then grants nothing on storage: the identity owner does). Set create_user_assigned_identity = false with no identity block for an identity-less app: storage auth then derives to StorageAccountConnectionString, which needs keys on. DEPLOY: push the package from outside the resource with one-deploy (see the complete example: archive_file plus az functionapp deployment source config-zip keyed on the package hash). The azurerm zip_deploy_file publish path is broken upstream for flex and stays as a passthrough; an ARM-native pull deploy cannot work keyless because one-deploy fetches packageUri anonymously. APP INSIGHTS: pass app_insights_connection_string to wire the app setting; with an app_insights_id and a module-created identity the AAD ingestion auth string and Monitoring Metrics Publisher grant are wired too. |
map(object({ |
{} |
no |
| location | The Azure region the plans, apps, and created storage live in. | string |
n/a | yes |
| resource_group_id | The id of the resource group everything lands in. Parsed for the resource group name. | string |
n/a | yes |
| service_plans | Service plans the module creates, keyed by name. Multiple function apps can share one plan by referencing its key, even though flex consumption is commonly one app per plan today. sku_name is not welded to FC1 and app_service_environment_id allows App Service Environment placement, so the map stays general purpose. Apps that reference no plan at all get a dedicated FC1 plan named asp- automatically (one module call = running app). |
map(object({ |
{} |
no |
| tags | Tags applied to everything the module creates (merged with any per-item tags). | map(string) |
{} |
no |
| Name | Description |
|---|---|
| default_hostnames | Map of app name to its default hostname (https:// is the app's base URL). |
| function_app_ids | Map of app name to its id. |
| function_app_ids_zipmap | Map of app name to { name, id }, for easy composition with other modules. |
| function_apps | Map of app name to the full flex consumption app object. Sensitive as a whole because the object carries storage_access_key and the site credentials; the ids, hostnames, and identity maps below stay plain for composition. |
| identity_principal_ids | Map of app name to { system_assigned, user_assigned } principal ids (nulls where an identity kind is absent). |
| possible_outbound_ip_address_lists | Map of app name to the app's possible outbound IPs: the allow-list for locking the backing storage down after create (see the complete example). |
| service_plan_ids | Map of plan key (from service_plans plus the dedicated asp- plans) to its id. |
| storage_account_ids | Map of app name to the created storage account id (only apps with created storage). |
| storage_container_endpoints | Map of app name to the deployment container endpoint the app runs from. |
| user_assigned_identity_ids | Map of app name to the module-created user assigned identity id (only apps with create_user_assigned_identity). |