When I started using Terraform, I didn’t really have a clear structure in place. I tried to force everything into modules way too early, and ended up with a “main.tf” file that was hundreds of lines long. It became hard to navigate, hard to maintain, and easy to make mistakes. Over time, I learned how helpful a clean, modular, file structure can be – not just for readability, but also for collaboration and scaling infrastructure efficiently.
In this post, I want to share a basic Terraform project structure which you could use as a starting point. It’s simple, flexible, and gives you a solid first foundation to build on. Whether you’re working solo or with a team, this structure helps you keep things organized and clean.
Contents
ποΈ Why a clean File Structure Matters
As your infrastructure grows, a clear and consistent structure helps you:
- Understand what’s happening at glance
- Reuse components more easily
- Avoid duplication
- Scale across environments (dev / staging / prod)
Here is an example file layout for a basic Terraform project:
terraform-azure-template/
βββ backend.tf
βββ provider.tf
βββ variables.tf
βββ terraform.tfvars
βββ locals.tf
βββ main.tf
βββ network.tf
βββ outputs.tf
π File-by-File Breakdown
backend.tf – Remote State Configuration
The backend.tf file defines where Terraform stores its state file.
π§ What is a state file?
Terraform keeps track of all the resources it manages in a file called terraform.tfstate. This file stores information about resource names, IDs, dependencies and current configurations. It is essential because Terraform compares your current infrastructure against the state file to determine what needs to be created, changed or destroyed.
By default, this file is stored locally, but this can be problematic – especially for teams:
- it’s not locked, so concurrent changes can corrupt the file
- it’s easy to misplace or forget to back up
- it doesn’t scale for collaborative work
That’s why defining a remote backend (like Azure Blob Storage, AWS S3 or Terraform Cloud) is a best practice. It ensures:
- centralized storage
- state locking
- version history
- safer collaboration
π‘ Best practice: Always set up a remote backend early in your project. It’s harder to migrate later when your infrastructure grows.
terraform {
backend "azurerm" {
subscription_id = "subscriptionid"
resource_group_name = "tfstate_rg"
storage_account_name = "tfstatestorageaccount001"
container_name = "tfstatefilesblob"
key = "projectname.tfstate"
use_azuread_auth = true
}
}
provider.tf – Provider declaration
Specifies the cloud provider and version. Without this, Terraform doesn’t know how to connect to Azure.
βοΈThere are many more Terraform providers than just AWS, Azure and GCP (https://registry.terraform.io/browse/providers)
terraform {
required_providers {
source = "hashicorp/azurerm"
version = "Λ= 3.100"
}
}
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = true // example
}
}
subscription_id = "<subscriptionid>"
}
π Tip: Lock the provider version to avoid breaking changes between updates.
variables.tf – Input Variables
Defines the input variables your configuration expects. This makes your project reusable and environment-agnostic.
variable "project_name" {
description = "Name of the project"
type = string
default = "TestProject"
}
variable "resourcegroup_name" {
description = "Name of the resourcegroup"
type = string
default = "TestRG"
}
variable "location" {
description = "Defines the location"
type = string
default = "westeurope"
}
variable "tags" {
description = "Define default tags"
type = map(string)
}
// many many more
terraform.tfvars – Variable Values
This file assigns values to variables defined in variables.tf. It keeps your config clean and makes switching environments easy.
project_name = "ProjectOne"
resourcegroup_name = "ProjectOne-rg"
location = "germanywestcentral"
tags = {
"Author" = "Simon Vedder"
"Contact" = "info@simonvedder.com"
}
β Best practice: Do not hardcode values in .tf files β use terraform.tfvars or CLI -var overrides.
locals.tf – Local Values
Locals make your Terraform configuration simpler and more organized by letting you assign names to values or expressions you use often.
locals {
local_location = "westus"
vnet_name = "default"
}
main.tf – Core Logic
This is the entry point for your Terraform code. You can split resources into modules (which we will discuss in another post) or dedicated files, but main.tf often coordinates everything.
// data block means that terraform look for the defined resource in the existing azure environment - this block will not create anything, just input
data "azurerm_subscription" "this" {
}
// resource block will create the defined resource
resource "azurerm_resource_group" "this" {
name = var.resourcegroup_name
location = local.local_location
tags = var.tags
}
// (optional) you can outsource any resource in other tf-files in the same directory like
// e.g. network.tf - contains vnet, subnet, ...
// e.g. storage.tf - contains storage account, blob container, ...
// e.g. logging.tf - contains log analytics workspace, diagnostic settings, ...
// e.g. automatin.tf - contains logic apps, automation accounts, ...
e.g. network.tf – Modular Resource File
Itβs a good practice to break large configs into logical pieces like network.tf, compute.tf, database.tf, etc.
resource "azurerm_virtual_network" "this" {
name = local.vnet_name
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
address_space = ["10.0.0.0/16"]
tags = var.tags
// depends on - resource creation will wait for a successful deployment of the defined resources - e.g. resource group
depends_on = [azurerm_resource_group.this]
}
π‘ Tip: Use filenames like network.tf to organize related resources.
outputs.tf – Useful Outputs
Displays information after a successful terraform apply. These values can be used in other modules or simply for reference.
output "resourcegroup_id" {
description = "ID of RG"
value = azurerm_resource_group.this.id
}
output "vnet_ip" {
description = "Addressspace of Vnet"
value = azurerm_virtual_network.this.address_space
}
π― Bonus Tips for Beginners
- β Use meaningful tags like Author, Contact, and Environment to improve resource discoverability.
- β Use terraform fmt to auto-format code.
- β Use terraform validate to check syntax before applying changes.
- β Commit your code to version control, but add .terraform and .tfstate* to .gitignore.
- β Use modules as soon as your code grows β itβll save you headaches down the line.
π Conclusion
Setting up a structured and reusable Terraform project from the beginning will save you time, reduce bugs, and help you scale your infrastructure with confidence. Feel free to use the example above as a template for your own projects, and add more files like security.tf, monitoring.tf, or custom modules as your needs grow.
Do you have suggestions or improvements? Drop me a message or open a pull request on GitHub! π