23

Getting Started with Terraform – File Structure & Best Practices

When I started using Terraform, I didn’t really have a clear structure in place. I tried to force everything into…

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.

πŸ—„οΈ 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! 😊

Simon

Cloud Engineer focused on Azure, Terraform & PowerShell. Passionate about automation, efficient solutions, and sharing real-world cloud projects and insights. βΈ» Let me know if you’d like to make it more casual, technical, or personalized.

Leave a Reply

Your email address will not be published. Required fields are marked *