CI/CD for Azure Serverless: Automating Deployments with Terraform and Azure DevOps
How a fintech team deployed 50+ Azure Functions across 3 environments using Terraform and Azure DevOps—with zero manual intervention.
Why Terraform + Azure DevOps?
Infrastructure-as-Code (IaC): Terraform’s declarative syntax ensures reproducible environments (dev, staging, prod).
Azure DevOps Pipelines: Enterprise-grade CI/CD with built-in approvals, logging, and integrations.
State Management: Terraform Cloud/Azure Storage for tracking infrastructure state.
Multi-Cloud Ready: Terraform’s provider model supports hybrid/AWS/GCP (unlike Bicep/ARM).
Architecture Overview
[Git Repo]
→ [Azure DevOps Pipeline (Build)]
→ [Terraform Plan/Apply (Infra)]
→ [Azure Functions Deployment (Code)]
→ [Dev/Staging/Prod Environments]
Step 1: Structuring the Terraform Project
Folder Structure
├── modules/
│ ├── function_app/
│ │ ├── main.tf
│ │ ├── variables.tf
│ ├── storage/
│ │ ├── main.tf
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ ├── staging/
│ ├── prod/
├── pipelines/
│ ├── azure-devops.yml
Example: Terraform Module for Azure Function (modules/function_app/main.tf)
resource "azurerm_function_app" "example" {
name = "func-${var.env}-payment-api"
resource_group_name = var.resource_group
location = var.location
app_service_plan_id = var.app_service_plan_id
storage_account_name = azurerm_storage_account.example.name
app_settings = {
"APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.example.instrumentation_key
"AzureWebJobsStorage" = azurerm_storage_account.example.primary_connection_string
}
identity {
type = "SystemAssigned"
}
}
Step 2: Azure DevOps Pipeline Setup
Pipeline Tasks
Install Terraform: Use the Terraform Azure DevOps Extension.
Initialize Terraform:
terraform init
with backend state in Azure Storage.Plan/Apply Infrastructure:
terraform plan
for dry runs.terraform apply
to deploy infra.
Deploy Function Code: Use
AzureFunctionApp@1
task.
Pipeline YAML (pipelines/azure-devops.yml)
trigger:
branches:
include:
- main
variables:
- name: TF_BACKEND_STORAGE
value: "terraformstate"
stages:
- stage: Build
jobs:
- job: Build
steps:
- task: TerraformInstaller@0
inputs:
terraformVersion: '1.5.7'
- script: |
terraform init -backend-config="storage_account_name=$(TF_BACKEND_STORAGE)"
terraform validate
displayName: 'Initialize Terraform'
- stage: Deploy_Dev
dependsOn: Build
jobs:
- job: Deploy
steps:
- task: TerraformTaskV2@2
inputs:
command: 'plan'
environmentServiceName: 'Azure-Dev'
commandOptions: '-var-file=environments/dev/terraform.tfvars'
- task: TerraformTaskV2@2
inputs:
command: 'apply'
environmentServiceName: 'Azure-Dev'
commandOptions: '-auto-approve'
- task: AzureFunctionApp@1
inputs:
azureSubscription: 'Azure-Dev'
appType: 'functionApp'
appName: 'func-dev-payment-api'
package: '$(Pipeline.Workspace)/drop/function.zip'
Step 3: Integrating Function Code Deployment
Build Pipeline for Function Code
- stage: Build_Function
jobs:
- job: Build
steps:
- task: UseDotNet@2
inputs:
version: '6.x'
- task: DotNetCoreCLI@2
inputs:
command: 'publish'
projects: 'src/PaymentFunction.csproj'
arguments: '-o $(Build.ArtifactStagingDirectory)'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: '$(Build.ArtifactStagingDirectory)'
includeRootFolder: false
archiveFile: '$(Build.ArtifactStagingDirectory)/function.zip'
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/function.zip'
artifactName: 'function'
Step 4: Securing Terraform State and Secrets
- Backend State in Azure Storage:
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "terraformstate"
container_name = "tfstate"
key = "dev.terraform.tfstate"
}
}
- Azure Key Vault Integration:
data "azurerm_key_vault_secret" "stripe_key" {
name = "StripeApiKey"
key_vault_id = azurerm_key_vault.example.id
}
# Use in Function App settings:
app_settings = {
"StripeApiKey" = "@Microsoft.KeyVault(SecretUri=${data.azurerm_key_vault_secret.stripe_key.id})"
}
Real-World Use Case: Fintech Deployment Pipeline
Scenario:
A payment gateway needed to deploy 50+ Functions across 3 regions with strict compliance.
Implementation:
Terraform: Defined all resources (Functions, Storage, Key Vault) as code.
Azure DevOps:
Multi-Stage Pipelines: Dev → Staging → Prod with manual approvals.
Variable Groups: Stored environment-specific settings (e.g., API endpoints).
Security:
Terraform state encrypted in Azure Storage.
Pipeline permissions scoped to least privilege.
Results:
Deployment time reduced from 2 hours to 15 minutes.
Zero configuration drift between environments.
Pros and Cons: Terraform vs. Bicep
Factor | Terraform | Bicep |
State Management | Requires backend (e.g., Azure Storage) | Native state with Azure |
Multi-Cloud Support | ✅ (AWS, GCP, etc.) | ❌ (Azure-only) |
Learning Curve | Moderate (HCL syntax) | Easy (ARM-like) |
Community Modules | Extensive (Terraform Registry) | Limited (Azure Quickstart) |
Cost | Free (OSS) | Free |
Best Practices
Modularize Terraform Code: Reuse modules for Functions, Storage, etc.
Pipeline Approvals: Require manual checks before prod deployments.
Secret Management: Never store secrets in Terraform variables—use Key Vault.
Testing:
terraform validate
andtflint
for static checks.Deploy to a sandbox environment nightly.
Conclusion
Combining Terraform for infrastructure and Azure DevOps for pipelines gives you a robust, auditable, and scalable CI/CD process for Azure serverless apps. While Bicep is great for Azure-only teams, Terraform shines in hybrid/multi-cloud environments.