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
  }
}
Tip

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.

SourcePriorityNotes
Default value in variables.tfLowestFallback when nothing else provides a value
terraform.tfvarsLowAutomatically loaded if present
*.auto.tfvarsMediumAutomatically loaded alphabetically
-var-file flagHighExplicitly passed file
-var flagHighestCommand-line override
TF_VAR_ environment variablesHighUsed in CI/CD for secrets

Common mistakes

  1. 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.
  2. Sharing state between environments. Using a single state file for all environments means a terraform destroy in the wrong environment could affect production. Always use separate state keys or separate storage containers per environment.
  3. 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.

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.

Last verified: 19 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.