When you manage infrastructure with Terraform across development, staging, and production, you need to decide where each piece of configuration lives. Terraform treats any directory of .tf files as a module. The directory where you run terraform apply is the root module; a directory of reusable resource definitions that a root module loads with a module block is a child module. Every Terraform configuration has exactly one root module, and that root module can call any number of child modules.
If dev and prod share a root module and one state file (the record of the infrastructure Terraform manages), a change you make for development also applies to production resources. To avoid this, keep reusable modules separate from root modules and give each environment its own directory and state file. Pass environment-specific differences in as variables. The recommended layout is the pattern Google Cloud documents for root modules: a modules directory for shared service configuration and an environments directory with one root configuration per environment.
Root modules and reusable modules play different roles
The two kinds of modules have different jobs. A root module is the configuration you apply. It contains the provider configuration (the block that sets cloud credentials, region, and other client settings), the backend configuration (where Terraform stores its state file), and the module blocks that call child modules. Any set of Terraform configuration files in a folder is a module; if you run apply directly on it, it is a root module, while a reusable module is meant to be used within other modules.
A reusable module declares resources, input variables, and output values. The caller passes values to the Input variables, the module’s API; those values control how the module behaves. Gruntwork’s webserver-cluster example takes variables for cluster name, instance type, minimum size, and maximum size, so the same module creates a small cluster in staging and a large one in production. Output values are the module’s return values, which the caller reads as module.<module_name>.<output_name>.
The one thing a reusable module should not contain is provider configuration. Configure providers only in root modules. Shared modules inherit the default provider configuration from the root, per Gruntwork’s guide and a HashiCorp Discuss answer. If a module hardcodes a provider, the caller cannot control which credentials or region the module uses. When a shared module genuinely needs a second configuration of the same provider, it can declare configuration_aliases, and the caller maps its own provider configurations to those aliases with the module block’s providers argument. The Discuss answer recommends that approach only when the module represents a connection between two configurations.
The module development guide warns that you make configuration harder to understand and maintain when you put a thin wrapper around a single resource type. Create a module when a group of resources belongs together and repeats, not to give every resource a named wrapper.
A layout that separates modules from environments
The pattern that scales is a repository with two top-level directories: modules/ for reusable configuration, and environments/ for the root modules that deploy that configuration. Google Cloud’s root-module best practices show this structure per service:
SERVICE-DIRECTORY/
OWNERS
modules/
<service-name>/
main.tf
variables.tf
outputs.tf
provider.tf
README
environments/
dev/
backend.tf
main.tf
qa/
backend.tf
main.tf
prod/
backend.tf
main.tf
Each environment directory is its own root module. Its backend.tf declares where Terraform stores that environment’s state file, and its main.tf calls the service module from modules//, passing that environment’s variable values.
The module directory includes a provider.tf that declares the providers and version constraints the module needs. The provider configuration, the block that sets credentials and region, belongs in the environment root module, so each environment targets its own cloud project. As long as the module lives in the same repository, the environment root modules reference it by relative path. Terraform loads modules from local relative paths or remote repositories, and once a module moves to its own repository or a registry, callers pin a version so each environment upgrades the module on its own schedule.
Keep environment-specific differences in variables
The service module should hard-code the inputs that are the same in every environment and require only environment-specific inputs as variables. Google Cloud’s root-module guidance says to do exactly this so that environment-specific differences stay explicit. Things that are the same everywhere, such as a network range or the service’s default port, are hard-coded inside the module. Things that differ, like instance type, cluster size, or environment name, become variables.
The environment’s main.tf then shows the difference at a glance. The dev root module calls the service module with a small instance type and a modest cluster size; the prod root module calls the same module with a larger instance type and a higher minimum. The module code is identical in both cases; the caller is where the environments diverge.
Root modules should provide those variable values through a terraform.tfvars file rather than command-line -var options. The same guidance recommends declaring each provider and pinning it to a minor version in a versions.tf file, which allows patch upgrades automatically, and committing the.terraform.lock.hcl dependency lock file to source control. Brainboard’s multi-environment guide follows the var-file pattern too: a separate.tfvars file per environment, applied with terraform apply -var-file.
Give each environment its own state file
The state file records what resources exist and how they map to the configuration; Terraform reads it on every plan and writes it on every apply. If dev, staging, and prod share one state file, a change you make in dev can overwrite production resources, critical infrastructure can be destroyed accidentally, configurations become inconsistent, and CI/CD pipelines break, as the state isolation guide on Medium summarizes.
Each environment directory gets its own backend.tf pointing at its own state key. With an S3 backend, the key can be env/${var.environment}/terraform.tfstate, so each environment has a distinct object. Brainboard’s guide calls remote state backends non-negotiable for multi-environment setups and pairs the S3 backend with DynamoDB locking and encryption. DynamoDB locking operates per state file, so Terraform holds a different lock when you apply in dev than when you apply in prod, and you can run terraform apply in parallel across environments without interference.
Terraform’s built-in workspaces also provide multiple state files from a single configuration, which looks like a shortcut. Google Cloud’s root-module guidance recommends against using multiple workspaces for environment separation. Multiple workspaces share one backend, making it a single point of failure. Each workspace’s configuration is hard to inspect, and code becomes harder to read when it switches on the current workspace variable. The same guidance recommends using only the default workspace in each environment. The Medium guide adds that workspaces depend on human discipline, and one wrong terraform workspace select can impact production. For production systems, the file layout wins because each environment is a separate directory with its own backend definition and its own state file, and each directory runs Terraform independently.
Because Terraform refreshes every resource in a root configuration on every run, Google Cloud advises keeping each root module to no more than 100 resources, ideally a few dozen. Practitioners on Stack Overflow suggest an even tighter target of around 20 to 30 resources per deployment; one commenter notes that if Terraform takes ten minutes to plan a tag change, the deployment is too big. When a service outgrows that, split it into multiple root modules, each with its own state.
Read another environment’s outputs without coupling
Environments and services still need to share values across those separate state files. The database root module needs to tell the service module its address and port; the networking root module needs to tell services which subnets exist. The terraform_remote_state data source reads outputs from another configuration’s state file. Gruntwork’s webserver example passes the database state’s bucket and key into the module as variables, and the module reads the database address and port from that state. As Quali’s module guide puts it, if you use those values and do not mutate the state, both teams can deploy independently.
This keeps the dependency explicit without coupling the apply order or the state files. The service root module can apply before or after the database root module; when it applies, it reads the current values from the database state. If the database module changes an output, the service module picks it up on its next apply. Do not hand-copy the values into the service configuration, because a hard-coded copy drifts the same way duplicated environment code drifts.
Tradeoffs: monolith vs separated directories
The two-directory layout is one point on a spectrum. The main alternatives are the monolith and the per-component directory split.
The monolith keeps all environments in one root module with a modules/ directory and a var file per environment, applied with terraform apply -var-file=vars/prod.tfvars. Its strength is uniformity: all environments run the exact same configuration, so checking that infrastructure is up to date is easy, and environments stay identical by construction. You pay for that uniformity in execution time and blast radius. One Stack Overflow answer reports roughly 45 minutes to plan or apply a single environment in that layout, with cleanup that is hard and an environment that is difficult to understand as a whole.
The per-component split goes further than the two-directory pattern. It divides each environment into directories like apps, data, general, and network, each with its own state, and you apply by changing into the specific directory and running terraform apply. You get quick applies after small changes and easier cleanup. The directory structure and CI become more complicated, though, and you have to keep environments in sync manually.
The two-directory pattern sits between these: one state per environment rather than one state per component, which keeps the number of state files manageable while still isolating dev from prod. When a single environment’s state grows past the resource-count comfort zone, split that environment’s configuration by service or domain, and give each piece its own state. That is how the layout grows without turning into either extreme.