Part 1: Deploying Python 3.12 Azure Functions on Consumption Plan

Ah, C# – such a versatile and powerful language with excellent support across the board. Yet ask any AI or data science specialist, and they’ll tell you it’s simply not the right tool for their work. Python remains their language of choice. Recently, a colleague reached out to me with a challenge. He’d been assigned to integrate AI features into an existing .NET monolithic application. Without access to libraries like Pandas and Polars, he was hitting significant roadblocks and asked if I knew of any workarounds. My suggestion was to extract the AI functionality into a separate Azure Functions app, which would provide the added benefit of dynamic resource scaling. After considerable amount of crying, complaining and arguing, I successfully developed a solution for him.

In this two-part series, I’ll walk you through exactly what I built and the approach I took to make it work.

Part 1 (this post): Setting up the complete infrastructure using Terraform and deploying Python Azure Functions through Azure DevOps Pipelines. I’ll provision resource groups, storage accounts, function apps, and all the supporting infrastructure as code.

Part 2: Implementing Microsoft Entra ID bearer token authentication to secure your Python Azure Functions, ensuring only authorized users with valid tokens can access the functions.

By the end, you’ll have a production-ready, secure, and scalable Python Azure Functions deployment that integrates seamlessly with .NET applications. All infrastructure will be version-controlled and reproducible through Terraform, and the deployment pipeline will be fully automated.

Let’s dive into the infrastructure provisioning.

Infrastructure Provisioning Using Terraform

main-functions.tf

# Resource Group for functions
resource "azurerm_resource_group" "rg_functions" {
    name     = "rg-functions"
    location = "West Europe"
    tags     = { description = "Azure functions resource group" }
}


resource "azuread_group" "role-rg-functions-contributor" {
    display_name            = format("ra-%s-contributor", azurerm_resource_group.rg_functions.name)
    description             = "Role group which grants contributor access to the Azure Functions resource group"
    prevent_duplicate_names = true
    security_enabled        = true
    members                 = [
        data.azurerm_client_config.current.object_id, # Your current user Azure ObjectId
        azuread_service_principal.azuredevops.object_id # Azure DevOps pipeline service principal
    ]
    lifecycle {
        # apart from setting initially; do not flag changes in members and owners as state change
        ignore_changes = [members, owners]
    }
}


resource "azuread_group" "perm-rg-functions-contributor" {
    display_name            = format("pm-%s-contributor", azurerm_resource_group.rg_functions.name)
    description             = "Permission group granting contributor rights to the group"
    members                 = [azuread_group.role-rg-functions-contributor.id]
    prevent_duplicate_names = true
    security_enabled        = true
    lifecycle {
        # apart from setting initially; do not flag changes in owners as state change
        ignore_changes = [owners]
    }
}


resource "azurerm_role_assignment" "azuredevops-rg-functions-contributor" {
    scope                = azurerm_resource_group.rg_functions.id
    role_definition_name = "Contributor"
    principal_id         = azuread_service_principal.azuredevops.id
}


resource "azurerm_service_plan" "functions_appplan" {
    name                                  = "appplan-functions"
    resource_group_name                   = azurerm_resource_group.rg_functions.name
    location                              = var.default_location
    os_type                               = "Linux"
    sku_name                              = "Y1" # THIS IS CRITICAL TO ENSURE DYNAMIC UPSCALING
}


resource "azurerm_storage_account" "sa_functions" { 
# Place to store the functions app(s) codebase(s)
    name                     = "functionssa"
    resource_group_name      = azurerm_resource_group.rg_functions.name
    location                 = "West Europe"
    account_tier             = "Standard"
    account_replication_type = "LRS"
    min_tls_version          = "TLS1_2"
    tags = merge(var.service_tags, {
        description = "Functions Runtime Storage"
    })
}
NOTE

The Y1 SKU corresponds to Azure’s Consumption Plan, but Microsoft has announced that after October 1, 2028, Linux function apps will no longer be supported on the traditional Consumption plan.

Microsoft suggests migrating to the Flex Consumption plan, which provides benefits like faster scaling, reduced cold start times, private networking capabilities, and enhanced performance and cost management.

For now, I’m sticking with the standard Consumption plan since Terraform support is still catching up and the migration isn’t as straightforward as a simple configuration change. However, if you want to get ahead of this transition, I’d recommend exploring the documentation

main-pythonfunctionsapp.tf

resource "azurerm_linux_function_app" "pythonfunctionsapp" {
    name                = "python-functions"
    resource_group_name = azurerm_resource_group.rg_functions.name
    location            = "West Europe"

    storage_account_name       = azurerm_storage_account.sa_functions.name
    storage_account_access_key = azurerm_storage_account.sa_functions.primary_access_key
    service_plan_id            = azurerm_service_plan.functions_appplan.id
    https_only                 = true

    tags = { description = "Python Functions App" }

    identity {
        type = "SystemAssigned"
    }

    site_config {
        application_stack {
            python_version = "3.12"
        }
        application_insights_connection_string = azurerm_application_insights.appi.connection_string

        health_check_path   = "/api/health"
        health_check_eviction_time_in_min = 10

        http2_enabled = true
    }

    app_settings = {
        # Azure AD Configuration for the FUNCTIONS API
        "AzureAd__Domain"                       = data.azuread_domains.aad_domains.domains[0].domain_name
        "AzureAd__TenantId"                     = var.tenant_id
        "AzureAd__ClientId"                     = azuread_application.pythonfunctionsaadapp.client_id
        "AzureAd__Instance"                     = "https://login.microsoftonline.com/"
        "AzureAd__Authority"                    = "https://login.microsoftonline.com/${var.tenant_id}"

        # Key Vault Configuration
        "AzureKeyVaultEndpoint"                 = azurerm_key_vault.kv.vault_uri

        # Application Insights
        "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.appi.connection_string

        "SCM_DO_BUILD_DURING_DEPLOYMENT" = "true"
        "ENABLE_ORYX_BUILD" = "true"

        WEBSITE_RUN_FROM_PACKAGE = ""
        WEBSITE_MOUNT_ENABLED    = "true"
    }

    lifecycle {
        ignore_changes = [
            app_settings["WEBSITE_RUN_FROM_PACKAGE"],
            app_settings["WEBSITE_MOUNT_ENABLED"]
        ]
    }
}

resource "azurerm_role_assignment" "python-functions-app-kv-reader-ra" {
    scope                = azurerm_key_vault.kv.id
    role_definition_name = "Key Vault Secrets User"
    principal_id         = azurerm_linux_function_app.pythonfunctionsapp.identity[0].principal_id
}


resource "azuread_application" "pythonfunctionsaadapp" {
    display_name     = "Python Functions API %s"
    identifier_uris  = ["api://python-functions"]
    sign_in_audience = "AzureADMyOrg"

    api {
        requested_access_token_version = 2

        # API scope that the main app will request
        oauth2_permission_scope {
            admin_consent_description = "Allow the application to access python functions on behalf of the signed-in user"
            admin_consent_display_name = "Access python functions"
            enabled                    = true
            id                         = random_uuid.random-uuid-app-role-pythonfunctionsapp.result # Unique ID
            type                       = "User"
            user_consent_description   = "Allow the application to access python functions on your behalf"
            user_consent_display_name  = "Access python functions"
            value                      = "access_as_user" #"Functions.Access"
        }
    }



    # App role for users who can access the functions
    app_role {
        id                   = random_uuid.random-uuid-app-role-dotnet-app-users.result
        allowed_member_types = ["User"]
        description          = "Users who can access python functions"
        display_name         = "PythonFunctionsUser"
        enabled              = true
        value                = "PythonFunctionsUser"
    }

}

resource "azuread_service_principal" "python-functions-app-sp" {
    client_id = azuread_application.pythonfunctionsaadapp.client_id
}


resource "azuread_application_password" "pythonfunctionsapppwd" {
    display_name      = "apppwd"
    application_id    = azuread_application.pythonfunctionsaadapp.id
    end_date_relative = "17520h"
}


resource "azurerm_key_vault_secret" "pythonfunctionsapppwd-secret" {
    key_vault_id = azurerm_key_vault.kv.id
    name         = "Python--ClientSecret"
    value        = azuread_application_password.pythonfunctionsapppwd.value
}

The decision to use Python 3.12 rather than 3.13 was based on Azure Functions Flex consumption plan compatibility, where 3.12 represents the highest supported Python version currently available. https://learn.microsoft.com/en-us/azure/azure-functions/flex-consumption-plan

main-uuids.tf

resource "random_uuid" "random-uuid-app-role-pythonfunctionsapp" {}
resource "random_uuid" "random-uuid-app-role-dotnet-app-users" {}

main-add.tf

resource "azuread_application" "aadapp" {
  display_name     = "Dotnet AAD Application"
  identifier_uris  = ["api://dotnetapp"]
  sign_in_audience = "AzureADMyOrg"
  api {
    requested_access_token_version = 2
  }

  # Other configurations...

  # Required to access the Python Functions app
    required_resource_access {
        resource_app_id = azuread_application.pythonfunctionsaadapp.client_id
        resource_access {
            id   = random_uuid.random-uuid-app-role-pythonfunctionsapp.result
            type = "Scope"
        }
    }
}

Deployment with Azure DevOps Pipelines

python-functions-app.yml

name: PythonFunctions$(Date:yyyyMMdd)$(Rev:r)
trigger:
    batch: true
    branches:
        include:
            - main
    paths:
        include:
            - src

variables:
    isMainBranch: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
    artifactName: "PythonFunctionsApp"
    functionAppPath: "src/Functions.Python"
    pythonVersion : "3.12"
pool:
    vmImage: ubuntu-latest

stages:
    - stage: Build
      displayName: Build and Package
      dependsOn: []
      jobs:
          - template: jobs-build-functions.yml
            parameters:
                artifactName: $(artifactName)
                functionAppPath: $(functionAppPath)
                pythonVersion: $(pythonVersion)

    - stage: DeployProd
      displayName: Deploy to PROD
      dependsOn: Build
      condition: and(succeeded(), eq(variables.isMainBranch, true))
      jobs:
          - template: jobs-deploy-functions.yml
            parameters:
                artifactName: $(artifactName)
                environment: Production
                environmentCode: p1
                serviceConnection: azuredevops-service-connection
                functionsAppName: pythonfunctionsapp

jobs-build-functions.yml

parameters:
    - name: functionAppPath
      type: string
    - name: buildConfiguration
      type: string
      default: "Release"
    - name: artifactName
      type: string
    - name: pythonVersion
      type: string
      default: "3.12"

jobs:
    - job: Build
      displayName: Build Python Azure Functions
      pool:
          vmImage: ubuntu-latest

      steps:
          - task: UsePythonVersion@0
            displayName: "Set Python version to ${{ parameters.pythonVersion }}"
            inputs:
                versionSpec: '${{ parameters.pythonVersion }}'
                architecture: 'x64'


          # Install dependencies following Microsoft's pattern
          - bash: |
                cd '${{ parameters.functionAppPath }}'
                if [ -f extensions.csproj ]
                then
                    dotnet build extensions.csproj --output ./bin
                fi
                pip install --target="./.python_packages/lib/site-packages" -r ./requirements.txt
            displayName: 'Install dependencies'


          # Archive files to the staging directory
          - task: ArchiveFiles@2
            displayName: 'Archive Azure Functions'
            inputs:
                rootFolderOrFile: "${{ parameters.functionAppPath }}"
                includeRootFolder: false
                archiveFile: "$(Build.ArtifactStagingDirectory)/publish/function-app.zip"


          # Publish using the modern syntax
          - publish: $(Build.ArtifactStagingDirectory)/publish
            artifact: ${{ parameters.artifactName }}
            displayName: 'Publish Function App artifact'

jobs-deploy-functions.yml

parameters:
    - name: "artifactName"
      type: string
    - name: "environment"
      type: string
    - name: "environmentCode"
      type: string
    - name: "serviceConnection"
      type: string
    - name: "functionsAppName"
      type: string

jobs:
    - deployment: Deploy
      displayName: "Deploy ${{parameters.functionsAppName}}"
      environment: ${{parameters.environment}}
      strategy:
          runOnce:
              deploy:
                  steps:
                      - checkout: self
                      - download: current
                        displayName: Artifacts - Download function app artifact
                        artifact: ${{ parameters.artifactName }}
                      - task: AzureFunctionApp@2
                        displayName: Deploy Azure Function App
                        inputs:
                            azureSubscription: ${{parameters.serviceConnection}}
                            appType: functionAppLinux
                            appName: ${{parameters.functionsAppName}}
                            package: $(Pipeline.Workspace)/${{ parameters.artifactName }}/**/*.zip

And voilà! Every component of your Azure Functions environment is now version-controlled, reproducible, and auditable.
From resource groups to storage accounts, from app service plans to Entra ID applications, everything is defined in Terraform.

Azure DevOps pipelines automatically build, package, and deploy your Python functions whenever you push to main.
The pipeline handles Python dependencies, creates deployment packages, and pushes them to Azure
all without manual intervention.

A special thanks to Marc Rufer for taking the time to review my Terraform code and pipelines

1 Comment.

Leave a Reply

Your email address will not be published. Required fields are marked *