Building a Modern Development Platform: Terraform & Terraform Cloud for Azure Infrastructure 🏗️
Introduction 🚀
In our tool selection post, we chose Terraform as our Infrastructure as Code (IaC) tool. But choosing the tool is just the beginning. To build a truly modern development platform, we need:
- Secure Azure authentication without storing credentials in code
- Centralized state management that works across teams
- Reusable infrastructure patterns based on Azure best practices
- A module registry for sharing standardized components
This post walks through setting up Terraform Cloud with Azure, organizing infrastructure code, and building modules that align with Azure’s resource classification patterns from Azure Charts.
📦 Code Repository: All the Terraform code from this tutorial is available on GitHub at blog-platform-aspire/aspire-tools-terraform.
Why Terraform Cloud? ☁️
Before diving into the setup, let’s understand why we’re using Terraform Cloud instead of local state files or basic remote backends:
State Management
- Centralized, secure state storage with automatic locking
- State versioning and rollback capabilities
- Team collaboration without state file conflicts
Secure Credential Management
- Workload Identity Federation with Azure (no stored secrets!)
- Encrypted variable storage
- Audit logging for compliance
Team Workflows
- CLI-driven runs for infrastructure deployments
- VCS integration for module versioning and publishing
- Shared state management across teams
- Private module registry for code reuse
Cost Optimization
- Free tier supports up to 500 resources per month
- Pay only for what you use beyond that
- Note: While 500 free resources sounds generous, the per-resource cost beyond that is quite high. If you’re managing large-scale infrastructure across multiple environments, costs can add up quickly. In a future post, we’ll explore migrating state management to Azure Storage Account as a more cost-effective alternative for teams with extensive infrastructure footprints.
Terraform Cloud Setup 🛠️
Creating Your Organization
First, sign up at Terraform Cloud and create an organization. This will be the container for all your projects and workspaces.
# After signing up, note your organization name
# We'll use "my-org" as an example
export TF_CLOUD_ORG="my-org"
Understanding Projects and Workspaces
Terraform Cloud uses a two-level hierarchy:
Projects: Logical groupings of related workspaces (e.g., “Platform Infrastructure”, “Application Environments”)
Workspaces: Individual environment configurations (e.g., “shared-services-dev”, “app-prod”, “app-staging”)
This maps well to our platform structure:
Organization: my-org
├── Project: Shared Services
│ ├── Workspace: shared-services # Cross-environment resources
│ # (App Config, Key Vault, ACR)
├── Project: Environment Infrastructure
│ ├── Workspace: env-dev # Environment-level resources
│ ├── Workspace: env-staging # (Front Door, APIM, App Service Plans)
│ └── Workspace: env-prod
└── Project: Applications
├── Workspace: app-weather-dev # App-specific resources
├── Workspace: app-weather-staging # (Web Apps, settings, FD/APIM config)
└── Workspace: app-weather-prod
Note: We’ll dive deeper into this Azure resource organization strategy in a later post on Azure environment architecture.
Creating Projects
In Terraform Cloud UI:
- Navigate to Projects → New Project
- Create a project called “platform-blog-post”
- We’ll create workspaces within this project in the next steps
For this tutorial, we’ll keep it simple with one project and three workspaces:
- shared-services - For cross-environment shared resources (ACR)
- env-test - For environment-level infrastructure (App Service Plan)
- app-test - For application-specific resources (Web App)
Creating Workspaces:
- In Terraform Cloud, navigate to your platform-blog-post project
- Click New workspace
- Choose CLI-driven workflow (not VCS-driven)
- Name it shared-services (repeat for env-test and app-test)
You should end up with three workspaces in your project:
shared-services
- Deploy first (no dependencies)env-test
- Deploy second (depends on shared-services)app-test
- Deploy third (depends on both shared-services and env-test)
Why CLI-driven workflow?
- Gives you full control over when and how infrastructure is deployed
- Allows orchestration via pipelines (GitHub Actions, Azure DevOps, etc.)
- Perfect for complex deployment workflows with dependencies
- You trigger runs from your local machine or CI/CD pipeline, not from Git commits
We’ll explore pipeline automation in a future post on Azure DevOps Pipelines.
Installing Terraform CLI
Before we can work with Terraform Cloud, we need to install the Terraform CLI:
macOS (using Homebrew):
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Verify installation
terraform version
Windows (using Chocolatey):
choco install terraform
# Verify installation
terraform version
Linux (Ubuntu/Debian):
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Verify installation
terraform version
Logging into Terraform Cloud
Once Terraform is installed, authenticate with Terraform Cloud:
# Login to Terraform Cloud
terraform login
# This will:
# 1. Open your browser to https://app.terraform.io/app/settings/tokens
# 2. Prompt you to create an API token
# 3. Copy the token back to your terminal
# 4. Store the token in ~/.terraform.d/credentials.tfrc.json
What happens during login:
- Terraform opens your browser to the token creation page
- You create a token (give it a descriptive name like “Local Development”)
- The token is saved locally and used for all future Terraform Cloud API calls
- This token authenticates your CLI operations (plan, apply, etc.)
Manual token creation (alternative):
If terraform login
doesn’t work, you can manually create the credentials file:
# Create the credentials file
mkdir -p ~/.terraform.d
cat > ~/.terraform.d/credentials.tfrc.json <<EOF
{
"credentials": {
"app.terraform.io": {
"token": "YOUR_TOKEN_HERE"
}
}
}
EOF
Azure Service Principal Setup 🔐
Now comes the critical part: setting up secure authentication between Terraform Cloud and Azure. We’ll use Workload Identity Federation instead of storing client secrets.
Prerequisites:
- Azure CLI installed (installation guide)
- An Azure subscription
Step 1: Create the Service Principal
First, authenticate with Azure:
# Login to Azure
az login
# If you have multiple subscriptions, list them
az account list --output table
# Set the subscription you want to use
SUBSCRIPTION_ID="your-subscription-id"
az account set --subscription $SUBSCRIPTION_ID
# Verify you're using the correct subscription
az account show
Now create the service principal:
# Create a service principal for Terraform
SP_NAME="terraform-platform-sp"
# Create with no role assignment initially
az ad sp create-for-rbac \
--name $SP_NAME \
--role Contributor \
--scopes "/subscriptions/$SUBSCRIPTION_ID" \
--output json > sp-output.json
# Capture the important values
CLIENT_ID=$(cat sp-output.json | jq -r '.appId')
TENANT_ID=$(cat sp-output.json | jq -r '.tenant')
echo "Client ID: $CLIENT_ID"
echo "Tenant ID: $TENANT_ID"
# Clean up the file (it contains a secret we won't use)
rm sp-output.json
Step 2: Configure Workload Identity Federation
This is where the magic happens - no stored secrets!
Important: Azure federated credentials don’t support wildcards (*
) in the subject pattern. You need to create a separate credential for each workspace and run phase combination.
# Get your Terraform Cloud organization name
TF_CLOUD_ORG="my-org"
# IMPORTANT: Project name must match EXACTLY as shown in Terraform Cloud (case-sensitive)
PROJECT_NAME="platform-blog-post"
# For our three-tier architecture, we need credentials for each workspace's plan and apply phases
# That's 6 total credentials: 3 workspaces × 2 run phases (plan + apply)
# 1. Shared Services - Plan
az ad app federated-credential create \
--id $CLIENT_ID \
--parameters '{
"name": "tfc-shared-services-plan",
"issuer": "https://app.terraform.io",
"subject": "organization:'$TF_CLOUD_ORG':project:'$PROJECT_NAME':workspace:shared-services:run_phase:plan",
"description": "Terraform Cloud - shared-services workspace, plan phase",
"audiences": ["api://AzureADTokenExchange"]
}'
# 2. Shared Services - Apply
az ad app federated-credential create \
--id $CLIENT_ID \
--parameters '{
"name": "tfc-shared-services-apply",
"issuer": "https://app.terraform.io",
"subject": "organization:'$TF_CLOUD_ORG':project:'$PROJECT_NAME':workspace:shared-services:run_phase:apply",
"description": "Terraform Cloud - shared-services workspace, apply phase",
"audiences": ["api://AzureADTokenExchange"]
}'
# 3. Environment Test - Plan
az ad app federated-credential create \
--id $CLIENT_ID \
--parameters '{
"name": "tfc-env-test-plan",
"issuer": "https://app.terraform.io",
"subject": "organization:'$TF_CLOUD_ORG':project:'$PROJECT_NAME':workspace:env-test:run_phase:plan",
"description": "Terraform Cloud - env-test workspace, plan phase",
"audiences": ["api://AzureADTokenExchange"]
}'
# 4. Environment Test - Apply
az ad app federated-credential create \
--id $CLIENT_ID \
--parameters '{
"name": "tfc-env-test-apply",
"issuer": "https://app.terraform.io",
"subject": "organization:'$TF_CLOUD_ORG':project:'$PROJECT_NAME':workspace:env-test:run_phase:apply",
"description": "Terraform Cloud - env-test workspace, apply phase",
"audiences": ["api://AzureADTokenExchange"]
}'
# 5. App Test - Plan
az ad app federated-credential create \
--id $CLIENT_ID \
--parameters '{
"name": "tfc-app-test-plan",
"issuer": "https://app.terraform.io",
"subject": "organization:'$TF_CLOUD_ORG':project:'$PROJECT_NAME':workspace:app-test:run_phase:plan",
"description": "Terraform Cloud - app-test workspace, plan phase",
"audiences": ["api://AzureADTokenExchange"]
}'
# 6. App Test - Apply
az ad app federated-credential create \
--id $CLIENT_ID \
--parameters '{
"name": "tfc-app-test-apply",
"issuer": "https://app.terraform.io",
"subject": "organization:'$TF_CLOUD_ORG':project:'$PROJECT_NAME':workspace:app-test:run_phase:apply",
"description": "Terraform Cloud - app-test workspace, apply phase",
"audiences": ["api://AzureADTokenExchange"]
}'
Understanding the subject pattern:
The subject
field defines which Terraform Cloud runs can use this credential:
organization:<ORG>:project:<PROJECT>:workspace:<WORKSPACE>:run_phase:<PHASE>
organization
: Your Terraform Cloud org name (required, exact match)project
: Project name (exact match, no wildcards)workspace
: Workspace name (exact match, no wildcards)run_phase
: Eitherplan
orapply
(exact match, no wildcards)
Important: Wildcards (*
) are not supported in Azure federated credentials. Each workspace and run phase combination requires its own credential.
Security best practices:
- Development: Create credentials for both
plan
andapply
phases per workspace - Production: Consider creating only
apply
credentials to prevent unauthorized planning (though both are typically needed) - Multiple environments: Create separate service principals for dev, staging, and prod with appropriate Azure RBAC permissions
- Least privilege: Each service principal should only have access to resources in its environment
Example: Separate service principals for dev and prod:
# Dev service principal - for dev/test workspaces
SP_NAME_DEV="terraform-platform-dev-sp"
az ad sp create-for-rbac \
--name $SP_NAME_DEV \
--role Contributor \
--scopes "/subscriptions/$DEV_SUBSCRIPTION_ID"
CLIENT_ID_DEV=$(az ad sp list --display-name $SP_NAME_DEV --query "[0].appId" -o tsv)
# Create credentials for dev workspaces
az ad app federated-credential create \
--id $CLIENT_ID_DEV \
--parameters '{
"name": "tfc-env-dev-plan",
"subject": "organization:'$TF_CLOUD_ORG':project:platform-blog-post:workspace:env-dev:run_phase:plan",
"audiences": ["api://AzureADTokenExchange"]
}'
az ad app federated-credential create \
--id $CLIENT_ID_DEV \
--parameters '{
"name": "tfc-env-dev-apply",
"subject": "organization:'$TF_CLOUD_ORG':project:platform-blog-post:workspace:env-dev:run_phase:apply",
"audiences": ["api://AzureADTokenExchange"]
}'
# Prod service principal - for prod workspaces only
SP_NAME_PROD="terraform-platform-prod-sp"
az ad sp create-for-rbac \
--name $SP_NAME_PROD \
--role Contributor \
--scopes "/subscriptions/$PROD_SUBSCRIPTION_ID"
CLIENT_ID_PROD=$(az ad sp list --display-name $SP_NAME_PROD --query "[0].appId" -o tsv)
# Create credentials for prod workspaces
az ad app federated-credential create \
--id $CLIENT_ID_PROD \
--parameters '{
"name": "tfc-env-prod-plan",
"subject": "organization:'$TF_CLOUD_ORG':project:platform-blog-post:workspace:env-prod:run_phase:plan",
"audiences": ["api://AzureADTokenExchange"]
}'
az ad app federated-credential create \
--id $CLIENT_ID_PROD \
--parameters '{
"name": "tfc-env-prod-apply",
"subject": "organization:'$TF_CLOUD_ORG':project:platform-blog-post:workspace:env-prod:run_phase:apply",
"audiences": ["api://AzureADTokenExchange"]
}'
What’s happening here?
- Terraform Cloud requests a token from Azure AD when runs execute
- Azure validates the token matches the exact subject pattern (org/project/workspace/phase)
- No client secret needed - the trust is based on the issuer (Terraform Cloud) and the specific subject pattern
- Each workspace/phase combination needs its own federated credential (6 total for our 3 workspaces)
- Azure enforces exact matching - no wildcards are supported in the subject field
Credential naming convention:
Use descriptive names for easy management: tfc-<workspace>-<phase>
tfc-shared-services-plan
tfc-shared-services-apply
tfc-env-test-plan
tfc-env-test-apply
tfc-app-test-plan
tfc-app-test-apply
Scaling to multiple environments:
When you add more environments (dev, staging, prod), you’ll create additional credentials:
- For
env-dev
,env-staging
,env-prod
workspaces: 6 more credentials (3 workspaces × 2 phases) - For
app-myapp-dev
,app-myapp-staging
,app-myapp-prod
: 6 more credentials per app - Consider using separate service principals per environment for better security isolation
Step 3: Assign Azure Permissions
Grant the service principal appropriate permissions:
# Contributor at subscription level
az role assignment create \
--assignee $CLIENT_ID \
--role "Contributor" \
--scope "/subscriptions/$SUBSCRIPTION_ID"
# Optional: Add User Access Administrator if managing RBAC
az role assignment create \
--assignee $CLIENT_ID \
--role "User Access Administrator" \
--scope "/subscriptions/$SUBSCRIPTION_ID"
Step 4: Configure Terraform Cloud Variable Set
Instead of configuring variables individually for each workspace, we’ll create a Variable Set and apply it to the entire project. This makes it easy to share authentication credentials across all workspaces.
In Terraform Cloud UI:
- Navigate to Settings → Variable Sets → Create variable set
- Name it “Azure Authentication - Dev” (or your environment name)
- Add a description: “Azure service principal credentials for OIDC authentication”
- Add the following Environment Variables:
# Azure Authentication (Environment Variables)
ARM_SUBSCRIPTION_ID = "<subscription-id>"
ARM_TENANT_ID = "<tenant-id>" # From step 1
TFC_AZURE_PROVIDER_AUTH = "true" # Enable OIDC for Terraform Cloud
TFC_AZURE_RUN_CLIENT_ID = "<client-id>" # From step 1
- Apply the variable set to:
- Scope: Select “Apply to specific projects and workspaces”
- Projects: Choose your “platform-blog-post” project
- This automatically applies to ALL workspaces in the project (env-test, app-test, etc.)
Important notes:
- Mark these as Environment Variables, NOT Terraform variables
- You can mark
ARM_SUBSCRIPTION_ID
,ARM_TENANT_ID
, andTFC_AZURE_RUN_CLIENT_ID
as sensitive if desired TFC_AZURE_PROVIDER_AUTH
is the Terraform Cloud-specific variable that enables OIDC authenticationTFC_AZURE_RUN_CLIENT_ID
is the client ID of the Azure AD application/service principal
Benefits of Variable Sets:
- ✅ Configure once, apply to all workspaces in the project
- ✅ Easy to update credentials across all workspaces
- ✅ Can create separate variable sets for different environments (dev/staging/prod)
- ✅ Can scope to specific projects or workspaces as needed
For multiple environments:
You might create separate variable sets for different environments:
- “Azure Authentication - Dev” → Applied to dev-related projects
- “Azure Authentication - Staging” → Applied to staging projects
- “Azure Authentication - Prod” → Applied to prod projects (with different service principal)
Each variable set would use a different TFC_AZURE_RUN_CLIENT_ID
corresponding to the service principal created for that environment (with appropriate subject pattern scoping).
No ARM_CLIENT_SECRET
needed! 🎉
Infrastructure Code Organization 📁
Now let’s structure our Terraform code using a three-tier architecture that separates concerns by lifecycle and scope. For this tutorial, we’ll focus on the essential building blocks: Container Registry, App Service Plan, and Web App.
Note: For this tutorial, we’re keeping modules local within each infrastructure layer. In a future post, we’ll extract these modules into separate Git repositories and publish them to Terraform Cloud’s private registry for reuse across multiple projects.
File Organization: While Terraform allows you to put all configuration in a single .tf
file (Terraform loads all .tf
files in a directory), I prefer breaking it out into separate files (main.tf
, variables.tf
, outputs.tf
, terraform.tf
) for better organization and readability. This separation makes it easier to find specific configurations and follows common Terraform conventions.
We’ll create an infra
folder with three main layers:
infra/
├── shared-services/ # Cross-environment shared resources
│ ├── main.tf # Resource definitions
│ ├── variables.tf # Input variables
│ ├── outputs.tf # Output values
│ ├── terraform.tf # Terraform & provider config
│ └── modules/
│ └── container-registry/ # Azure Container Registry
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
├── environment/ # Environment-level infrastructure
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── terraform.tf
│ └── modules/
│ └── app-service-plan/ # Shared App Service Plans
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
└── app/ # Application-specific resources
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tf
└── modules/
└── web-app/ # App Service Web Apps
├── main.tf
├── variables.tf
└── outputs.tf
Three-tier structure explained:
- Shared Services (
infra/shared-services/
)- Resources used across all environments (dev, staging, prod)
- Deployed once and referenced by all other layers
- Example: Container Registry for storing Docker images
- Environment (
infra/environment/
)- Resources specific to an environment but shared across apps
- Deployed per environment (separate for dev, staging, prod)
- Example: App Service Plan (shared compute for multiple apps)
- App (
infra/app/
)- Resources for a specific application
- Deployed per app per environment
- Example: Web App instance with app-specific settings
Why this structure?
- Clear separation of concerns - Different lifecycles for different resource types
- Resource sharing - ACR shared across all environments, App Service Plan shared within an environment
- Independent deployments - Can update app without touching shared services
- Cost optimization - Share expensive resources (ACR, App Service Plans) where appropriate
Later, we can expand each layer with additional modules like Key Vault, Front Door, API Management, and Application Insights.
Note: We’ll dive deeper into this Azure resource organization strategy in our upcoming post on Azure environment architecture.
Layer 1: Shared Services Infrastructure
This contains resources shared across all environments - deployed once and used by dev, staging, and prod.
infra/shared-services/terraform.tf
terraform {
required_version = ">= 1.6"
cloud {
organization = "my-org"
workspaces {
name = "shared-services"
}
}
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.40"
}
}
}
provider "azurerm" {
features {}
# OIDC authentication - automatically detected from TFC_AZURE_PROVIDER_AUTH environment variable
}
infra/shared-services/variables.tf
variable "location" {
description = "Azure region for shared resources"
type = string
default = "westus2"
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {
ManagedBy = "Terraform"
Project = "Platform"
Layer = "Shared-Services"
}
}
infra/shared-services/main.tf
# Resource Group for shared services
resource "azurerm_resource_group" "shared" {
name = "rg-shared-services"
location = var.location
tags = var.tags
}
# Azure Container Registry - shared across all environments
module "container_registry" {
source = "./modules/container-registry"
resource_group_name = azurerm_resource_group.shared.name
location = azurerm_resource_group.shared.location
tags = var.tags
}
infra/shared-services/outputs.tf
output "resource_group_name" {
description = "Shared services resource group name"
value = azurerm_resource_group.shared.name
}
output "acr_id" {
description = "Container Registry ID"
value = module.container_registry.id
}
output "acr_login_server" {
description = "Container Registry login server"
value = module.container_registry.login_server
}
output "acr_name" {
description = "Container Registry name"
value = module.container_registry.name
}
Container Registry Module (infra/shared-services/modules/container-registry/main.tf
)
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
resource "azurerm_container_registry" "main" {
name = "acrshared${random_string.suffix.result}"
resource_group_name = var.resource_group_name
location = var.location
sku = "Basic"
admin_enabled = false
tags = var.tags
}
Container Registry Module Variables (infra/shared-services/modules/container-registry/variables.tf
)
variable "resource_group_name" {
description = "Resource group name"
type = string
}
variable "location" {
description = "Azure region"
type = string
}
variable "tags" {
description = "Resource tags"
type = map(string)
}
Container Registry Module Outputs (infra/shared-services/modules/container-registry/outputs.tf
)
output "id" {
description = "Container Registry ID"
value = azurerm_container_registry.main.id
}
output "login_server" {
description = "Container Registry login server"
value = azurerm_container_registry.main.login_server
}
output "name" {
description = "Container Registry name"
value = azurerm_container_registry.main.name
}
Deploying Shared Services
Now deploy the shared services layer:
cd infra/shared-services
terraform init
terraform plan
terraform apply # Add --auto-approve to skip confirmation prompt
# Capture outputs for later use
ACR_LOGIN_SERVER=$(terraform output -raw acr_login_server)
echo "ACR Login Server: $ACR_LOGIN_SERVER"
Layer 2: Environment Infrastructure
This contains resources specific to an environment but shared across applications within that environment.
infra/environment/terraform.tf
terraform {
required_version = ">= 1.6"
cloud {
organization = "my-org"
workspaces {
name = "env-test" # For tutorial; use env-dev, env-staging, env-prod for multiple environments
}
}
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.40"
}
}
}
provider "azurerm" {
features {}
}
infra/environment/variables.tf
variable "environment" {
description = "Environment name (test, dev, staging, prod)"
type = string
validation {
condition = contains(["test", "dev", "staging", "prod"], var.environment)
error_message = "Environment must be test, dev, staging, or prod."
}
}
variable "location" {
description = "Azure region for resources"
type = string
default = "westus2"
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {
ManagedBy = "Terraform"
Project = "Platform"
}
}
infra/environment/main.tf
# Reference shared services
data "terraform_remote_state" "shared" {
backend = "remote"
config = {
organization = "my-org"
workspaces = {
name = "shared-services"
}
}
}
# Resource Group for environment infrastructure
resource "azurerm_resource_group" "environment" {
name = "rg-${var.environment}"
location = var.location
tags = merge(var.tags, { Environment = var.environment })
}
# App Service Plan - shared compute for all apps in this environment
module "app_service_plan" {
source = "./modules/app-service-plan"
resource_group_name = azurerm_resource_group.environment.name
environment = var.environment
location = azurerm_resource_group.environment.location
tags = merge(var.tags, { Environment = var.environment })
}
infra/environment/outputs.tf
output "app_service_plan_id" {
description = "App Service Plan ID"
value = module.app_service_plan.id
}
output "app_service_plan_name" {
description = "App Service Plan name"
value = module.app_service_plan.name
}
# Pass through shared services outputs for convenience
output "acr_login_server" {
description = "Container Registry login server from shared services"
value = data.terraform_remote_state.shared.outputs.acr_login_server
}
App Service Plan Module (infra/environment/modules/app-service-plan/main.tf
)
resource "azurerm_service_plan" "main" {
name = "asp-${var.environment}"
resource_group_name = var.resource_group_name
location = var.location
os_type = "Linux"
sku_name = var.environment == "prod" ? "P1v3" : "P0v3"
tags = var.tags
}
App Service Plan Module Variables (infra/environment/modules/app-service-plan/variables.tf
)
variable "resource_group_name" {
description = "Resource group name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "location" {
description = "Azure region"
type = string
}
variable "tags" {
description = "Resource tags"
type = map(string)
}
App Service Plan Module Outputs (infra/environment/modules/app-service-plan/outputs.tf
)
output "id" {
description = "App Service Plan ID"
value = azurerm_service_plan.main.id
}
output "name" {
description = "App Service Plan name"
value = azurerm_service_plan.main.name
}
Deploying Environment Infrastructure
Deploy the environment layer (requires shared services to be deployed first):
cd ../environment
terraform init
terraform plan -var="environment=test"
terraform apply -var="environment=test" # Add --auto-approve to skip confirmation prompt
# Capture outputs
APP_SERVICE_PLAN_ID=$(terraform output -raw app_service_plan_id)
echo "App Service Plan ID: $APP_SERVICE_PLAN_ID"
Layer 3: Application Infrastructure
This contains resources for a specific application - deployed per app per environment.
infra/app/terraform.tf
terraform {
required_version = ">= 1.6"
cloud {
organization = "my-org"
workspaces {
name = "app-test" # For tutorial; use app-myapp-dev, app-myapp-staging, app-myapp-prod for multiple environments
}
}
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.40"
}
}
}
provider "azurerm" {
features {}
}
infra/app/variables.tf
variable "environment" {
description = "Environment name (test, dev, staging, prod)"
type = string
validation {
condition = contains(["test", "dev", "staging", "prod"], var.environment)
error_message = "Environment must be test, dev, staging, or prod."
}
}
variable "app_name" {
description = "Application name"
type = string
default = "myapp"
}
variable "location" {
description = "Azure region for resources"
type = string
default = "westus2"
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {
ManagedBy = "Terraform"
Project = "Platform"
}
}
infra/app/main.tf
# Reference shared services and environment infrastructure
data "terraform_remote_state" "shared" {
backend = "remote"
config = {
organization = "my-org"
workspaces = {
name = "shared-services"
}
}
}
data "terraform_remote_state" "environment" {
backend = "remote"
config = {
organization = "my-org"
workspaces = {
name = "env-${var.environment}"
}
}
}
# Resource Group for application
resource "azurerm_resource_group" "app" {
name = "rg-${var.environment}-${var.app_name}"
location = var.location
tags = merge(var.tags, {
Environment = var.environment
Application = var.app_name
})
}
# Web App
module "web_app" {
source = "./modules/web-app"
resource_group_name = azurerm_resource_group.app.name
app_name = var.app_name
environment = var.environment
location = azurerm_resource_group.app.location
service_plan_id = data.terraform_remote_state.environment.outputs.app_service_plan_id
acr_login_server = data.terraform_remote_state.shared.outputs.acr_login_server
tags = merge(var.tags, {
Environment = var.environment
Application = var.app_name
})
}
infra/app/outputs.tf
output "web_app_name" {
description = "Web App name"
value = module.web_app.name
}
output "web_app_url" {
description = "Web App URL"
value = module.web_app.url
}
output "web_app_default_hostname" {
description = "Web App default hostname"
value = module.web_app.default_hostname
}
Web App Module (infra/app/modules/web-app/main.tf
)
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
resource "azurerm_linux_web_app" "main" {
name = "app-${var.environment}-${var.app_name}-${random_string.suffix.result}"
resource_group_name = var.resource_group_name
location = var.location
service_plan_id = var.service_plan_id
site_config {
always_on = var.environment == "prod" ? true : false
application_stack {
docker_image_name = "${var.acr_login_server}/${var.app_name}:latest"
docker_registry_url = "https://${var.acr_login_server}"
}
}
identity {
type = "SystemAssigned"
}
tags = var.tags
}
# Get ACR ID for role assignment
data "azurerm_container_registry" "acr" {
name = split(".", var.acr_login_server)[0]
resource_group_name = "rg-shared-services"
}
# Grant Web App access to pull images from ACR
resource "azurerm_role_assignment" "acr_pull" {
principal_id = azurerm_linux_web_app.main.identity[0].principal_id
role_definition_name = "AcrPull"
scope = data.azurerm_container_registry.acr.id
skip_service_principal_aad_check = true
}
Web App Module Variables (infra/app/modules/web-app/variables.tf
)
variable "resource_group_name" {
description = "Resource group name"
type = string
}
variable "app_name" {
description = "Application name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "location" {
description = "Azure region"
type = string
}
variable "service_plan_id" {
description = "App Service Plan ID"
type = string
}
variable "acr_login_server" {
description = "Container Registry login server"
type = string
}
variable "tags" {
description = "Resource tags"
type = map(string)
}
Web App Module Outputs (infra/app/modules/web-app/outputs.tf
)
output "id" {
description = "Web App ID"
value = azurerm_linux_web_app.main.id
}
output "name" {
description = "Web App name"
value = azurerm_linux_web_app.main.name
}
output "default_hostname" {
description = "Web App default hostname"
value = azurerm_linux_web_app.main.default_hostname
}
output "url" {
description = "Web App URL"
value = "https://${azurerm_linux_web_app.main.default_hostname}"
}
Deploying Application Infrastructure
Deploy the application layer (requires both shared services and environment to be deployed):
cd ../app
terraform init
terraform plan -var="environment=test" -var="app_name=myapp"
terraform apply -var="environment=test" -var="app_name=myapp" # Add --auto-approve to skip confirmation prompt
# Get the app URL
APP_URL=$(terraform output -raw web_app_url)
echo "App deployed at: $APP_URL"
Common Patterns 📋
Environment-Specific Configurations
Use workspace variables for environment-specific values:
# In Terraform Cloud, set these as Terraform variables:
# workspace: app-dev
environment = "dev"
location = "westus2"
sku_tier = "Basic"
# workspace: app-prod
environment = "prod"
location = "westus2"
sku_tier = "Standard"
Cross-Workspace Data Sharing
Use terraform_remote_state
to reference outputs from other workspaces:
# App infrastructure needs data from shared services and environment
data "terraform_remote_state" "shared" {
backend = "remote"
config = {
organization = "my-org"
workspaces = {
name = "shared-services"
}
}
}
data "terraform_remote_state" "environment" {
backend = "remote"
config = {
organization = "my-org"
workspaces = {
name = "env-${var.environment}"
}
}
}
# Use outputs from other workspaces
acr_id = data.terraform_remote_state.shared.outputs.acr_id
app_service_plan = data.terraform_remote_state.environment.outputs.app_service_plan_id
front_door_id = data.terraform_remote_state.environment.outputs.front_door_id
This pattern creates clear dependencies:
- Apps depend on Environment and Shared Services
- Environment depends on Shared Services
- Shared Services has no dependencies (deployed first)
Troubleshooting 🔧
Missing Variable Configuration
If you get this error during terraform plan
:
Error: unable to build authorizer for Resource Manager API: could not configure AzureCli Authorizer:
could not parse Azure CLI version: launching Azure CLI: exec: "az": executable file not found in $PATH
This means your Terraform Cloud environment variables are not configured correctly. Terraform is trying to fall back to Azure CLI authentication because it can’t find the OIDC credentials.
Fix:
- Go to Terraform Cloud → Settings → Variable Sets
- Verify your variable set has these Environment Variables (not Terraform variables):
ARM_SUBSCRIPTION_ID
= Your Azure subscription IDARM_TENANT_ID
= Your tenant IDTFC_AZURE_PROVIDER_AUTH
="true"
(as a string)TFC_AZURE_RUN_CLIENT_ID
= Your service principal client ID
- Ensure the variable set is applied to your project or workspace
- Run
terraform plan
again
Note: These must be Environment Variables, not Terraform variables. The category must be set to “env” in Terraform Cloud.
Remote State Access Denied
If you get this error when trying to access remote state from another workspace:
Error: Error loading state: state data in S3 does not have the expected content.
This Terraform run is not authorized to read the state of the workspace 'shared-services'.
Most commonly, this is required when using the terraform_remote_state data source.
To allow this access, 'shared-services' must configure this workspace ('env-test')
as an authorized remote state consumer.
This means the workspace you’re trying to read state from hasn’t granted your workspace permission to access its state.
Fix:
- Go to Terraform Cloud → Navigate to the source workspace (e.g.,
shared-services
) - Go to Settings → General
- Scroll down to Remote state sharing
- Select Share with specific workspaces
- Add the workspace(s) that need access (e.g.,
env-test
,app-test
) - Click Save settings
- Run
terraform plan
again in your consuming workspace
For our three-tier architecture, configure these permissions:
- shared-services workspace: Grant access to
env-test
andapp-test
- env-test workspace: Grant access to
app-test
Example configuration:
Workspace: shared-services
├─ Remote state sharing: Share with specific workspaces
├─ Allowed workspaces:
│ ├─ env-test ✓
│ └─ app-test ✓
Workspace: env-test
├─ Remote state sharing: Share with specific workspaces
├─ Allowed workspaces:
│ └─ app-test ✓
Alternative: Global sharing (not recommended for production):
You can also choose Share with all workspaces in this organization, but this is less secure as it allows any workspace to read the state. Use specific workspace sharing for better security.
OIDC Authentication Issues
If you get authentication errors:
# Verify service principal exists
az ad sp show --id $CLIENT_ID
# Check federated credential
az ad app federated-credential list --id $CLIENT_ID
# Verify workspace variables
# In Terraform Cloud UI, check:
# - ARM_SUBSCRIPTION_ID is set
# - ARM_TENANT_ID is set
# - TFC_AZURE_PROVIDER_AUTH is "true"
# - TFC_AZURE_RUN_CLIENT_ID is set
State Locking Issues
If state is locked:
# View lock info in Terraform Cloud UI
# Or force unlock (use with caution!)
terraform force-unlock <LOCK_ID>
Cleaning Up Resources 🧹
Important: If you’re just testing this setup and don’t want to incur ongoing Azure charges, make sure to destroy the infrastructure when you’re done. Destroy in reverse order of deployment:
# Step 1: Destroy application infrastructure first
cd infra/app
terraform destroy -var="environment=test" -var="app_name=myapp" --auto-approve
# Step 2: Destroy environment infrastructure
cd ../environment
terraform destroy -var="environment=test" --auto-approve
# Step 3: Destroy shared services last
cd ../shared-services
terraform destroy --auto-approve
Why destroy in reverse order?
- The app layer depends on the environment layer (App Service Plan)
- The environment layer depends on shared services (Container Registry)
- Destroying in reverse ensures dependencies are removed before their dependencies
- Terraform will error if you try to destroy a resource that’s still being referenced
Cost considerations:
- Container Registry (Basic SKU): ~$5/month
- App Service Plan (P0v3): ~$58/month
- Web App: Included with App Service Plan
Total estimated cost for this tutorial setup: ~$63/month if left running.
Next Steps 🚀
We’ve now set up:
- ✅ Terraform Cloud with secure Azure authentication
- ✅ Three-tier infrastructure organization (shared-services, environment, app)
- ✅ CLI-driven workflows for better developer experience
Coming up in the series:
- Publishing Terraform Modules - Extracting our local modules into separate Git repositories and publishing them to Terraform Cloud’s private registry for reuse across projects
- Complete Platform Infrastructure - Building out the full infrastructure with all the modules (Key Vault, Front Door, API Management, Application Insights, and more) using our published modules
- Azure Environment Architecture - Deep dive into our three-tier resource organization strategy (shared services, environment resources, and application workspaces) and why this pattern works well for multi-environment platforms
- Azure DevOps Pipelines - Automating infrastructure deployment and application CI/CD workflows
Resources 📚
- Terraform Cloud Documentation
- Azure Provider Documentation
- Azure Charts Resource Classification
- Workload Identity Federation
- Module Registry Best Practices
Questions? Want to discuss Terraform patterns? Find me on LinkedIn or GitHub!
More
Recent Posts
- » Building a Modern Development Platform: Terraform & Terraform Cloud for Azure Infrastructure 🏗️
- » Building a Modern Development Platform: TypeSpec for Contract-First API Development 📋
- » Building a Modern Development Platform: Aspire for Local Development
- » Building a Modern Development Platform: Tool Selection 🛠️
- » Building a Modern Development Platform: From Legacy .NET Framework to Cloud-Native 🚀