Terraform and Bicep: Infrastructure as Code for the Cloud
A practical guide to Infrastructure as Code (IaC) using Terraform and Bicep — the two dominant tools for defining, provisioning, and managing cloud resources declaratively, covering core concepts, state management, modules, and how to choose between them.
Table of Contents
- Introduction
- Why Infrastructure as Code
- Bicep
- Terraform
- State Management
- Modules and Reusability
- Multi-Environment Patterns
- CI/CD Integration
- Terraform vs. Bicep
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Infrastructure as Code means defining cloud resources — virtual machines, databases, networks, container clusters — in text files that a tool can read and use to create, update, or destroy real infrastructure, rather than clicking through a cloud portal or running one-off CLI commands. The file becomes the source of truth: it's version-controlled, reviewable in a pull request, and reproducible across environments.
Bicep is Microsoft's domain-specific language for deploying Azure resources — a cleaner syntax layered directly on top of Azure Resource Manager (ARM) templates. Terraform, from HashiCorp, is cloud-agnostic — the same tool and language (HCL) can provision resources across Azure, AWS, GCP, and dozens of other providers, all from one workflow.
# Terraform
resource "azurerm_linux_web_app" "api" {
name = "my-api"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
service_plan_id = azurerm_service_plan.main.id
}
// Bicep
resource api 'Microsoft.Web/sites@2023-12-01' = {
name: 'my-api'
location: resourceGroup().location
properties: {
serverFarmId: appServicePlan.id
}
}
Both describe the same underlying idea: "this resource should exist, with these properties" — declared once, applied repeatedly and predictably.
1. Why Infrastructure as Code
The problem with manual provisioning
Clicking through a cloud console to create a VM, a database, and a network works fine once. It breaks down the moment you need to:
- Recreate the exact same environment for staging, or after a disaster.
- Know what changed and why, months later.
- Review a proposed infrastructure change before it's applied, the way you'd review a code change.
- Tear down and rebuild an entire environment reliably (e.g., ephemeral pull-request environments).
What IaC provides
- Reproducibility — the same definition produces the same infrastructure, every time, in every environment.
-
Version control — infrastructure changes go through the same
git diff, pull request, and review process as application code. - Declarative intent — you describe the desired end state; the tool figures out the steps to get there (create, update, or delete) rather than you scripting each step imperatively.
- Drift detection — the tool can compare what's actually deployed against what's declared and flag discrepancies (someone manually changed a setting in the portal, for instance).
- Auditability — a Git history of every infrastructure change, tied to a specific commit and author.
Declarative vs. imperative
# Imperative: describe the steps
az group create --name my-rg --location eastus
az appservice plan create --name my-plan --resource-group my-rg --sku B1
az webapp create --name my-api --resource-group my-rg --plan my-plan
# Declarative: describe the desired end state
resource "azurerm_resource_group" "main" {
name = "my-rg"
location = "eastus"
}
resource "azurerm_service_plan" "main" {
name = "my-plan"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku_name = "B1"
os_type = "Linux"
}
The declarative version doesn't care whether the resource group already exists, needs updating, or needs creating — the tool diffs the desired state against reality and figures out the necessary action itself.
2. Bicep
Bicep compiles down to standard ARM JSON templates but reads far more like a real programming language — no more deeply nested JSON, no more "[concat(...)]" string-function gymnastics.
A basic Bicep file
param location string = resourceGroup().location
param appServicePlanSku string = 'B1'
param appName string
resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: '${appName}-plan'
location: location
sku: {
name: appServicePlanSku
}
}
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
properties: {
serverFarmId: appServicePlan.id
siteConfig: {
linuxFxVersion: 'DOTNETCORE|9.0'
}
}
}
output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
az deployment group create --resource-group my-rg --template-file main.bicep --parameters appName=my-api
Key language features
Parameters and variables:
param environment string = 'dev'
var appName = 'myapp-${environment}'
Resource references (implicit dependency tracking):
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
name: appName
properties: {
serverFarmId: appServicePlan.id // referencing appServicePlan creates an automatic dependency
}
}
Bicep infers deployment order from these references — you rarely need to declare an explicit dependsOn the way raw ARM templates often required.
Modules (composing reusable Bicep files):
module database 'modules/database.bicep' = {
name: 'databaseDeployment'
params: {
serverName: 'my-sql-server'
location: location
}
}
Loops:
param regions array = ['eastus', 'westeurope', 'southeastasia']
resource storageAccounts 'Microsoft.Storage/storageAccounts@2023-01-01' = [for region in regions: {
name: 'storage${region}'
location: region
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
}]
Conditions:
resource cdn 'Microsoft.Cdn/profiles@2023-05-01' = if (environment == 'production') {
name: 'my-cdn'
location: 'global'
sku: { name: 'Standard_Microsoft' }
}
What-if deployments
Bicep (via the Azure CLI) supports a what-if preview — showing exactly what would change without actually applying anything, similar in spirit to terraform plan:
az deployment group what-if --resource-group my-rg --template-file main.bicep --parameters appName=my-api
Best fit
Teams building exclusively on Azure who want a first-party, tightly integrated authoring experience — Bicep gets day-one support for new Azure resource types and API versions (since it compiles directly to ARM, the same mechanism the Azure portal itself uses), with strong IDE tooling (VS Code extension with live validation and IntelliSense).
3. Terraform
Terraform uses HCL (HashiCorp Configuration Language), and its defining characteristic is being provider-agnostic — the same core workflow and syntax apply whether you're provisioning Azure, AWS, GCP, Kubernetes, Datadog, or literally hundreds of other systems that publish a Terraform provider.
A basic Terraform configuration
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = "my-rg"
location = "East US"
}
resource "azurerm_service_plan" "main" {
name = "my-plan"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
os_type = "Linux"
sku_name = "B1"
}
resource "azurerm_linux_web_app" "api" {
name = "my-api"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
service_plan_id = azurerm_service_plan.main.id
site_config {
application_stack {
dotnet_version = "9.0"
}
}
}
output "web_app_url" {
value = "https://${azurerm_linux_web_app.api.default_hostname}"
}
The core workflow
terraform init # downloads providers and initializes the working directory
terraform plan # shows what would change, without applying it
terraform apply # applies the change, after confirmation
terraform destroy # tears down everything Terraform manages in this configuration
terraform plan is one of Terraform's most valuable features in practice — it's a dry run showing exactly what will be created, changed, or destroyed, in a readable diff format, before anything actually happens:
Terraform will perform the following actions:
# azurerm_linux_web_app.api will be created
+ resource "azurerm_linux_web_app" "api" {
+ name = "my-api"
+ ...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Variables and outputs
variable "environment" {
type = string
default = "dev"
}
variable "app_name" {
type = string
}
resource "azurerm_resource_group" "main" {
name = "${var.app_name}-${var.environment}-rg"
location = "East US"
}
terraform apply -var="app_name=my-api" -var="environment=production"
Loops and conditionals
variable "regions" {
default = ["eastus", "westeurope", "southeastasia"]
}
resource "azurerm_storage_account" "storage" {
for_each = toset(var.regions)
name = "storage${each.value}"
resource_group_name = azurerm_resource_group.main.name
location = each.value
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_cdn_profile" "cdn" {
count = var.environment == "production" ? 1 : 0
name = "my-cdn"
resource_group_name = azurerm_resource_group.main.name
location = "global"
sku = "Standard_Microsoft"
}
Multi-cloud in one configuration
provider "aws" {
region = "us-east-1"
}
provider "azurerm" {
features {}
}
resource "aws_s3_bucket" "logs" {
bucket = "my-app-logs"
}
resource "azurerm_storage_account" "backup" {
name = "myappbackup"
resource_group_name = azurerm_resource_group.main.name
location = "East US"
account_tier = "Standard"
account_replication_type = "GRS"
}
This is Terraform's signature capability — a single tool, workflow, and state model spanning multiple clouds and even non-cloud systems (DNS providers, monitoring platforms, SaaS configuration) in one coherent configuration.
Best fit
Multi-cloud or hybrid environments, teams already invested in the broader Terraform/HashiCorp ecosystem, or organizations wanting one consistent IaC tool and workflow across every platform they use rather than a different tool per cloud.
4. State Management
This is the area where Terraform and Bicep differ most fundamentally.
Terraform: explicit, persisted state
Terraform maintains a state file (terraform.tfstate) — a JSON record of exactly what Terraform believes exists and its current configuration. Every plan and apply compares your .tf files against this state (and, to a lesser extent, against the real infrastructure) to compute what needs to change.
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstatestorage"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
Storing state remotely (Azure Storage, AWS S3, Terraform Cloud, etc.) rather than as a local file is essential for any team environment — it enables state locking (preventing two people from running apply concurrently and corrupting the state) and gives everyone on the team a consistent view of what's actually deployed.
The state file is sensitive — it often contains resource attributes verbatim, including things that should be treated as secrets (connection strings, generated passwords). Treat it with the same care as any other credential store: encrypt it at rest, restrict access, and never commit it to source control.
Bicep: stateless, relies on Azure Resource Manager
Bicep doesn't maintain a separate state file at all — it relies entirely on Azure Resource Manager as the source of truth for what currently exists. Every deployment is compared directly against the live state of Azure itself.
This has real practical implications:
- No state file to secure, lock, or corrupt — one whole category of Terraform operational concerns simply doesn't exist in Bicep.
- No drift between "what Terraform thinks exists" and "what Azure actually has" — Bicep is always looking at the real thing.
- The tradeoff: Bicep's "what currently exists" understanding is scoped entirely to Azure Resource Manager, so it has no concept of tracking non-Azure resources or arbitrary external systems the way Terraform's state and provider model can.
5. Modules and Reusability
Terraform modules
# modules/web-app/main.tf
variable "app_name" {}
variable "resource_group_name" {}
variable "location" {}
resource "azurerm_service_plan" "plan" {
name = "${var.app_name}-plan"
resource_group_name = var.resource_group_name
location = var.location
os_type = "Linux"
sku_name = "B1"
}
resource "azurerm_linux_web_app" "app" {
name = var.app_name
resource_group_name = var.resource_group_name
location = var.location
service_plan_id = azurerm_service_plan.plan.id
}
output "url" {
value = azurerm_linux_web_app.app.default_hostname
}
# root main.tf
module "product_api" {
source = "./modules/web-app"
app_name = "product-api"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
The Terraform Registry hosts a large public ecosystem of pre-built, community- and vendor-maintained modules for common patterns (a well-configured VPC, a production-ready Kubernetes cluster) that teams can consume directly instead of writing from scratch.
Bicep modules
// modules/web-app.bicep
param appName string
param location string
resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: '${appName}-plan'
location: location
sku: { name: 'B1' }
}
resource app 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
properties: { serverFarmId: plan.id }
}
output url string = app.properties.defaultHostName
// main.bicep
module productApi 'modules/web-app.bicep' = {
name: 'productApiDeployment'
params: {
appName: 'product-api'
location: resourceGroup().location
}
}
Both ecosystems support publishing and versioning modules for reuse — Terraform via the public/private Registry, Bicep via Azure Container Registry-hosted module registries or, more recently, Bicep Registry support for public/private module sharing.
6. Multi-Environment Patterns
Terraform: workspaces or separate state per environment
terraform workspace new staging
terraform workspace new production
terraform workspace select staging
terraform apply -var-file="staging.tfvars"
Many teams prefer separate state files/directories per environment (rather than workspaces) for stronger isolation — accidental cross-environment changes are much harder when dev, staging, and production literally have separate state files, separate backend configurations, and often separate approval gates:
environments/
dev/main.tf
staging/main.tf
production/main.tf
modules/
web-app/
Bicep: parameter files per environment
// main.bicep — shared across all environments
param environment string
param appServicePlanSku string
// staging.bicepparam
using 'main.bicep'
param environment = 'staging'
param appServicePlanSku = 'S1'
// production.bicepparam
using 'main.bicep'
param environment = 'production'
param appServicePlanSku = 'P1v3'
az deployment group create --resource-group my-rg --template-file main.bicep --parameters production.bicepparam
Bicep leans on Azure subscriptions/resource groups as the natural environment boundary — since there's no separate state file, "which environment is this" is really just "which resource group/subscription am I deploying into," combined with a parameter file selecting environment-specific values.
7. CI/CD Integration
Terraform in a pipeline
# GitHub Actions
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
A common, valuable pattern: run terraform plan automatically on every pull request (posting the plan output as a PR comment for human review), and only run terraform apply after merge to the main branch — infrastructure changes get the same review-before-merge discipline as application code.
Bicep in a pipeline
- uses: azure/arm-deploy@v2
with:
resourceGroupName: my-rg
template: ./main.bicep
parameters: ./production.bicepparam
# Preview equivalent to terraform plan, for PR review
az deployment group what-if --resource-group my-rg --template-file main.bicep --parameters production.bicepparam
Both tools support this "preview in PR, apply after merge" pattern — the details differ, but the underlying discipline (never apply infrastructure changes without a reviewed diff first) is identical and equally important for both.
8. Terraform vs. Bicep
| Aspect | Bicep | Terraform |
|---|---|---|
| Cloud scope | Azure only | Multi-cloud (Azure, AWS, GCP, and hundreds more) |
| State management | None — relies on Azure Resource Manager directly | Explicit state file, requires a remote backend for teams |
| New Azure feature support | Immediate (same mechanism as ARM/the Azure portal) | Depends on the azurerm provider catching up, usually fast but not instant |
| Language | Bicep DSL (compiles to ARM JSON) | HCL, purpose-built, provider-agnostic |
| Ecosystem/modules | Growing, Bicep Registry, ARM template ecosystem | Very large — Terraform Registry, huge community module ecosystem |
| Preview changes | az deployment ... what-if |
terraform plan (arguably the more mature, widely relied-upon version of this idea) |
| Learning curve | Lower if you already know Azure and ARM concepts | Slightly higher, but transferable across every cloud you'll ever touch |
| Vendor | Microsoft (first-party Azure tool) | HashiCorp (BSL-licensed as of Terraform 1.5.x, with OpenTofu as an open-source fork) |
A practical rule of thumb
- Exclusively on Azure, want the tightest integration with new Azure features, and don't need to manage non-Azure infrastructure? → Bicep.
- Multi-cloud, hybrid, or want one consistent IaC tool and skill set across every platform (including non-cloud systems like DNS or monitoring configuration)? → Terraform.
- Already deep in the Terraform ecosystem for other clouds and just need to add Azure resources? → Terraform, for consistency, even for an Azure-only workload.
9. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Committing Terraform state to source control | State often contains secrets in plaintext | Use a remote backend (Azure Storage, S3, Terraform Cloud) with encryption and access control |
| Manually changing resources in the cloud portal | Causes drift between IaC definition and reality, silently | Make all changes through the IaC tool; use drift detection (terraform plan, Bicep what-if) to catch exceptions |
| No plan/what-if review before applying | Changes get applied blind, including accidental deletions | Always run plan/what-if and review the diff, ideally as part of a PR |
Hardcoding secrets in .tf/.bicep files |
Secrets end up in version control history | Use a secrets manager (Azure Key Vault, AWS Secrets Manager) referenced at deploy time, not literal values in code |
| One giant configuration for everything | Slow plans, high blast radius for any single mistake, hard to reason about | Split into modules and separate state/deployments per logical boundary (network, data, app) |
| No locking on shared state | Concurrent applies can corrupt state or double-apply changes | Use a backend that supports state locking (Azure Storage with lease, S3 + DynamoDB, Terraform Cloud) |
Quick Reference Table
| Concept | Bicep | Terraform |
|---|---|---|
plan/preview |
az deployment ... what-if |
terraform plan |
| Apply | az deployment group create |
terraform apply |
| Reusable units | Modules (.bicep files) |
Modules (directories with variables/outputs) |
| State | None — Azure Resource Manager is the source of truth | Explicit .tfstate, needs a remote backend for teams |
| Multi-cloud | No — Azure only | Yes — hundreds of providers |
| Parameter files |
.bicepparam files |
.tfvars files |
| Loops |
for expressions |
count / for_each
|
| Conditions |
if expressions on resources |
count = condition ? 1 : 0 |
Conclusion
Terraform and Bicep both solve the same core problem — describing infrastructure declaratively so it's reproducible, reviewable, and version-controlled — but they make different bets about scope. Bicep bets on being the best possible tool for Azure specifically, with no separate state to manage and immediate support for new Azure capabilities. Terraform bets on being one consistent tool across every cloud and system you'll ever need to provision, at the cost of an explicit state file you're responsible for securing and managing.
Neither choice is wrong in isolation — an Azure-only shop loses little by choosing Bicep's simplicity, and a multi-cloud or platform-team context gains real, lasting value from Terraform's provider-agnostic consistency. What matters far more than which tool you pick is actually adopting the discipline both enable: infrastructure changes reviewed as diffs before they're applied, state (if any) treated as sensitive, and manual "just click it in the portal" changes eliminated as a habit.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the state-file mishap that taught you to respect remote backends.
Top comments (0)