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: Eitherplanorapply(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
planandapplyphases per workspace - ๐ Production: Consider creating only
applycredentials 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-plantfc-shared-services-applytfc-env-test-plantfc-env-test-applytfc-app-test-plantfc-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-prodworkspaces: 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_IDas sensitive if desired TFC_AZURE_PROVIDER_AUTHis the Terraform Cloud-specific variable that enables OIDC authenticationTFC_AZURE_RUN_CLIENT_IDis 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 planagain
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 planagain in your consuming workspace
For our three-tier architecture, configure these permissions:
- shared-services workspace: Grant access to
env-testandapp-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.
Cost Comparison: App Service vs Azure Static Web Apps ๐ฐ
If youโre wondering whether this architecture is right for your use case, hereโs a comparison with Azure Static Web Apps - a lightweight alternative for static content and simple applications:
App Service Architecture (This Tutorial)
| Resource | SKU | Monthly Cost |
|---|---|---|
| ๐ณ Container Registry (Basic) | Basic | $5.00 |
| โ๏ธ App Service Plan (Linux) | P0v3 | $58.00 |
| ๐ Web App | Included with Plan | $0.00 |
| Total | ย | ~$63/month |
Use when:
- โ You need backend API capabilities (Node.js, .NET, Python, Java, etc.)
- โ Running containerized applications
- โ Need traditional app server architecture
- โ Require stateful applications
- โ Need VNet integration or advanced networking
- โ Running long-running processes or background jobs
Azure Static Web Apps Alternative
| Resource | Tier | Monthly Cost |
|---|---|---|
| ๐ Static Web App | Free Tier | $0.00 |
| ๐ง Functions (Serverless Backend) | Consumption | ~$5-15 |
| ๐๏ธ Static Content (100GB/mo included) | Free | $0.00 |
| Total | ย | ~$5-15/month |
Use when:
- โ Hosting static sites (documentation, blogs, SPAs)
- โ Simple serverless backends (Azure Functions)
- โ No need for traditional app servers
- โ Want minimal operational overhead
- โ Cold-start latency is acceptable (functions)
- โ Infrequent traffic patterns
Key Differences:
| Feature | App Service | Static Web Apps |
|---|---|---|
| Startup Cost | ~$63/month | ~$0-15/month |
| Backend Runtime | Full runtimes (24/7) | Serverless (on-demand) |
| Deployment | Containers or direct code | Git integration or Static files |
| Always-On | Yes (default) | No (pay per execution) |
| State Management | Supported | Limited |
| Scaling | App Service Plan based | Automatic serverless |
| Cold Start Penalty | None | Functions: 2-5 seconds |
Decision Matrix:
Choose App Service if you need:
- Traditional backend APIs
- High request volume (>1M requests/month)
- Stateful applications
- Always-on availability
- Complex business logic
Choose Static Web Apps if you need:
- Static content hosting
- Lightweight APIs
- Bursty traffic patterns
- Cost optimization focus
- Simple architectures
Hybrid Approach: Many teams use both:
- Static Web Apps for documentation sites and marketing pages (~$5-15/month)
- App Service for production APIs and services (~$63+/month per environment)
For this tutorial, weโre using App Service because weโre building a full platform infrastructure that will eventually run backend services. In a future post covering documentation deployment, weโll demonstrate Static Web Apps for static site hosting with Front Door.
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!