Skip to content

title: Provider configuration description: Sensible azurerm + azuread provider defaults for Terraform / OpenTofu: features block, OIDC auth from CI, multi-subscription aliases, and ARM environment variables. tags: - terraform - azure


Provider configuration

Drop-in provider "azurerm" blocks for the most common shapes: local developer auth via Azure CLI, OIDC from CI, and multi-subscription aliases. Targets azurerm ≥ 4.0 and azuread ≥ 3.0.

features {} is mandatory

The empty features {} block is required even when you don't override anything: terraform validate will fail without it. Use it to control destroy-time behaviours like Key Vault soft-delete recovery and resource group force-delete.

required_providers

terraform {
  required_version = ">= 1.6.0"

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

    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 3.0"
    }
  }
}

Baseline provider "azurerm" block

provider "azurerm" {
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id

  features {
    key_vault {
      purge_soft_delete_on_destroy               = false
      purge_soft_deleted_secrets_on_destroy      = false
      recover_soft_deleted_key_vaults            = true
      recover_soft_deleted_secrets               = true
    }

    resource_group {
      prevent_deletion_if_contains_resources = true
    }

    virtual_machine {
      delete_os_disk_on_deletion     = true
      graceful_shutdown              = false
      skip_shutdown_and_force_delete = false
    }

    log_analytics_workspace {
      permanently_delete_on_destroy = false
    }

    storage {
      data_plane_available = true
    }
  }
}

Recover, don't purge

Defaults set above prefer recovery over purge for Key Vault and Log Analytics. Accidental destroys are recoverable; flip the flags only in ephemeral environments where you want a clean tear-down.

azuread provider

provider "azuread" {
  tenant_id = var.tenant_id
}

Authentication

The provider tries auth methods in this order: environment variables → managed identity → OIDC → CLI. Pick exactly one method per environment so behaviour stays predictable.

Local development: Azure CLI

az login
az account set --subscription "<your-subscription-id>"
provider "azurerm" {
  features {}
  # subscription_id is read from the CLI context if omitted.
  use_cli = true   # default: shown for clarity
}

CI: GitHub Actions OIDC

provider "azurerm" {
  features {}

  use_oidc        = true
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id
  client_id       = var.client_id
}
# .github/workflows/terraform.yml
permissions:
  id-token: write
  contents: read

jobs:
  apply:
    runs-on: ubuntu-latest
    env:
      ARM_USE_OIDC:        "true"
      ARM_CLIENT_ID:       ${{ vars.AZURE_CLIENT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      ARM_TENANT_ID:       ${{ vars.AZURE_TENANT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform apply -auto-approve

Self-hosted runners: Managed Identity

provider "azurerm" {
  features {}
  use_msi         = true
  subscription_id = var.subscription_id
}

Service principal + secret (legacy)

export ARM_CLIENT_ID="…"
export ARM_CLIENT_SECRET="…"   # rotate via Key Vault, never commit
export ARM_SUBSCRIPTION_ID="…"
export ARM_TENANT_ID="…"
provider "azurerm" {
  features {}
  # All four IDs are read from ARM_* env vars.
}

Multi-subscription aliases

Terraform supports multiple instances of the same provider via alias. Use this to provision shared resources (DNS, Log Analytics) in one subscription while everything else lives in a workload subscription.

provider "azurerm" {
  alias           = "workload"
  features {}
  subscription_id = var.workload_subscription_id
}

provider "azurerm" {
  alias           = "shared"
  features {}
  subscription_id = var.shared_subscription_id
}

# Workload-subscription resource (default-ish: pick the alias explicitly)
resource "azurerm_resource_group" "app" {
  provider = azurerm.workload
  name     = "rg-app-prod"
  location = "eastus"
}

# Shared DNS zone lives in the platform subscription
resource "azurerm_dns_a_record" "api" {
  provider            = azurerm.shared
  name                = "api"
  zone_name           = "example.com"
  resource_group_name = "rg-shared-dns"
  ttl                 = 300
  records             = [azurerm_public_ip.api.ip_address]
}

Pass aliases into modules explicitly

A child module that uses an aliased provider must declare it in its own required_providers and you must wire it up via providers = { ... } on the module call:

module "dns" {
  source = "./modules/dns"
  providers = {
    azurerm = azurerm.shared
  }
}

ARM environment variables (reference)

Variable Purpose
ARM_SUBSCRIPTION_ID Default subscription (overrides CLI context).
ARM_TENANT_ID Entra ID tenant.
ARM_CLIENT_ID Service principal / app registration client ID.
ARM_CLIENT_SECRET SP secret (avoid; prefer OIDC or MSI).
ARM_USE_OIDC true to use OIDC token from CI.
ARM_OIDC_TOKEN_FILE_PATH Path to a file containing the OIDC token (Kubernetes).
ARM_USE_MSI true to use the runner's managed identity.
ARM_USE_CLI true to use the Azure CLI session (default locally).
ARM_ENVIRONMENT public, usgovernment, china, german.

References