Terraform Project Structure for Azure
A well-structured Terraform project is one you can navigate six months from now without asking the person who wrote it. Good structure separates concerns — providers from resources, variables from outputs, reusable modules from environment-specific configuration. This page shows the folder layouts and patterns that work well for Azure projects of different sizes.
Starting point: flat structure for small projects
For a single-environment project or a learning exercise, a flat structure with a few files in one directory is perfectly reasonable. You can always refactor later.
my-azure-project/
├── .gitignore
├── providers.tf # terraform {} block and provider "azurerm" {}
├── variables.tf # variable declarations with types and descriptions
├── main.tf # resource blocks
├── outputs.tf # output blocks
└── terraform.tfvars # variable values (do not commit if it contains secrets)
The .gitignore for any Terraform project should always include:
# .gitignore for Terraform projects
.terraform/
terraform.tfstate
terraform.tfstate.backup
*.tfstate
*.tfstate.*
*.tfvars # if they contain secrets
.terraform.lock.hcl # sometimes committed, sometimes not — pick one and be consistent
crash.log
override.tf
Multi-environment structure: separate directories
When you need separate dev, staging, and prod environments, the cleanest pattern is a directory per environment. Each environment directory has its own state file, its own variable values, and its own backend configuration. There is no risk of a misconfigured workspace applying to the wrong environment.
infra/
├── modules/
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── app-service/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── dev/
│ │ ├── providers.tf
│ │ ├── main.tf # calls modules with dev-specific values
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── backend.tf # state stored in dev storage account
│ ├── staging/
│ │ ├── providers.tf
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── backend.tf
│ └── prod/
│ ├── providers.tf
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── backend.tf
└── .gitignore
Writing a reusable networking module
A module is just a Terraform directory. The module’s variables.tf defines what callers must provide. The module’s outputs.tf defines what callers can use from it. The main.tf contains the resources.
# modules/networking/variables.tf
variable "resource_group_name" {
type = string
description = "Name of the resource group to deploy into"
}
variable "location" {
type = string
description = "Azure region"
}
variable "vnet_name" {
type = string
description = "Name of the virtual network"
}
variable "vnet_address_space" {
type = list(string)
description = "Address space for the virtual network"
default = ["10.0.0.0/16"]
}
variable "subnets" {
type = map(object({
address_prefix = string
}))
description = "Map of subnet names to their address prefixes"
}
variable "tags" {
type = map(string)
description = "Tags to apply to all resources"
default = {}
}
# modules/networking/main.tf
resource "azurerm_virtual_network" "this" {
name = var.vnet_name
resource_group_name = var.resource_group_name
location = var.location
address_space = var.vnet_address_space
tags = var.tags
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [each.value.address_prefix]
}
resource "azurerm_network_security_group" "this" {
name = "${var.vnet_name}-nsg"
resource_group_name = var.resource_group_name
location = var.location
tags = var.tags
}
# modules/networking/outputs.tf
output "vnet_id" {
value = azurerm_virtual_network.this.id
description = "ID of the virtual network"
}
output "vnet_name" {
value = azurerm_virtual_network.this.name
description = "Name of the virtual network"
}
output "subnet_ids" {
value = { for k, v in azurerm_subnet.this : k => v.id }
description = "Map of subnet names to their IDs"
}
output "nsg_id" {
value = azurerm_network_security_group.this.id
description = "ID of the network security group"
}
Calling the module from an environment
# environments/dev/main.tf
locals {
environment = "dev"
location = "East US"
prefix = "myapp-${local.environment}"
common_tags = {
environment = local.environment
managed_by = "terraform"
project = "myapp"
}
}
resource "azurerm_resource_group" "main" {
name = "${local.prefix}-rg"
location = local.location
tags = local.common_tags
}
module "networking" {
source = "../../modules/networking"
resource_group_name = azurerm_resource_group.main.name
location = local.location
vnet_name = "${local.prefix}-vnet"
vnet_address_space = ["10.1.0.0/16"]
subnets = {
"app-subnet" = { address_prefix = "10.1.1.0/24" }
"db-subnet" = { address_prefix = "10.1.2.0/24" }
}
tags = local.common_tags
}
module "app_service" {
source = "../../modules/app-service"
resource_group_name = azurerm_resource_group.main.name
location = local.location
name = "${local.prefix}-app"
subnet_id = module.networking.subnet_ids["app-subnet"]
sku_name = "B1" # dev uses a cheaper SKU
tags = local.common_tags
}
# environments/prod/main.tf — same structure, different values
locals {
environment = "prod"
location = "West US 2"
prefix = "myapp-${local.environment}"
common_tags = {
environment = local.environment
managed_by = "terraform"
project = "myapp"
}
}
# ... same module calls but with:
# vnet_address_space = ["10.3.0.0/16"]
# sku_name = "P2v3" # prod uses a higher-tier SKU
Configuring a separate backend per environment
Each environment directory has its own backend.tf that points to a different storage container. This ensures dev and prod state files are completely isolated — a mistake in dev cannot corrupt prod state.
# environments/dev/backend.tf
terraform {
backend "azurerm" {
resource_group_name = "terraform-state-rg"
storage_account_name = "tfstatemyapp"
container_name = "tfstate"
key = "dev/terraform.tfstate"
}
}
# environments/prod/backend.tf
terraform {
backend "azurerm" {
resource_group_name = "terraform-state-rg"
storage_account_name = "tfstatemyapp"
container_name = "tfstate"
key = "prod/terraform.tfstate" # different key
}
}
Run terraform init separately in each environment directory. Each directory has its own .terraform/ folder and its own lock file. This is not wasteful — it ensures the exact provider versions used in each environment are tracked independently.
Variable value precedence
Terraform evaluates variable values in this order, from lowest to highest priority. Higher priority sources override lower ones.
| Source | Priority | Notes |
|---|---|---|
Default value in variables.tf | Lowest | Fallback when nothing else provides a value |
terraform.tfvars | Low | Automatically loaded if present |
*.auto.tfvars | Medium | Automatically loaded alphabetically |
-var-file flag | High | Explicitly passed file |
-var flag | Highest | Command-line override |
TF_VAR_ environment variables | High | Used in CI/CD for secrets |
Common mistakes
- Putting all resources in one giant main.tf. A 2,000-line main.tf becomes unreadable. Group resources by logical concern into separate files (networking.tf, compute.tf, database.tf) within the same directory. Terraform reads them all as one configuration.
- Sharing state between environments. Using a single state file for all environments means a
terraform destroyin the wrong environment could affect production. Always use separate state keys or separate storage containers per environment. - Making modules too granular. A module that wraps a single resource type adds boilerplate without value. Create modules at the level of meaningful infrastructure units — a “web tier” module that creates an App Service Plan, App Service, and their associated networking makes sense. A module that just creates an NSG usually does not.
Summary
- Small single-environment projects work fine with a flat file structure: providers.tf, variables.tf, main.tf, outputs.tf.
- For multi-environment projects, use separate directories per environment, each with its own backend configuration and state file.
- Modules live in a shared
modules/directory and are called from environment-specific main.tf files with different variable values. - Module inputs are defined in
variables.tf, module outputs inoutputs.tf, and resources inmain.tf. - Use
for_eachwith maps to create multiple similar resources from a single resource block, keeping configuration DRY.
Frequently asked questions
What is a Terraform module?
A module is a directory of Terraform files that can be called from another Terraform configuration. Modules accept input variables and return outputs, letting you create reusable infrastructure components — for example, a networking module that always creates a VNet, subnets, and an NSG together.
How do I manage multiple environments (dev, staging, prod) with Terraform?
The most reliable pattern is separate directories — one per environment — each with their own state file and their own variable values. Terraform workspaces are an alternative but add complexity around state isolation. For most teams, separate directories are simpler and safer.
Should I put everything in one main.tf or split into multiple files?
Splitting is strongly recommended. Keeping resources.tf, variables.tf, outputs.tf, and providers.tf as separate files makes the configuration much easier to navigate. Terraform reads all .tf files in a directory as a single configuration regardless of filename.