Terraform files: How to structure, format, and scale your configuration
Clean Terraform file organization is good practice, but it only takes you so far. Once your infrastructure grows, the real bottleneck is not how your files are laid out, it's how state is stored and locked underneath them.
One of the first questions most Terraform users have is how to organize their configuration files. The answer starts with the basics: main.tf, variables.tf, outputs.tf, provider configuration, variable values, and modules.
They make a Terraform project easier to read, easier to review in pull requests, and easier to move between a development environment and a production environment without losing track of intent.
However, file organization is only the visible layer. Once infrastructure grows, the challenge is no longer making sure Terraform code files are split into the right names or stored in the right directory structure. The challenge becomes dealing with the fact that Terraform writes the resource tracking layer underneath those files into state, and that state becomes the coordination point every terraform plan, terraform apply, and terraform destroy must pass through.
This guide explains what Terraform files are, how to organize them and why, as you scale, even the best file structure has problems, but problems that can be solved.
What are Terraform files?
What are Terraform files? Terraform files are plain text configuration files, usually written with the .tf extension, that use HashiCorp Configuration Language to declare infrastructure resources in a declarative style.
Instead of writing a script that says how to create an object step by step, you write Terraform configurations that describe the desired result, and Terraform evaluates the configuration, builds a dependency graph, compares it with existing infrastructure, and decides what must change.
Terraform also supports .tf.json, a JSON-based variant of the same language, although HCL is the standard format most humans write.
The important detail is that Terraform treats all top-level configuration files in a directory as one single configuration. Splitting a Terraform directory into multiple files is for human readability, not for changing execution behavior.
Terraform loads the .tf and .tf.json files in the module, evaluates them as one document, and uses references and resource dependencies rather than file order to decide what can be created, updated, or destroyed.
Nested directories are treated as separate modules, meaning Terraform does not automatically include them just because they sit under the same git repository.
Just be conscious that the Terraform file function is not a Terraform file type. It reads file content from a given path and returns it as a string, which is useful for things like templates or public keys, but is not the same thing as a tf file, and functions do not participate in the dependency graph during a Terraform operation.
A Terraform project starts with familiar file types
A small Terraform project often begins as a single file. A single file can be enough for a test account, a tiny service, or a short-lived prototype. The moment other people need to read it, review it, or reuse it across multiple environments, it's time to make sure you know the usual file types.
main.tf
main.tf is the primary configuration file in many Terraform projects. It's usually where resource blocks and data sources live, which means it tells Terraform what infrastructure to create, modify, or destroy.
In a small root module, it may contain most of the Terraform code. In a larger project, teams often split networking, storage, compute resources, and other logical groups into separate code files so a maintainer can find a resource without scanning a giant document.
HashiCorp's style guide recommends main.tf for resource and data source blocks, while also acknowledging that larger codebases may need files such as network.tf, storage.tf, and compute.tf for readability.
variables.tf
variables.tf defines the input variables your configuration accepts. It's the interface of the module, not the place where most variable values are assigned.
A good variable declaration includes a type and a description, and sensitive data such as passwords or API keys should be marked with the sensitive argument so they don't appear in normal CLI output – although Terraform can still store sensitive values in state unless newer ephemeral behavior is used carefully.
outputs.tf
outputs.tf defines the output values the module exposes after an apply, such as resource IDs, ARNs, service endpoints, IP addresses, or values needed by other modules.
Outputs are useful for operators, for downstream automation, and for parent modules that need to consume values from child modules. Keep output blocks in outputs.tf, adding descriptions, and ordering them predictably so review diffs stay boring.
providers.tf
providers.tf usually contains provider configuration for AWS, Google Cloud, Azure, Kubernetes, and other provider plugins.
Some teams also keep required provider versions, the required Terraform version, and backend configuration nearby.
Small projects often combine these blocks into main.tf, which is not wrong. Separating them becomes useful when multiple regions, multiple accounts, provider aliases, or backend configuration choices start to obscure the actual infrastructure definitions.
terraform.tfvars and other .tfvars files
A tfvars file assigns values to variables declared in variables.tf. It's where instance_type, region, environment, feature flags, or other variable values become concrete for a given run.
Terraform automatically loads terraform.tfvars, terraform.tfvars.json, and files ending in .auto.tfvars or .auto.tfvars.json, while other .tfvars files can be applied explicitly with -var-file.
As a result, multi-environment projects often keep files such as prod.tfvars and staging.tfvars, then run a command that points Terraform at the right values for the production environment or staging environment.
terraform.tfstate
terraform.tfstate is not a file that engineers author, but it is the file that eventually matters most.
Terraform uses state to map real-world resources to the resource instances declared in your configuration, store metadata, and determine which changes to make before an operation.
By default, Terraform stores state locally as terraform.tfstate and writes a backup file named terraform.tfstate.backup, but you should consider using a remote backend for collaboration and avoid storing state in a version control system, because state can expose secrets, and version control systems do not provide the right locking and access controls.
Terraform formatting should be boring
The best answer to how to format Terraform files is to run terraform fmt and then write the remaining code in the usual HashiCorp style.
terraform fmt rewrites Terraform configuration file contents into the canonical format and style, applying a subset of the Terraform language style conventions plus small readability adjustments.
It normalizes indentation, spacing, and alignment, but does not validate provider schemas, prove that the configuration is correct, or change the logic of the infrastructure you are managing.
By default, terraform fmt scans the current directory.
The -recursive flag processes files in subdirectories too, which is relevant when a repository contains local Terraform modules under modules/<name> or multiple environment directories.
The -check flag tests whether the input is already formatted, returns exit status 0 when it is, and returns a non-zero exit status with the names of unformatted files when it is not, making it useful as a CI/CD gate before a pull request can merge.
terraform fmt is necessary, but you should do the following as well:
- Run
terraform fmtandterraform validatebefore committing - Use
#for comments - Name resources with descriptive nouns
- Use underscores for multi-word names
- Define dependent resources after the resources they reference
- Include a type and description for every variable
- Add a description for every output
The same guide recommends running terraform fmt before each commit, and Git pre-commit hooks can make that automatic instead of relying on memory.
Project structure changes as the root module grows
A simple root module usually looks like this.
That layout is enough when one team owns the whole Terraform project and the resource count is small. It keeps the top-level configuration files obvious, gives input variables a single home, keeps output values discoverable, and avoids turning every small configuration into architecture theater.
As soon as the same Terraform code needs to provision infrastructure for multiple environments, the directory structure usually changes.
This structure makes each environment directory its own root module, with its own backend configuration and its own state. The shared modules/ directory holds reusable infrastructure patterns, and the environment directories call those modules with different values.
Terraform will not automatically include nested directories, so placing a directory under modules/ does nothing by itself. The root module must call the child module explicitly with a module block.
Early project structure decisions are hard to undo because they shape state boundaries, CI/CD pipelines, ownership, and review habits. A repository can start as one root module and later be split into separate repositories, separate workspaces, or separate environment directories, but every move introduces migration work.
A clean layout should be boring, it just needs to make ownership, variable values, provider versions, backend configuration, and resource dependencies easy to reason about before scale forces the question.
Modules turn repeated patterns into reusable units
A Terraform module is a directory of .tf or .tf.json files treated as a reusable unit. The directory where Terraform runs is the root module. Any module called from that root module is a child module, and child modules can be sourced from local directories, a separate repository, the public Terraform Registry, a private module registry, or other supported sources.
A module block tells Terraform to create resources defined somewhere else.
At this point, Terraform modules start to go beyond just tidy file organization.
A good module captures a repeatable infrastructure pattern, such as a VPC, a database, a queue, or an application deployment, and gives consumers a smaller interface through input variables and output values.
A weak module does the opposite. It hides too much behavior, exposes too many inputs, and turns every edge case into another variable. Local modules under modules/<name> are convenient inside one git repository, while registry modules are better when teams need versioned sharing across multiple configurations. Publish reusable modules to a module registry when teams need versioning, sharing, and reuse across an organization.
Good file structure is important, but not enough
Everything discussed so far improves the Terraform file layer. It makes configuration files readable, makes code reviews cleaner, gives teams a shared convention for where resource blocks, variable declarations, output values, provider plugins, and environment-specific versions should live, and reduces the chance that a maintainer has to grep through a thousand-line main.tf to find one security group.
None of that changes the state layer.
Terraform locks state because the state file is the shared record that must be protected during operations.
The backend mechanics vary, but the pattern is the same. One lock guards the entire state, so a state file containing thousands of resources across multiple regions can still be locked by a change to one small resource.
Observation
As a result, you get false contention, where two teams touching unrelated infrastructure serialize anyway because the lock operates at state file granularity rather than resource granularity.
That serialization gets worse as teams and resource counts grow. More engineers touch more infrastructure through the same shared state, and every Terraform operation has to coordinate through the same lock, even when the resources involved are unrelated.
The actual dependency graph may say two changes can proceed independently, but the backend cannot act on that information. It sees one state file, so it enforces one mutex. That is the architectural mismatch at the heart of Terraform state locking.
The common response is state splitting. Break one large state into multiple smaller state files, often by environment, service, team, or infrastructure layer. Smaller states usually mean smaller lock scopes, smaller blast radius, and less waiting, causing a real improvement when one monolithic state file is blocking every pipeline.
However, state splitting redistributes the problem rather than solving it. You gain narrower locks, then inherit cross-state dependency management, orchestration overhead, remote state references, and no single view of the infrastructure graph.
Pattern Recognition
State splitting reduces contention, but it does not change the fact that each state is still protected as a file-shaped unit rather than a graph-shaped system.
Stategraph fixes the state layer by keeping the graph alive
Stategraph addresses the bottleneck below the Terraform files.
It is a state management and execution layer for Terraform and OpenTofu that treats state as what it actually is: a directed graph of resources and dependencies, rather than a monolithic JSON blob that must be locked as one object.
Stategraph helps teams preserve the dependency graph at the storage and coordination layer, where it can actually reduce contention, without changing how they write code.
With Stategraph Velocity, independent subgraphs can be planned and applied concurrently, because the system uses the Terraform dependency graph to identify groups of resources with no dependencies between them. Traditional backends lock the entire state file during operations.
Stategraph uses resource-level conflict detection, so two transactions conflict only when they touch overlapping resources, and transactions that touch different resources can run in parallel even when they operate against the same state.
Design Principle
That is the practical meaning of subgraph isolation. If one team is updating an RDS instance and another is changing a CloudFront distribution, those operations should not block each other unless the graph says their resources overlap.
Stategraph exists because Terraform already builds a dependency graph for planning, but normal state storage flattens that graph into a blob and discards the useful structure after each run.
Adoption is designed around the existing Terraform ecosystem rather than a rewrite. Stategraph implements the Terraform HTTP backend, exposes backend endpoints used by terraform init and apply workflows, and can store state directly in Stategraph instead of S3, GCS, Consul, or Terraform Cloud.
Stategraph also reads existing tfstate files and constructs the graph representation automatically, so existing .tf files, module layouts, and workflows do not need to be reorganized just to fix the state layer.
The file layer is only the beginning
Terraform files deserve care.
A consistent main.tf, variables.tf, outputs.tf, providers.tf, and .tfvars structure helps teams understand the Terraform code they are changing. terraform fmt removes pointless formatting noise from pull requests. Modules let teams reuse infrastructure patterns instead of copying resource blocks across a git repository. Environment directories make multi-environment projects easier to reason about when development, staging, and production need different values.
Those habits are foundational, but they do not remove the bottleneck that appears when infrastructure becomes shared, concurrent, and large.
At that point, even if Terraform configuration files are neatly arranged, operations still coordinate through state, and file-based state locking forces safe changes to wait behind unrelated work.
Stategraph addresses that constraint directly by moving coordination from the file layer to the graph layer, while leaving Terraform files and module structure intact. For teams that have already cleaned up their Terraform project structure and are still waiting on state locks, long plans, or serialized CI/CD pipelines, the next place to look is the backend.
Book a demo with us or start for free to try Stategraph.