In lessons 3.13.1 through 3.13.3 the Function App ran locally. Now it goes to Azure: as Bicep in the repository, delivered with azd, with a managed identity for the host's storage and for Application Insights, without a connection string with a key. After that you measure what serverless promises and what it costs: the cold start and the price per execution. At the end comes the decision whether the Kladde's import stays a worker in Container Apps or runs as Functions, and then everything is torn down again.

Costs of this lesson: A Flex Consumption plan without always ready instances costs money only while functions run, and has a free allowance per month; on top of that come a storage account, a Log Analytics workspace and Application Insights by data volume. Check the current prices in the pricing calculator before you begin. An always ready instance costs money even while it waits; switch it on only for the measurement. The lesson ends with azd down --purge.

The Function App as Bicep

The file infra/functions.bicep creates everything the app needs. Learn describes the same shape in its example for Flex Consumption with managed identities; here it is tailored to the Kladde:

bicep
// functions.bicep: the Kladde's Function App in a Flex Consumption plan, without a single key
@description('Region of all resources.')
param location string = resourceGroup().location

@description('Short name of the environment, part of every resource name.')
@allowed(['dev', 'test', 'prod'])
param environmentName string = 'dev'

@description('Memory of one instance in MB.')
@allowed([512, 2048, 4096])
param instanceMemoryMB int = 2048

@description('Always ready instances of the HTTP group; every one is paid while it waits.')
@minValue(0)
param alwaysReadyHttp int = 0

var token = toLower(uniqueString(resourceGroup().id, environmentName))
var appName = 'func-kladde-${environmentName}-${token}'
var deploymentContainer = 'app-package'

// Role definitions from the Azure built-in roles.
var storageBlobDataOwner = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b'
var storageQueueDataContributor = '974c5e8b-45b9-4653-ba55-5f855dd0fb88'
var storageTableDataContributor = '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3'
var monitoringMetricsPublisher = '3913510d-42f4-4e42-8a64-420c390055eb'

resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
  name: 'id-kladde-func-${environmentName}'
  location: location
}

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: 'stkladdefn${token}'
  location: location
  kind: 'StorageV2'
  sku: { name: 'Standard_LRS' }
  properties: {
    allowSharedKeyAccess: false
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
  }
}

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
  parent: storage
  name: 'default'
}

resource packages 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
  parent: blobService
  name: deploymentContainer
}

resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: 'log-kladde-func-${environmentName}'
  location: location
  properties: {
    sku: { name: 'PerGB2018' }
    retentionInDays: 30
  }
}

resource insights 'Microsoft.Insights/components@2020-02-02' = {
  name: 'appi-kladde-func-${environmentName}'
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: workspace.id
    DisableLocalAuth: true
  }
}

resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
  name: 'plan-kladde-func-${environmentName}'
  location: location
  kind: 'functionapp'
  sku: {
    tier: 'FlexConsumption'
    name: 'FC1'
  }
  properties: {
    reserved: true
  }
}

resource app 'Microsoft.Web/sites@2024-04-01' = {
  name: appName
  location: location
  kind: 'functionapp,linux'
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: { '${identity.id}': {} }
  }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    siteConfig: { minTlsVersion: '1.2' }
    functionAppConfig: {
      deployment: {
        storage: {
          type: 'blobContainer'
          value: '${storage.properties.primaryEndpoints.blob}${deploymentContainer}'
          authentication: {
            type: 'UserAssignedIdentity'
            userAssignedIdentityResourceId: identity.id
          }
        }
      }
      scaleAndConcurrency: {
        maximumInstanceCount: 40
        instanceMemoryMB: instanceMemoryMB
        alwaysReady: alwaysReadyHttp > 0 ? [ { name: 'http', instanceCount: alwaysReadyHttp } ] : []
      }
      runtime: {
        name: 'dotnet-isolated'
        version: '10'
      }
    }
  }

  resource settings 'config' = {
    name: 'appsettings'
    properties: {
      AzureWebJobsStorage__accountName: storage.name
      AzureWebJobsStorage__credential: 'managedidentity'
      AzureWebJobsStorage__clientId: identity.properties.clientId
      APPLICATIONINSIGHTS_CONNECTION_STRING: insights.properties.ConnectionString
      APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'ClientId=${identity.properties.clientId};Authorization=AAD'
    }
  }
}

var storageRoles = [storageBlobDataOwner, storageQueueDataContributor, storageTableDataContributor]

resource storageRoleAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in storageRoles: {
  name: guid(storage.id, identity.id, role)
  scope: storage
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role)
    principalId: identity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}]

resource metricsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(insights.id, identity.id, monitoringMetricsPublisher)
  scope: insights
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', monitoringMetricsPublisher)
    principalId: identity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

output appName string = app.name
output defaultHostName string = app.properties.defaultHostName

What matters here:

  • The plan is FlexConsumption with the name FC1, always on Linux (reserved: true, kind: 'functionapp,linux').
  • functionAppConfig is the part that exists only for Flex Consumption: where the app's package is delivered (a container in the storage account, read with the identity), how the app scales (at most 40 instances, 2,048 MB each) and which runtime runs (dotnet-isolated, version 10).
  • No key. The storage account allows no access with an account key (allowSharedKeyAccess: false); the host signs in with the user-assigned identity (AzureWebJobsStorage__credential and __clientId), and Application Insights accepts telemetry only with Entra sign-in (DisableLocalAuth, APPLICATIONINSIGHTS_AUTHENTICATION_STRING). The connection string of Application Insights contains no key that grants access; it only says where to send.
  • The roles are assigned by the template itself, with names from guid() as in lesson 3.3.1: Blob Data Owner, Queue and Table Data Contributor on the storage (Durable Functions with the Azure Storage provider needs queues and tables), Monitoring Metrics Publisher on Application Insights. For Service Bus from lesson 3.13.2, an assignment of Azure Service Bus Data Receiver on the Kladde's namespace is added.

az bicep build --file infra/functions.bicep compiles the file without a warning. Before delivering, check with what-if, as in lesson 3.2.4, and remember that what-if doesn't prove the deployment will succeed.