Background
Sections
IntroductionFoundations1. Resource Hierarchy2. Resource Manager3. Identity and RBAC4. Regions and Availability5. Naming and TaggingVirtual Machines1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetVirtual Network1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetBlob Storage1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure SQL Database1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Kubernetes Service1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Container Registry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetMicrosoft Entra ID1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure RBAC1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Functions1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAPI Management1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure App Configuration1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Machine Learning1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Monitor1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure AI Foundry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and Cheatsheet

5. Deployment

17 min read

Getting Started proved the service exists. This page makes it repeatable, reviewable, and reversible: a parameterised module, remote state, a pipeline that authenticates without secrets, three environments, and a rollback you could perform at 2 a.m.

Functions has one deployment concern no other compute service in this article shares: there are two things to deploy. The infrastructure (app, plan, storage, identity, settings) and the code package. They have different lifecycles, different pipelines, and different rollback mechanisms, and conflating them is the most common structural mistake. Terraform owns the infrastructure; your build pipeline owns the package. Do not make Terraform deploy your code.

Tool order

The same everywhere in this article: Terraform primary, Ansible secondary, Bicep/ARM third.

Rank Tool What it's for here When it's the wrong choice
1. Primary Terraform (azurerm, plus azapi for preview features) The function app, plan, storage, identity, role assignments, diagnostic settings, and app settings State is yours to protect, and the storage access key lands in it unless you use identity-based connections. azurerm lags new Functions features — Flex Consumption support arrived well after the feature did
2. Secondary Ansible (azure.azcollection) Day-2 operations: rotating settings, restarting apps, swapping slots, orchestrating a multi-app release The Azure Functions modules in azure.azcollection are thinner than the Terraform provider and lag further behind. Do not use it as your provisioning source of truth here
3. Third Bicep / ARM Day-one support for brand-new Functions features, deployment stacks, and any shop already living in Azure DevOps Azure-only; no plan as rich as Terraform's, though what-if is close

Terraform — the production-shaped module

Three files. The difference from Getting Started is parameterisation, identity instead of keys, explicit settings, and outputs other modules can consume.

variables.tf

variable "name_prefix" {
  type        = string
  description = "Short project identifier, used in every resource name"
}

variable "environment" {
  type        = string
  description = "dev | staging | prod"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

variable "location" {
  type    = string
  default = "uksouth"
}

variable "plan_sku" {
  type        = string
  description = "Y1 = Consumption, EP1-EP3 = Premium, P1v3 = Dedicated"
  default     = "Y1"
}

variable "app_settings" {
  type        = map(string)
  description = "Extra application settings merged into the app"
  default     = {}
}

main.tf

terraform {
  required_version = ">= 1.6"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {
    # The features block controls destructive behaviour. Two matter here:
    key_vault {
      # If this module references a Key Vault, purge protection changes what destroy can do
      purge_soft_delete_on_destroy = false
    }
    resource_group {
      # Refuse to delete a resource group that still contains resources Terraform doesn't know about
      prevent_deletion_if_contains_resources = true
    }
  }
}

locals {
  suffix = "${var.name_prefix}-${var.environment}"
  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Workload    = var.name_prefix
  }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${local.suffix}"
  location = var.location
  tags     = local.tags
}

resource "azurerm_storage_account" "func" {
  name                            = "st${replace(local.suffix, "-", "")}"
  resource_group_name             = azurerm_resource_group.this.name
  location                        = azurerm_resource_group.this.location
  account_tier                    = "Standard"
  account_replication_type        = var.environment == "prod" ? "ZRS" : "LRS"
  min_tls_version                 = "TLS1_2"
  allow_nested_items_to_be_public = false
  shared_access_key_enabled       = false # force identity-based access; see the note below
  tags                            = local.tags
}

resource "azurerm_service_plan" "this" {
  name                = "plan-${local.suffix}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  os_type             = "Linux"
  sku_name            = var.plan_sku
  tags                = local.tags
}

resource "azurerm_log_analytics_workspace" "this" {
  name                = "log-${local.suffix}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  sku                 = "PerGB2018"
  retention_in_days   = var.environment == "prod" ? 90 : 30
  tags                = local.tags
}

resource "azurerm_application_insights" "this" {
  name                = "appi-${local.suffix}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  workspace_id        = azurerm_log_analytics_workspace.this.id
  application_type    = "web"
  tags                = local.tags
}

resource "azurerm_linux_function_app" "this" {
  name                = "func-${local.suffix}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  service_plan_id     = azurerm_service_plan.this.id

  # Identity-based storage connection: no access key anywhere, including in state
  storage_account_name          = azurerm_storage_account.func.name
  storage_uses_managed_identity = true

  https_only                    = true
  public_network_access_enabled = var.environment == "prod" ? false : true

  identity {
    type = "SystemAssigned"
  }

  site_config {
    ftps_state                             = "Disabled"
    minimum_tls_version                    = "1.2"
    application_insights_connection_string  = azurerm_application_insights.this.connection_string
    application_stack {
      node_version = "20"
    }
  }

  app_settings = merge(
    {
      "WEBSITE_RUN_FROM_PACKAGE" = "1"
      "ENVIRONMENT"              = var.environment
    },
    var.app_settings
  )

  lifecycle {
    # The code package is deployed by the build pipeline, not by Terraform.
    # Without this, every terraform apply fights the last code deployment.
    ignore_changes = [
      app_settings["WEBSITE_RUN_FROM_PACKAGE"],
      tags["hidden-link: /app-insights-instrumentation-key"],
    ]
  }

  tags = local.tags
}

# The app's identity must be allowed to use the storage account it depends on.
# Without these, an identity-based connection fails and NO trigger works.
resource "azurerm_role_assignment" "func_storage_blob" {
  scope                = azurerm_storage_account.func.id
  role_definition_name = "Storage Blob Data Owner"
  principal_id         = azurerm_linux_function_app.this.identity[0].principal_id
}

resource "azurerm_role_assignment" "func_storage_queue" {
  scope                = azurerm_storage_account.func.id
  role_definition_name = "Storage Queue Data Contributor"
  principal_id         = azurerm_linux_function_app.this.identity[0].principal_id
}

resource "azurerm_role_assignment" "func_storage_table" {
  scope                = azurerm_storage_account.func.id
  role_definition_name = "Storage Table Data Contributor"
  principal_id         = azurerm_linux_function_app.this.identity[0].principal_id
}

resource "azurerm_monitor_diagnostic_setting" "func" {
  name                       = "diag-func"
  target_resource_id         = azurerm_linux_function_app.this.id
  log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id

  enabled_log { category = "FunctionAppLogs" }
  metric { category = "AllMetrics" }
}

Three things in that file deserve calling out because they're where people get hurt:

  • storage_uses_managed_identity = true plus three role assignments. This is the modern, keyless way to satisfy AzureWebJobsStorage, and it removes the storage key from your state file entirely. But the role assignments are not optional and they are not obvious: the host needs blob, queue and table data access. Set shared_access_key_enabled = false without them and every trigger in the app silently stops. ⚠️ Verify the exact required roles and any additional identity-based connection settings against current Azure docs; this area has changed more than once.
  • ignore_changes on the package setting. Terraform must not fight your code pipeline. Declare the boundary explicitly rather than discovering it when an infrastructure apply rolls back yesterday's release.
  • Role assignments are separate ARM resources with their own propagation delay. A fresh apply can succeed while the app still 403s for a minute or two. That's not a bug in your config.

outputs.tf

output "function_app_name" {
  value = azurerm_linux_function_app.this.name
}

output "function_app_id" {
  value = azurerm_linux_function_app.this.id
}

output "default_hostname" {
  value = azurerm_linux_function_app.this.default_hostname
}

output "principal_id" {
  description = "Grant this identity access to downstream services"
  value       = azurerm_linux_function_app.this.identity[0].principal_id
}

The loop

terraform init -backend-config=backends/dev.tfbackend
terraform plan  -var-file=envs/dev.tfvars -out=tfplan
terraform apply tfplan

Remote state and locking

Local state is a footgun the moment a second person or a pipeline touches this. The azurerm backend stores state in a blob and uses native blob leases for locking — no separate lock table, which is a real simplification versus the DynamoDB table AWS users are used to.

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateuniquename"
    container_name       = "tfstate"
    key                  = "functions/dev.terraform.tfstate"
    use_azuread_auth     = true # authenticate to the backend with Entra ID, not a storage key
  }
}

Create that backend outside this module — a state store bootstrapped by the thing it stores state for is a chicken-and-egg problem you solve once, by hand, and then protect with a CanNotDelete resource lock.

Workspaces vs. directory-per-environment. Both are defensible:

  • Terraform workspaces — one directory, one backend, state keyed by workspace. Less duplication, but one misplaced terraform workspace select and you have applied dev's plan to prod.
  • Directory (or backend-file) per environment — separate state keys and, ideally, separate subscriptions. More files, far less chance of catastrophe.

Pick directory-per-environment for anything with a prod. The duplication is cheap; the failure mode of the alternative is not.

Ansible — day-2 operations

Ansible is not the provisioning source of truth for Functions; the azure.azcollection Functions modules lag the Terraform provider meaningfully. Where it earns its place is imperative day-2 work: rotating a setting across many apps, restarting in a controlled order, or driving a slot swap as one step of a wider release runbook.

- name: Day-2 operations on the function app
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    resource_group: "rg-{{ name_prefix }}-{{ environment }}"
    app_name: "func-{{ name_prefix }}-{{ environment }}"
  tasks:
    - name: Ensure the function app exists and settings are correct
      azure.azcollection.azure_rm_functionapp:
        resource_group: "{{ resource_group }}"
        name: "{{ app_name }}"
        storage_account: "st{{ name_prefix }}{{ environment }}"
        app_settings:
          ENVIRONMENT: "{{ environment }}"
          FEATURE_NEW_PRICING: "{{ feature_new_pricing | default('false') }}"
        state: present
      register: app_result

    - name: Report whether anything actually changed
      ansible.builtin.debug:
        msg: "changed={{ app_result.changed }}"

    - name: Swap staging into production (release step)
      ansible.builtin.command: >
        az functionapp deployment slot swap
        -g {{ resource_group }} -n {{ app_name }}
        --slot staging --target-slot production
      when: do_swap | default(false) | bool
      changed_when: true

Idempotency, demonstrated: run the playbook twice. The first run reports changed=true; the second reports changed=false because the module compares desired state to actual before acting. That property is the whole reason to use Ansible rather than a shell script — but note that the swap task above is a raw command and is not idempotent, which is why it's gated behind an explicit variable. Be honest about which tasks are declarative and which are actions.

⚠️ Verify azure.azcollection module names and supported parameters against the current collection documentation; this collection changes module surface between major versions.

Bicep / ARM equivalent

The Azure-native path. It gets new Functions features on day one — which matters here, because Functions ships preview capabilities faster than azurerm absorbs them.

Bicep module and what-if preview
param namePrefix string
param environment string
param location string = resourceGroup().location
param planSku string = 'Y1'

var suffix = '${namePrefix}-${environment}'

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: 'st${replace(suffix, '-', '')}'
  location: location
  sku: { name: environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS' }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    supportsHttpsTrafficOnly: true
  }
}

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: 'plan-${suffix}'
  location: location
  sku: { name: planSku }
  properties: { reserved: true } // reserved: true == Linux
}

resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
  name: 'func-${suffix}'
  location: location
  kind: 'functionapp,linux'
  identity: { type: 'SystemAssigned' }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    siteConfig: {
      linuxFxVersion: 'Node|20'
      ftpsState: 'Disabled'
      minTlsVersion: '1.2'
      appSettings: [
        { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
        { name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' }
        { name: 'AzureWebJobsStorage__accountName', value: storage.name } // identity-based
        { name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' }
      ]
    }
  }
}

output functionAppName string = functionApp.name
output principalId string = functionApp.identity.principalId
# Preview before you deploy — this is Bicep's answer to terraform plan
az deployment group what-if \
  -g rg-demo-dev \
  -f main.bicep \
  -p namePrefix=demo environment=dev

az deployment group create \
  -g rg-demo-dev \
  -f main.bicep \
  -p namePrefix=demo environment=dev

Deployment modes — the footgun. az deployment group create defaults to incremental mode: resources in the template are created or updated, and anything else in the resource group is left alone. Complete mode (--mode Complete) deletes every resource in the resource group that isn't in the template. For a function app that means the storage account holding your Durable orchestration state and your function keys can vanish because someone wanted a "clean" deployment. Complete mode is a legitimate tool for strictly template-owned resource groups. It is a catastrophe in a shared one. Know which you have before you type it.

Deploying the code (separately)

The infrastructure pipeline and the code pipeline are different things. For the code:

  • Build once, deploy many. Produce one zip artifact, promote the same bytes through dev → staging → prod. Rebuilding per environment reintroduces the "works in staging" class of bug.
  • Run from package. WEBSITE_RUN_FROM_PACKAGE=1 mounts the zip read-only. Faster cold starts, an atomic swap of app content, and no half-copied file states. On Flex Consumption the packaged model is the default deployment mechanism ⚠️ verify against current Azure docs.
  • Deploy to a slot, then swap on plans that support slots. See rollback, below.
az functionapp deployment source config-zip \
  -g rg-demo-prod -n func-demo-prod --src ./app.zip --slot staging

CI/CD with workload identity federation

No client secrets. No publish profiles. A publish profile is a long-lived credential with full deployment rights that people paste into repository settings and never rotate; it's the single worst credential practice still common in Azure Functions tutorials.

Instead, register a Microsoft Entra application, add a federated credential trusting your GitHub repository's OIDC issuer for a specific branch or environment, and assign it the least privilege it needs at the narrowest scope (Contributor on one resource group, not the subscription).

name: deploy-functions

on:
  pull_request:
  push:
    branches: [main]

permissions:
  id-token: write   # required to request the OIDC token
  contents: read

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config=backends/dev.tfbackend
      - run: terraform plan -var-file=envs/dev.tfvars

  apply-prod:
    if: github.ref == 'refs/heads/main'
    needs: plan
    runs-on: ubuntu-latest
    environment: production   # manual approval gate lives here
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config=backends/prod.tfbackend
      - run: terraform apply -auto-approve -var-file=envs/prod.tfvars

      # Code deployment: same artifact, into the staging slot, then swapped
      - run: |
          az functionapp deployment source config-zip \
            -g rg-demo-prod -n func-demo-prod --src ./app.zip --slot staging
          az functionapp deployment slot swap \
            -g rg-demo-prod -n func-demo-prod --slot staging --target-slot production

plan on pull request, apply on merge, and a manual approval gate for prod via a GitHub environment (or Azure DevOps approvals and checks). The federated credential should be scoped to that environment so a pull request from a fork can never obtain a prod token.

Infrastructure and code take separate routes from the same commit to the same environment

Environments

Dev, staging, and prod differ in more than a .tfvars file, and the honest recommendation is stronger in Azure than in AWS:

Boundary What it buys Recommendation
.tfvars file Different names, SKUs, retention Necessary, insufficient on its own
Separate resource groups Independent RBAC, independent deletion, independent locks Minimum bar
Separate subscriptions Independent quota, independent policy, a real blast-radius boundary, a clean cost split The right answer for anything with a prod
Management group + Azure Policy Enforcement rather than convention Use it to deny the mistakes

In Azure, the subscription is the natural quota and blast-radius boundary — and Functions scale limits are counted per subscription-per-region, so a runaway dev workload can genuinely consume capacity prod needs. That single fact makes "one subscription per environment" the default answer far more often than "one account per environment" is in AWS.

Where Azure Policy does the enforcing rather than a code review:

  • Deny function apps with httpsOnly = false.
  • Deny public network access on the prod management group.
  • Require a diagnostic setting routed to the central Log Analytics workspace.
  • Require the Environment and Owner tags, and deny SKUs above a threshold outside prod — the cheapest possible defence against an EP3 in a sandbox.

Rollback and blast radius

What "undo" means here, in increasing order of pain:

1. Slot swap — the good one. If you deployed to a staging slot and swapped, rolling back is swapping again. The swap is near-atomic and the previous version is warm, so recovery is seconds. This is the single strongest argument for using slots at all.

az functionapp deployment slot swap -g rg-demo-prod -n func-demo-prod \
  --slot staging --target-slot production

Two caveats: app settings are swapped too unless marked as deployment slot settings (sticky), which is how you keep a staging connection string pointing at staging. And slot support varies by plan — Consumption has limited slot support and Flex Consumption's slot story has changed recently ⚠️ verify against current Azure docs before designing a release process around it.

2. Redeploy the previous package. Same artifact, previous version, config-zip again. Slower than a swap and it pays a cold start, but it works on every plan.

3. Re-apply the previous commit's Terraform. For infrastructure changes. Reliable, but it is a full plan/apply cycle, and see the replacement warning below.

4. Restore from backup. For the storage side — Durable state, keys — not the app itself.

Operations that force replacement rather than update. These are the ones that hurt, because Terraform will destroy and recreate rather than modify:

  • Changing the function app name, resource group, or region.
  • Changing the plan's OS type (Linux ↔ Windows) — a full recreate of the plan and everything on it.
  • Changing the storage account name — which, if it's AzureWebJobsStorage, orphans your Durable orchestration history and your function keys. Every caller holding a function key breaks. This is the most expensive replacement in this topic.
  • Moving between certain plan families — read the terraform plan output, and if you see # forces replacement next to anything storage- or plan-related, stop and think.

Two Azure-specific traps that make a rollback fail in confusing ways:

  • Soft delete and purge protection. If your app reads secrets from a Key Vault, a "deleted" vault still holds its name and blocks recreation until it's purged — and with purge protection on, it cannot be purged at all until the retention period expires. A rollback that recreates a vault will fail with a name-conflict error that looks like a bug.
  • Resource locks. A CanNotDelete lock on the resource group makes terraform destroy or a replacement fail with an authorisation error that reads exactly like a missing RBAC role. Check for locks before you debug permissions.

Drift detection

Someone will change something in the portal. Plan for it:

  • Scheduled terraform plan in CI — nightly, against each environment, failing the job on a non-empty diff. This is the highest-value drift control and it costs almost nothing.
  • az deployment group what-if for the Bicep path.
  • Azure Policy compliance state — catches the classes of drift you care about most (public access, missing diagnostics, missing tags) regardless of which tool made the change.
  • The activity log — tells you who changed it and when, which is the question that actually gets asked. Route it to Log Analytics so you can query it in KQL.

The specific Functions gotcha: app settings drift constantly, because portal users add them and because some Azure features (Application Insights connection, deployment tooling) write them automatically. Decide which settings Terraform owns, put the rest under ignore_changes, and document the split — otherwise your drift alert becomes noise and you'll stop reading it.

Teardown

terraform destroy -var-file=envs/dev.tfvars

What destroy will not remove:

  • Soft-deleted Key Vaults and their secrets — still holding their names, blocking recreation.
  • Anything behind a CanNotDelete or ReadOnly resource lock — destroy fails outright.
  • Role assignments created outside the state file, including ones the portal created for you.
  • Diagnostic settings created out of band.
  • The deployed code package and anything your functions wrote to external stores.
  • Log Analytics data already ingested — you're billed for retention until it ages out.
  • Application Insights components created by the portal in a different resource group.

For a truly clean sweep of a throwaway environment, deleting the whole resource group is still the most reliable instrument — provided the resource group is genuinely owned by this module and nothing else lives in it.


Next: Integrations →

← Back to the Azure Functions overview · ← Previous: Getting Started