Skip to content

About

⚡ Terraform module for Azure Functions Flex Consumption: keyless end to end, plans that flex, a deploy story that works

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Terraform Azure Flex Consumption Function App

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.

CI Release Terraform Registry License


Overview

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 = false with the complete documented recipe: Storage Blob Data Owner (the host's secrets store) plus Blob, Queue, and Table Contributor for the identity, and AzureWebJobsStorage__accountName / __credential = managedidentity / __clientId app settings. The module also pins the bare AzureWebJobsStorage setting 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. (The az CLI still prints a harmless Failed to fetch host key warning 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_name is not welded to FC1, and app_service_environment_id is 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_file publish 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 fetches packageUri anonymously; 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 with az functionapp deployment source config-zip, keyed on the package hash, verified by curling the endpoint. zip_deploy_file stays 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.

Usage

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

  • 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-packages on 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 empty requirements.psd1 so the app needs no gallery access at runtime).

Developing

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.

Security scan exceptions

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).

Reference

The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.

Requirements

Name Version
terraform >= 1.9.0, < 2.0.0
azurerm >= 4.0.0, < 5.0.0

Providers

Name Version
azurerm >= 4.0.0, < 5.0.0

Modules

No modules.

Resources

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

Inputs

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({
runtime_name = string
runtime_version = string

service_plan_key = optional(string)
service_plan_id = optional(string)

# Storage (three shapes; see description).
create_storage_account = optional(bool, true)
storage_account_name = optional(string)
storage_account_id = optional(string)
storage_container_endpoint = optional(string)
storage_container_name = optional(string, "app-packages")
storage_shared_access_key_enabled = optional(bool, false)
storage_infrastructure_encryption_enabled = optional(bool, true)
storage_authentication_type = optional(string)
storage_access_key = optional(string)
storage_role_names = optional(list(string), ["Storage Blob Data Owner", "Storage Blob Data Contributor", "Storage Queue Data Contributor", "Storage Table Data Contributor"])
storage_account_replication_type = optional(string, "LRS")
storage_network_rules = optional(object({
default_action = string
bypass = optional(list(string), ["AzureServices"])
ip_rules = optional(list(string))
virtual_network_subnet_ids = optional(list(string))
}))
wire_host_storage_settings = optional(bool, true)

# Identity.
create_user_assigned_identity = optional(bool, true)
identity = optional(object({
type = string
identity_ids = optional(list(string))
}))

# Observability. The grant flag exists because the AI id is usually a same-plan module output
# (unknown until apply), and for_each keys must stay plan-known: set it alongside
# app_insights_id to grant Monitoring Metrics Publisher to the module-created identity.
app_insights_connection_string = optional(string)
app_insights_id = optional(string)
grant_app_insights_metrics_publisher = optional(bool, false)

# Scale and runtime.
maximum_instance_count = optional(number)
instance_memory_in_mb = optional(number, 2048)
http_concurrency = optional(number)
always_ready = optional(list(object({
name = string
instance_count = optional(number)
})), [])

# Security and networking.
https_only = optional(bool, true)
public_network_access_enabled = optional(bool, true)
virtual_network_subnet_id = optional(string)
client_certificate_enabled = optional(bool)
client_certificate_mode = optional(string)
client_certificate_exclusion_paths = optional(string)
webdeploy_publish_basic_authentication_enabled = optional(bool)
enabled = optional(bool, true)

# Deployment. zip_deploy_file is broken upstream for flex (its publish poll 404s); the
# supported pattern is pushing the package with one-deploy from OUTSIDE the resource (see the
# complete example: archive_file + az functionapp deployment source config-zip keyed on the
# package hash). An ARM-native pull deploy is impossible keyless: one-deploy fetches
# packageUri anonymously (verified live), and keyless accounts cannot mint SAS.
zip_deploy_file = optional(string)

# Settings.
app_settings = optional(map(string), {})
connection_strings = optional(list(object({
name = string
type = string
value = string
})), [])
sticky_settings = optional(object({
app_setting_names = optional(list(string))
connection_string_names = optional(list(string))
}))

site_config = optional(object({
api_definition_url = optional(string)
api_management_api_id = optional(string)
app_command_line = optional(string)
application_insights_connection_string = optional(string)
application_insights_key = optional(string)
container_registry_managed_identity_client_id = optional(string)
container_registry_use_managed_identity = optional(bool)
default_documents = optional(list(string))
elastic_instance_minimum = optional(number)
health_check_eviction_time_in_min = optional(number)
health_check_path = optional(string)
http2_enabled = optional(bool)
ip_restriction_default_action = optional(string)
load_balancing_mode = optional(string)
managed_pipeline_mode = optional(string)
minimum_tls_version = optional(string)
remote_debugging_enabled = optional(bool)
remote_debugging_version = optional(string)
runtime_scale_monitoring_enabled = optional(bool)
scm_ip_restriction_default_action = optional(string)
scm_minimum_tls_version = optional(string)
scm_use_main_ip_restriction = optional(bool)
use_32_bit_worker = optional(bool)
vnet_route_all_enabled = optional(bool)
websockets_enabled = optional(bool)
worker_count = optional(number)

app_service_logs = optional(object({
disk_quota_mb = optional(number)
retention_period_days = optional(number)
}))

cors = optional(object({
allowed_origins = optional(list(string))
support_credentials = optional(bool)
}))

ip_restrictions = optional(list(object({
action = optional(string)
description = optional(string)
ip_address = optional(string)
name = optional(string)
priority = optional(number)
service_tag = optional(string)
virtual_network_subnet_id = optional(string)
headers = optional(list(object({
x_azure_fdid = optional(list(string))
x_fd_health_probe = optional(list(string))
x_forwarded_for = optional(list(string))
x_forwarded_host = optional(list(string))
})))
})), [])

scm_ip_restrictions = optional(list(object({
action = optional(string)
description = optional(string)
ip_address = optional(string)
name = optional(string)
priority = optional(number)
service_tag = optional(string)
virtual_network_subnet_id = optional(string)
headers = optional(list(object({
x_azure_fdid = optional(list(string))
x_fd_health_probe = optional(list(string))
x_forwarded_for = optional(list(string))
x_forwarded_host = optional(list(string))
})))
})), [])
}), {})

auth_settings = optional(object({
enabled = bool
additional_login_parameters = optional(map(string))
allowed_external_redirect_urls = optional(list(string))
default_provider = optional(string)
issuer = optional(string)
runtime_version = optional(string)
token_refresh_extension_hours = optional(number)
token_store_enabled = optional(bool)
unauthenticated_client_action = optional(string)

active_directory = optional(object({
client_id = string
allowed_audiences = optional(list(string))
client_secret = optional(string)
client_secret_setting_name = optional(string)
}))
facebook = optional(object({
app_id = string
app_secret = optional(string)
app_secret_setting_name = optional(string)
oauth_scopes = optional(list(string))
}))
github = optional(object({
client_id = string
client_secret = optional(string)
client_secret_setting_name = optional(string)
oauth_scopes = optional(list(string))
}))
google = optional(object({
client_id = string
client_secret = optional(string)
client_secret_setting_name = optional(string)
oauth_scopes = optional(list(string))
}))
microsoft = optional(object({
client_id = string
client_secret = optional(string)
client_secret_setting_name = optional(string)
oauth_scopes = optional(list(string))
}))
twitter = optional(object({
consumer_key = string
consumer_secret = optional(string)
consumer_secret_setting_name = optional(string)
}))
}))

auth_settings_v2 = optional(object({
auth_enabled = optional(bool)
config_file_path = optional(string)
default_provider = optional(string)
excluded_paths = optional(list(string))
forward_proxy_convention = optional(string)
forward_proxy_custom_host_header_name = optional(string)
forward_proxy_custom_scheme_header_name = optional(string)
http_route_api_prefix = optional(string)
require_authentication = optional(bool)
require_https = optional(bool)
runtime_version = optional(string)
unauthenticated_action = optional(string)

active_directory_v2 = optional(object({
client_id = string
tenant_auth_endpoint = string
allowed_applications = optional(list(string))
allowed_audiences = optional(list(string))
allowed_groups = optional(list(string))
allowed_identities = optional(list(string))
client_secret_certificate_thumbprint = optional(string)
client_secret_setting_name = optional(string)
jwt_allowed_client_applications = optional(list(string))
jwt_allowed_groups = optional(list(string))
login_parameters = optional(map(string))
www_authentication_disabled = optional(bool)
}))
apple_v2 = optional(object({
client_id = string
client_secret_setting_name = string
}))
azure_static_web_app_v2 = optional(object({
client_id = string
}))
custom_oidc_v2 = optional(list(object({
client_id = string
name = string
openid_configuration_endpoint = string
name_claim_type = optional(string)
scopes = optional(list(string))
})), [])
facebook_v2 = optional(object({
app_id = string
app_secret_setting_name = string
graph_api_version = optional(string)
login_scopes = optional(list(string))
}))
github_v2 = optional(object({
client_id = string
client_secret_setting_name = string
login_scopes = optional(list(string))
}))
google_v2 = optional(object({
client_id = string
client_secret_setting_name = string
allowed_audiences = optional(list(string))
login_scopes = optional(list(string))
}))
microsoft_v2 = optional(object({
client_id = string
client_secret_setting_name = string
allowed_audiences = optional(list(string))
login_scopes = optional(list(string))
}))
twitter_v2 = optional(object({
consumer_key = string
consumer_secret_setting_name = string
}))
login = optional(object({
allowed_external_redirect_urls = optional(list(string))
cookie_expiration_convention = optional(string)
cookie_expiration_time = optional(string)
logout_endpoint = optional(string)
nonce_expiration_time = optional(string)
preserve_url_fragments_for_logins = optional(bool)
token_refresh_extension_time = optional(number)
token_store_enabled = optional(bool)
token_store_path = optional(string)
token_store_sas_setting_name = optional(string)
validate_nonce = optional(bool)
}), {})
}))

tags = optional(map(string))
}))
{} 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({
os_type = optional(string, "Linux")
sku_name = optional(string, "FC1")
app_service_environment_id = optional(string)
zone_balancing_enabled = optional(bool)
worker_count = optional(number)
tags = optional(map(string))
}))
{} no
tags Tags applied to everything the module creates (merged with any per-item tags). map(string) {} no

Outputs

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).

About

⚡ Terraform module for Azure Functions Flex Consumption: keyless end to end, plans that flex, a deploy story that works

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages