Deploy Azure Functions with Pulumi Python
An Azure Function is not one resource but four that must agree with each other: a storage account, a hosting plan, a function app, and its settings. This guide, part of Azure provider configuration under Pulumi patterns and provider management, assembles a Python function app on a consumption plan and explains which settings are load-bearing.
Context
Most failed function deployments are not code problems — they are a missing app setting or a runtime mismatch between the plan and the app. Azure requires a storage account for the function runtime's own bookkeeping, and the FUNCTIONS_WORKER_RUNTIME setting must match the language actually deployed, or the app starts and serves nothing. Modelling all four resources in one program, in the spirit of the reusable component patterns, keeps them consistent.
The four resources are not peers. The resource group is a naming and lifecycle scope; the storage account is a hard runtime dependency the Functions host reads on every cold start; the hosting plan decides both the operating system and the scaling model; and the function app is the only one of the four that carries application configuration. Pulumi's dependency graph will order them correctly on the first pulumi up because each one references the previous through an Output, but it cannot tell you that the values you passed are mutually consistent. That check is yours.
The consistency rule worth internalising: reserved on the plan, the kind string on the app, and linux_fx_version in the site config all describe the same decision — Linux or Windows — in three different vocabularies. Azure's control plane accepts almost any combination of them without complaint and produces an app that never serves a request. There is no ARM validation error for a Windows plan hosting a functionapp,linux app; the deployment succeeds and the host simply reports the runtime as unreachable.
Prerequisites
pulumi-azure-native>=2.0and Contributor on the resource group- A storage account in the same region — the function runtime requires one
- Python 3.9–3.11 for the function code; the app's
linux_fx_versionmust match func(Azure Functions Core Tools) if you intend to publish code from the same pipeline
Contributor on the resource group is enough to create all four resources but not enough to assign roles, which matters if you later move to identity-based storage access. Role assignment needs Microsoft.Authorization/roleAssignments/write, carried by User Access Administrator or Owner. Sort out which principal your pipeline uses before you need it — authenticating Pulumi to Azure with service principals covers how that principal is established and scoped.
Region availability is the other pre-flight check. Not every region offers Y1 on Linux, and the failure arrives late, from the ARM API rather than from Pulumi:
azure-native:web:AppServicePlan (functions-plan): error: Code="SubscriptionIsOverQuotaForSku"
Message="This region has quota of 0 instances for your subscription. Try selecting different region or SKU."
# CLI: confirm which Python runtimes the region currently offers
az functionapp list-runtimes --os linux --query "linux[?runtime=='python'].version" -o tsv
# CLI: confirm the Linux consumption SKU is actually available where you are deploying
az appservice list-locations --sku Y1 --linux-workers-enabled -o tsv
Implementation
Step 0 — the scope and the storage account. Both are unremarkable resources, but two arguments are load-bearing. allow_blob_public_access=False keeps the deployment container private, which is what makes the SAS in step 3 necessary rather than optional. minimum_tls_version set to TLS1_2 is required by the Functions host on recent runtime versions; leaving it at the account default of TLS1_0 produces intermittent handshake failures that look like transient network errors in Application Insights.
# base.py — resource group and the runtime's storage account
# CLI: pulumi up --stack prod
from typing import Final
import pulumi
import pulumi_azure_native.resources as resources
import pulumi_azure_native.storage as storage
LOCATION: Final[str] = "westeurope"
rg = resources.ResourceGroup("platform", location=LOCATION)
account = storage.StorageAccount(
"fnstate",
resource_group_name=rg.name,
location=rg.location,
kind=storage.Kind.STORAGE_V2,
sku=storage.SkuArgs(name=storage.SkuName.STANDARD_LRS),
allow_blob_public_access=False,
minimum_tls_version=storage.MinimumTlsVersion.TLS1_2,
)
# Provider note: the physical name is auto-suffixed by Pulumi. Storage account
# names are globally unique, lower-case, 3-24 chars — a logical name with a
# hyphen or capital letter fails with Code="AccountNameInvalid".
Step 1 — create the consumption plan. For Linux consumption the SKU is Y1 and reserved must be true; omitting reserved silently produces a Windows plan that will not run a Python app.
# plan.py — a Linux consumption plan
# CLI: pulumi up
import pulumi_azure_native.web as web
plan = web.AppServicePlan(
"functions-plan",
resource_group_name=rg.name,
kind="functionapp",
reserved=True, # Provider note: required for Linux
sku=web.SkuDescriptionArgs(name="Y1", tier="Dynamic"),
)
# State implication: moving between plan tiers later replaces the plan,
# which briefly interrupts every app hosted on it.
sku takes both a name and a tier, and they are not interchangeable synonyms — name is the SKU code Azure bills against, tier is the family it belongs to. Pass name="Y1" with tier="Standard" and the ARM API rejects the pair rather than guessing. The combinations that matter in practice are narrow:
Changing the SKU on an existing plan is an in-place update between sizes of the same tier (EP1 to EP2) but a replacement across tiers (Y1 to EP1). Pulumi will show the difference in pulumi preview as ~ update versus +- replace; read it before approving, because the replacement path deletes the plan and every app bound to it must be re-pointed.
Step 2 — create the function app with its required settings. The storage connection string and worker runtime are not optional.
# functionapp.py — the app, wired to its plan and storage
import pulumi
import pulumi_azure_native.storage as storage
import pulumi_azure_native.web as web
keys = storage.list_storage_account_keys_output(
resource_group_name=rg.name, account_name=account.name)
conn = pulumi.Output.all(account.name, keys.keys[0].value).apply(
lambda a: f"DefaultEndpointsProtocol=https;AccountName={a[0]};AccountKey={a[1]}")
app = web.WebApp(
"ingest",
resource_group_name=rg.name,
server_farm_id=plan.id,
kind="functionapp,linux",
site_config=web.SiteConfigArgs(
linux_fx_version="Python|3.11", # must match the deployed code's version
app_settings=[
web.NameValuePairArgs(name="AzureWebJobsStorage", value=conn),
web.NameValuePairArgs(name="FUNCTIONS_WORKER_RUNTIME", value="python"),
web.NameValuePairArgs(name="FUNCTIONS_EXTENSION_VERSION", value="~4"),
],
),
)
pulumi.export("functionUrl", app.default_host_name.apply(lambda h: f"https://{h}"))
# State implication: app_settings is replace-in-full — a partial update drops
# any setting not listed here, including ones added out of band.
list_storage_account_keys_output is the _output variant of the function, and that suffix matters. The plain list_storage_account_keys is a synchronous call that needs concrete strings, which you do not have at graph-construction time because account.name is an unresolved Output. The _output form accepts Output arguments and returns an Output, so it participates in the dependency graph and re-evaluates whenever the account is replaced. Reaching for the synchronous form is the most common way to end up with a pulumi up that fails on a clean stack but succeeds on a second run.
Step 3 — ship the code with run-from-package. Publishing with func azure functionapp publish works, but it is a second tool with its own credential chain and it leaves the deployed artifact untracked by Pulumi. Uploading the zip as a resource and pointing WEBSITE_RUN_FROM_PACKAGE at a signed URL keeps the whole thing in one graph, and pulumi up becomes the only deploy verb.
# package.py — upload the handler and hand the app a read-only signed URL
# CLI: pulumi up
import datetime
import pulumi
import pulumi_azure_native.storage as storage
container = storage.BlobContainer(
"deployments",
account_name=account.name,
resource_group_name=rg.name,
public_access=storage.PublicAccess.NONE,
)
archive = storage.Blob(
"handler-zip",
account_name=account.name,
resource_group_name=rg.name,
container_name=container.name,
type=storage.BlobType.BLOCK,
source=pulumi.FileArchive("./functions"),
)
# State implication: Pulumi hashes the directory. Change one handler file and
# the blob is replaced, which is what triggers the app to pick up new code.
sas = storage.list_storage_account_service_sas_output(
account_name=account.name,
resource_group_name=rg.name,
protocols=storage.HttpProtocol.HTTPS,
resource=storage.SignedResource.C,
permissions=storage.Permissions.R,
shared_access_start_time="2024-01-01",
shared_access_expiry_time="2030-01-01",
canonicalized_resource=pulumi.Output.concat("/blob/", account.name, "/", container.name),
)
package_url = pulumi.Output.concat(
"https://", account.name, ".blob.core.windows.net/", container.name,
"/", archive.name, "?", sas.service_sas_token,
)
# Provider note: the SAS token is a secret. Feed package_url straight into the
# app setting rather than exporting it as a plain stack output.
Add web.NameValuePairArgs(name="WEBSITE_RUN_FROM_PACKAGE", value=package_url) to the app_settings list from step 2. The runtime downloads the archive on start and mounts it read-only at /home/site/wwwroot, which is why the file system is not writable in this mode — code that expects to write next to itself will fail with a read-only filesystem error.
Step 4 — telemetry and identity. An app without Application Insights gives you no invocation traces, and a cold-start failure is then indistinguishable from a network timeout. Wire it in the same program so the instrumentation key never has to be copied by hand.
# observability.py — Application Insights plus a system-assigned identity
# CLI: pulumi up
import pulumi_azure_native.insights as insights
import pulumi_azure_native.authorization as authorization
import pulumi_azure_native.operationalinsights as loganalytics
import pulumi_azure_native.web as web
subscription_id = authorization.get_client_config_output().subscription_id
workspace = loganalytics.Workspace(
"ingest-logs",
resource_group_name=rg.name,
sku=loganalytics.WorkspaceSkuArgs(name=loganalytics.WorkspaceSkuNameEnum.PER_GB2018),
retention_in_days=30,
)
telemetry = insights.Component(
"ingest-ai",
resource_group_name=rg.name,
application_type=insights.ApplicationType.WEB,
kind="web",
retention_in_days=90,
ingestion_mode=insights.IngestionMode.LOG_ANALYTICS,
workspace_resource_id=workspace.id,
)
# Provider note: since the classic Application Insights retirement, a Component
# with LOG_ANALYTICS ingestion requires workspace_resource_id. Omit it and the
# create fails with Code="WorkspaceRequired".
# Grant the app's own identity data access instead of embedding a key.
BLOB_DATA_OWNER = "b7e6dc6d-f1e8-4753-8033-0f276bb0955b"
assignment = authorization.RoleAssignment(
"app-blob-owner",
scope=account.id,
principal_id=app.identity.apply(lambda i: i.principal_id),
principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
role_definition_id=pulumi.Output.concat(
"/subscriptions/", subscription_id,
"/providers/Microsoft.Authorization/roleDefinitions/", BLOB_DATA_OWNER,
),
)
# State implication: role assignments are eventually consistent. A function app
# created in the same `pulumi up` can start before the assignment propagates and
# log one round of authorisation failures before settling.
To use that identity for the runtime's own bookkeeping, drop AzureWebJobsStorage and set AzureWebJobsStorage__accountName to the account name instead. The double underscore is the Functions host's configuration-section separator, not a typo. Set identity=web.ManagedServiceIdentityArgs(type=web.ManagedServiceIdentityType.SYSTEM_ASSIGNED) on the web.WebApp for app.identity to be populated at all — without it the apply above resolves to None and the role assignment fails with an empty principal.
Verification
Confirm the app is running the intended runtime and that its settings survived the deploy.
# CLI: the app answers, on the expected runtime, with the required settings present
curl -s -o /dev/null -w '%{http_code}\n' "$(pulumi stack output functionUrl)"
az functionapp config show --name ingest --resource-group platform-prod \
--query linuxFxVersion -o tsv
az functionapp config appsettings list --name ingest --resource-group platform-prod \
--query "[?name=='FUNCTIONS_WORKER_RUNTIME'].value" -o tsv
A 200 (or 401 for an authenticated function) plus Python|3.11 and python means the wiring is correct.
Those three checks confirm the infrastructure. They do not confirm the runtime actually loaded your handlers — an app with a broken requirements.txt still answers on the root URL. Ask the host directly which functions it discovered:
# CLI: list the functions the running host has registered, not the ones you meant to deploy
az functionapp function list --name ingest --resource-group platform-prod \
--query "[].{name:name, trigger:config.bindings[0].type}" -o table
# CLI: tail the host's own log stream while sending a request
az webapp log tail --name ingest --resource-group platform-prod
An empty function list with a healthy app is the signature of a package that unpacked but whose dependencies failed to resolve. The host log shows the real cause — usually a wheel built for the wrong Python version, because linux_fx_version and the environment that produced the zip disagreed.
Gotchas & Edge Cases
app_settings replaces rather than merges. Anything added through the portal disappears on the next pulumi up. Keep every setting in code, or the deploy quietly removes it.
Runtime version drift. If linux_fx_version says Python|3.11 and the published package targets 3.9, the app deploys successfully and then fails at invocation with an import error. Pin both in one place.
Consumption plans cold-start. Y1 scales to zero, so the first request after idle can take several seconds. Where that matters, move to a Premium plan — a change that replaces the plan resource.
"Azure Functions runtime is unreachable." This portal banner almost always means the host cannot reach the storage account named in AzureWebJobsStorage. The three causes worth checking in order: the account was deleted or replaced and the connection string in state is stale; the account has a network rule set that excludes the app's outbound addresses; the key was rotated out of band. Nothing in pulumi up reports it, because from ARM's perspective the app resource is perfectly healthy.
Rotating the storage key breaks the app until the next deploy. The connection string is materialised at deploy time and frozen into app_settings. Rotate the key in the portal and the app keeps presenting the old one. pulumi up re-reads the key through list_storage_account_keys_output and fixes it — but only if something else in the graph changed, since Pulumi will not re-run the function for an otherwise unchanged stack. Force it with pulumi up --refresh, or move to identity-based access and stop carrying the key at all.
Deleting a plan that still hosts apps fails. Removing the AppServicePlan from the program while the WebApp still references it produces an ARM conflict rather than a cascading delete, because Pulumi deletes in reverse dependency order but Azure refuses a plan delete while sites remain assigned. Delete the app first, or let a single pulumi destroy handle both.
Secrets in app_settings are visible in plan output. web.NameValuePairArgs values are ordinary strings to Pulumi. Wrap anything sensitive with pulumi.Output.secret() before passing it in, or the connection string and the package SAS appear in pulumi preview diffs and in the state file in plain text — see using Pulumi config and typed settings in Python for the typed way to handle this.
Operational Notes
Scaling on a consumption plan is driven by the scale controller, a component outside your app that watches trigger sources and decides how many instances to run. For an HTTP trigger it watches request rate; for a queue trigger it polls the queue length through the same storage account. That second case is the reason a queue-triggered app on identity-based storage needs the Storage Queue Data Contributor role as well as blob access — the scale controller authenticates as the app's identity, and without queue permissions it sees an empty queue and never scales past zero.
The functionAppScaleLimit site-config property caps that behaviour. Set it when the function talks to a database with a bounded connection pool; the default on Y1 is 200 instances, and 200 concurrent workers opening five connections each will exhaust a small PostgreSQL server long before Azure notices anything is wrong. It is web.SiteConfigArgs(function_app_scale_limit=20) and it is an in-place update, so tightening it is cheap.
Deployment slots are available on Premium and Dedicated plans but not on consumption. Where you have them, web.WebAppSlot gives you a staging target that shares the plan, and a swap is an atomic hostname exchange rather than a redeploy. Slot-specific settings need web.WebAppSlotConfigurationNames to mark which app settings are sticky; without it the connection strings swap along with the code, which is rarely what you want.
Finally, treat the function's own access keys as infrastructure. web.list_web_app_host_keys_output(name=app.name, resource_group_name=rg.name) returns the master key and the named function keys, so a downstream API Management policy or a test harness can be wired from the same program instead of copying a key out of the portal. The values land in state, so the stack must be encrypted — which is the default for the Pulumi Service backend and something you configure explicitly for a self-managed one.
FAQ
Does Pulumi deploy the function code itself?
Not with these resources — they create the hosting infrastructure. Publish the code separately (func azure functionapp publish) or point the app at a package URL via the WEBSITE_RUN_FROM_PACKAGE setting.
Why does a Linux plan need reserved=True?
reserved is the ARM API's flag for a Linux plan. Without it Azure creates a Windows plan, and a Python function app cannot run on it.
Can several function apps share one plan?
Yes on dedicated and Premium plans, where sharing amortises cost. On consumption each app scales independently, so sharing brings no benefit.
Is the storage account really mandatory?
Yes. The Functions runtime keeps trigger state, leases, and logs there. An app without a reachable AzureWebJobsStorage will not start.
Why does pulumi up succeed but the function URL returns 404?
A 404 from the app's host name means the host is running and no route matched — the code deployed but no function was discovered. Check az functionapp function list; an empty result points at a packaging problem rather than an infrastructure one. A 503 instead would mean the host itself failed to start, which sends you back to the storage connection.
Can I keep the storage account key out of the state file entirely?
Yes, by switching AzureWebJobsStorage to AzureWebJobsStorage__accountName with a system-assigned identity and the Storage Blob Data Owner plus Storage Queue Data Contributor roles. The account key never enters the program, so nothing sensitive is recorded. The trade-off is a chicken-and-egg on first deploy: the app must exist before its identity can be granted a role, so the first start may log authorisation failures until the assignment propagates.
Related
- Deploying AWS Lambda Functions with Pulumi Python — the equivalent serverless deployment on AWS.
- Provision Azure Storage Accounts with Pulumi Python — the storage account this function app depends on.
- Azure Provider Configuration — the parent topic on Azure provider setup.