Provision an Azure Function plan with a subnet using Bicep

4 minute read

In a recent project, I need an Azure Function hosted inside a subnet. To have vnet support, I need to use a premium plan, and I wanted to use Linux hosting as well. All this must be part of my CICD rollout using Bicep.

First of all, I need to roll out a hosting plan placed inside a Bicep module. A module is nothing more than a separate file containing the resource definition, some parameters, and output.

@description('Name of hosting plan')
param hostingPlanName string

param location string = resourceGroup().location

@allowed([
  'Y1'
  'EP1'
  'EP2'
  'EP3'  
])
@description('The name of the SKU to use when creating the Azure Functions plan. Common SKUs include Y1 (consumption) and EP1, EP2, and EP3 (premium).')
param functionPlanSkuName string = 'EP1'

var functionPlanKind = functionPlanSkuName ==  'Y1' ?  'functionapp' : 'elastic'

resource hostingPlan 'Microsoft.Web/[email protected]' = {
  name: hostingPlanName
  location: location
  properties: {    
    reserved: true    
  }
  kind: functionPlanKind
  sku: {
    name: functionPlanSkuName    
  }
}

output id string = hostingPlan.id

The module will create a place to host the application, so let’s create another module that will roll out a function app.

@description('Id of hosting plan')
param hostingPlanId string

param functionAppName string

param location string = resourceGroup().location

@description('The application runtime that the function app uses')
param functionRuntime string = 'dotnet'

param functionSubnetId string

param environmentName string

param instrumentationKey string

param storageAccountName string

var connectionString = concat('DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', storageAccountName), '2019-06-01').keys[0].value)

resource functionApp 'Microsoft.Web/[email protected]' = {
  name: functionAppName
  kind: 'functionapp'
  location: location  
  properties: {
    enabled: true    
    serverFarmId: hostingPlanId
    reserved: true    
    siteConfig: {
      detailedErrorLoggingEnabled: false
      vnetRouteAllEnabled: true

      ftpsState: 'Disabled'
      http20Enabled: true

      minTlsVersion: '1.2'
      scmMinTlsVersion: '1.2'
      minimumElasticInstanceCount: 1
    } 
    hostNamesDisabled: true
    httpsOnly: true    
  }
  identity: {
    type: 'SystemAssigned'    
  }  
}

// Add the function to the subnet
resource functionToSubnet 'Microsoft.Web/sites/[email protected]' = {
  name: '${functionAppName}/VirtualNetwork'
  properties: {
    subnetResourceid: functionSubnetId
    swiftSupported: true
  }
  dependsOn:[
    functionApp
  ]
}

resource functionAppSettings 'Microsoft.Web/sites/[email protected]' = {
    name: '${functionAppName}/appsettings'
    properties: {
        'FUNCTIONS_EXTENSION_VERSION':  '~3'
        'FUNCTIONS_WORKER_RUNTIME': functionRuntime
        'ASPNETCORE_ENVIRONMENT': environmentName
        'APPINSIGHTS_INSTRUMENTATIONKEY': instrumentationKey
        'WEBSITE_VNET_ROUTE_ALL': '1'
        'WEBSITE_CONTENTOVERVNET': '1'
        'AzureWebJobsDisableHomepage': 'true'
        'AzureWebJobsStorage': connectionString
    }
    dependsOn:[
        functionApp
        functionToSubnet
    ]
}

output principalId string = functionApp.identity.principalId

It will create a function app inside the hosting plan, enable it for a functionapp, set a couple of options, and create a system-assigned identity.

The next part is adding the function to the subnet specified in the functionSubnetId parameter. You will not find this in the Microsoft documentation, and it is tempting to use Microsoft.Web sites/virtualNetworkConnections instead. However, this creates a different kind of connection, not linking the function to the subnet.

The last part sets a couple of application settings. You can also inline this when creating the function itself. I specify with WEBSITE_VNET_ROUTE_ALL and WEBSITE_CONTENTOVERVNET that all network traffic needs to go over the virtual network.

Normally, I can also specify a WEBSITE_CONTENTAZUREFILECONNECTIONSTRING and WEBSITE_CONTENTSHARE, but that will only work when they reference a storage account that is not behind a virtual network. According to GitHub I can omit these settings and get some internal storage.

Now we combine this by calling the above modules:

// Create hosting plan
module hostingplan './modules/hostingplan.bicep' = {
  name: 'hostingplan'
  scope: resourceGroup
  params: {
    functionPlanSkuName: 'EP1'
    hostingPlanName: '${environment}-functionplan'   
  }
}

// Create functions
module monitoringFunctions './modules/function.bicep' = {
  name: 'monitoring'
  scope: resourceGroup
  params: {    
    hostingPlanId: hostingplan.outputs.id
    functionAppName: '${environment}-monitoring'
    functionSubnetId: '${virtualNetworkId}/subnets/functions-${environment}' 
    environmentName: environment
    instrumentationKey: applicationInsights.outputs.instrumentationKey
    storageAccountName: 'storageaccountname'
  }
  dependsOn:[    
    hostingplan
  ]
}

The function will need a storage account for its internals like durable functions, so you need to either create or reference it. Same with the instrumentation key of Application Insights. I have a shared network between different environments, so that is why I postfix it with the environment name. The id is passed in via a parameter.

Now we only add a single function to the plan, but I can reuse it to add more.

To deploy your function app, you can use something like the below GitHub actions pipeline code:

name: Deploy code 

env:
  AZURE_FUNCTIONAPP_PACKAGE_PATH: 'src/code'    # set this to the path to your web app project
  DOTNET_VERSION: '3.1.x'              # set this to the dotnet version to use
  OUTPUT_PATH: $/.output

# Controls when the action will run. 
on:
  push:
    branches: 
    - main
  pull_request:
    branches: 
    - main
    - 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - name: 'Checkout GitHub Action'
      uses: actions/[email protected]

    - name: Setup DotNet $ Environment
      uses: actions/[email protected]
      with:
        dotnet-version: $

    - name: 'Resolve Project Dependencies Using Dotnet'
      shell: bash
      run: |
        pushd './$'
        dotnet publish --configuration Release --output $
        popd
   
    - name: Package functions
      uses: actions/[email protected]
      with:
          name: functions
          path: $

  deploy_test:
    if: github.event_name == 'pull_request'
    name: Deploy to Test 
    runs-on: ubuntu-latest
    needs: [build]
    env:
        FUNC_APP_NAME_MONITORING: tst-monitoring
    steps:
      - name: Download functions
        uses: actions/[email protected]
        with:
            name: functions
            path: $

      - name: "Login via Azure CLI"
        uses: azure/[email protected]
        with:
            creds: $

      - name: "Run Azure Functions Action for monitoring"
        uses: Azure/[email protected]
        with:
            slot-name: 'production'
            app-name: $
            package: $

      - name: "Set Function app config" 
        uses: azure/[email protected]
        with:
          app-name: $
          mask-inputs: true          
          app-settings-json: '[
          { 
            "name": "TaskHubName",
            "value": "monitoring20210525", 
            "slotSetting": false
          },
          { 
            "name": "Blob__RelationsContainer",
            "value": "salesforce",
            "slotSetting": false
          }
              ]'
        id: settings
   

This yaml contains a build and a deploy stage and will deploy a published function from the build stage and apply settings.