DEV Community

Cover image for Terraform and Bicep: Infrastructure as Code for the Cloud
Rhuturaj Takle
Rhuturaj Takle

Posted on

Terraform and Bicep: Infrastructure as Code for the Cloud

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

  1. Introduction
  2. Why Infrastructure as Code
  3. Bicep
  4. Terraform
  5. State Management
  6. Modules and Reusability
  7. Multi-Environment Patterns
  8. CI/CD Integration
  9. Terraform vs. Bicep
  10. Common Pitfalls
  11. Quick Reference Table
  12. 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
}
Enter fullscreen mode Exit fullscreen mode
// Bicep
resource api 'Microsoft.Web/sites@2023-12-01' = {
  name: 'my-api'
  location: resourceGroup().location
  properties: {
    serverFarmId: appServicePlan.id
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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"
}
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode
az deployment group create --resource-group my-rg --template-file main.bicep --parameters appName=my-api
Enter fullscreen mode Exit fullscreen mode

Key language features

Parameters and variables:

param environment string = 'dev'
var appName = 'myapp-${environment}'
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
}]
Enter fullscreen mode Exit fullscreen mode

Conditions:

resource cdn 'Microsoft.Cdn/profiles@2023-05-01' = if (environment == 'production') {
  name: 'my-cdn'
  location: 'global'
  sku: { name: 'Standard_Microsoft' }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
terraform apply -var="app_name=my-api" -var="environment=production"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
# 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// main.bicep
module productApi 'modules/web-app.bicep' = {
  name: 'productApiDeployment'
  params: {
    appName: 'product-api'
    location: resourceGroup().location
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Bicep: parameter files per environment

// main.bicep — shared across all environments
param environment string
param appServicePlanSku string
Enter fullscreen mode Exit fullscreen mode
// staging.bicepparam
using 'main.bicep'
param environment = 'staging'
param appServicePlanSku = 'S1'
Enter fullscreen mode Exit fullscreen mode
// production.bicepparam
using 'main.bicep'
param environment = 'production'
param appServicePlanSku = 'P1v3'
Enter fullscreen mode Exit fullscreen mode
az deployment group create --resource-group my-rg --template-file main.bicep --parameters production.bicepparam
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# Preview equivalent to terraform plan, for PR review
az deployment group what-if --resource-group my-rg --template-file main.bicep --parameters production.bicepparam
Enter fullscreen mode Exit fullscreen mode

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)