Terraform Project Structure for GCP: Environments, Modules, and Best Practices
A flat main.tf with everything in it works fine for learning. It stops working well the moment you have more than one environment, more than one person, or more than one team. This page shows you how to structure a Terraform codebase for GCP so that dev, staging, and production stay isolated, shared patterns live in modules, and your infrastructure is safe to change.
What is Terraform project structure?
Terraform project structure is how you organise the files and folders that define your infrastructure. At its simplest, you could put every resource in a single file in a single folder. That works for a learning exercise. It does not work for a real project.
The problem with keeping everything in one place is that Terraform manages all your resources as a single unit. A plan for one small change forces Terraform to process your entire infrastructure. A mistake like running the wrong command or destroying the wrong resource can affect everything at once. You cannot safely give a junior engineer access to dev without also giving them access to prod.
The solution is to split infrastructure into separate directories by environment. Each directory has its own Terraform state. What happens in dev stays in dev. Shared patterns like a Cloud Run service definition, a VPC setup, or a service account configuration live in modules you call from each environment. This is the folder structure most teams converge on when they outgrow a single file.
If you are new to Terraform itself, start with Terraform for Google Cloud before reading this page. If you want the broader picture of why teams adopt IaC at all, see Infrastructure as Code on GCP.
Why Terraform project structure matters
The way you structure a Terraform project determines what kinds of mistakes are possible.
When everything lives in a single state file, a corrupted state, a bad plan, or an accidental terraform destroy can affect your entire infrastructure across all environments at once. There is no safe way to give someone access to dev without also giving them access to prod. Code review becomes harder because every pull request touches shared configuration that applies everywhere.
With separate directories per environment, a destroy in dev destroys dev resources only. Plans are faster and scoped to one environment. Pull requests clearly show which environment will be affected. IAM access can be scoped to specific environment directories in CI/CD. You can apply to dev, confirm the result, and only then apply to staging and production.
Structure also matters for teams using secure CI/CD pipelines. A well-structured project maps naturally to a safe pipeline: one job runs terraform plan on pull request, another runs terraform apply only after merge, and each environment is deployed independently.
Standard Terraform folder layout for GCP
Here is the layout most GCP teams end up using. Every directory under environments/ is a root module, meaning a directory you run terraform apply from. Shared patterns live in modules/ and are called from each environment root.
terraform/
environments/
dev/
main.tf # Module calls and resources for dev
variables.tf # Variable declarations
outputs.tf # Exported values (useful for CI/CD or other modules)
versions.tf # Provider and Terraform version constraints
terraform.tfvars # Non-sensitive variable values for dev
staging/
main.tf
variables.tf
outputs.tf
versions.tf
terraform.tfvars
prod/
main.tf
variables.tf
outputs.tf
versions.tf
terraform.tfvars
modules/
cloud-run-service/
main.tf # The Cloud Run resource definition
variables.tf # Inputs the module accepts
outputs.tf # Values the module exposes
vpc-network/
main.tf
variables.tf
outputs.tf
service-account/
main.tf
variables.tf
outputs.tfEach environment directory is completely self-contained. It has its own state file, its own variable values, and its own provider configuration. The environments share nothing except the module code, which is versioned in the same repository.
Modules are like LEGO brick designs. You design the brick once, then use it as many times as you need. Each environment is a separate assembly using the same brick designs, but configured with different quantities, names, and settings. Fix a flaw in the brick design and every environment picks up the improvement on its next apply.
How it works in practice
Here is the flow a team typically follows when making an infrastructure change:
- Make the change in a branch. Edit a module or an environment’s
main.tf. If the change is to a module, all environments that call that module will pick up the change on their next apply. - Run
terraform planagainst dev. Navigate intoenvironments/dev/and runterraform plan. Check the output carefully. The plan only affects dev state and dev GCP resources. - Open a pull request. CI/CD runs
terraform planautomatically and posts the output as a comment. Reviewers can see exactly what will change before approving. - Merge and apply to dev first. After the PR merges, the pipeline applies to dev. If GitHub Actions or Cloud Build is driving this, the apply job runs from
environments/dev/. - Promote to staging, then prod. Run the same plan and apply sequence in
environments/staging/, confirm the result, then repeat forenvironments/prod/. Each environment is a separate apply with its own state.
Promoting a change from dev to prod is like testing a new recipe on a small batch before making it for a large event. You do not skip straight to the full run. You taste, adjust, confirm it works, then scale up. With infrastructure, “tasting” is running the plan and apply in dev before touching staging or production.
Each environment points to its own GCP project. For most teams, this means three separate GCP projects: my-app-dev, my-app-staging, and my-app-prod. Keeping environments in separate GCP projects gives you hard IAM boundaries, separate billing, and separate audit logs. See Dev vs Staging vs Production for more on how environment separation maps to GCP project isolation.
Root modules vs reusable modules
A root module is the directory you run terraform apply from. It is the entry point. Each environment directory is a root module.
A reusable module is a directory of Terraform files you call from a root module using a module block. It encapsulates a repeatable pattern (a Cloud Run service, a VPC, a set of IAM bindings) and accepts inputs via variables.
# environments/prod/main.tf
module "api_service" {
source = "../../modules/cloud-run-service"
project_id = "my-app-prod"
region = "europe-west2"
service_name = "api-service"
image = "europe-west2-docker.pkg.dev/my-app-prod/api/api:v1.2.0"
min_instances = 2
max_instances = 20
}# environments/dev/main.tf
module "api_service" {
source = "../../modules/cloud-run-service"
project_id = "my-app-dev"
region = "europe-west2"
service_name = "api-service"
image = "europe-west2-docker.pkg.dev/my-app-dev/api/api:latest"
min_instances = 0
max_instances = 5
}The module’s variables.tf declares what inputs it accepts. The root module provides the values. When you fix a misconfiguration in the module (adding a missing Cloud Run environment variable, correcting a concurrency setting), all environments pick up the fix after their next apply.
Common GCP patterns that work well as modules: Cloud Run service definitions, VPC networks with subnets and firewall rules, GCS buckets with lifecycle policies, and service accounts with IAM bindings. For IAM specifically, see Managing IAM with Terraform.
Do not build modules speculatively. Create one when you notice yourself copying the same resource blocks into a second or third environment. A module that only has one caller adds complexity without payoff. Wait until repetition makes the cost obvious.
Variables, tfvars files, and secrets
Variables are how environment differences flow into modules. Declare them in variables.tf:
# variables.tf
variable "project_id" {
type = string
description = "GCP project ID for this environment"
}
variable "min_instances" {
type = number
description = "Minimum Cloud Run instances"
default = 0
}Supply non-sensitive values in terraform.tfvars:
# environments/prod/terraform.tfvars
project_id = "my-app-prod"
region = "europe-west2"
min_instances = 2This is the right approach for values like project IDs, region names, instance counts, and service names. These are configuration, not secrets.
Database passwords, API keys, and private certificates do not belong in terraform.tfvars or any version-controlled file. Variable files get added to git carelessly and it only takes one accidental commit to expose a credential. Use TF_VAR_ environment variables in CI/CD, or read secrets at runtime from Secret Manager. For more on keeping secrets safe in pipelines, see Secrets in CI/CD Pipelines.
When this structure is worth adopting
The environment-per-directory structure is worth the setup cost when:
- You have or expect to have multiple environments (dev, staging, and prod at minimum)
- More than one person will apply Terraform changes
- You want to enforce a change process: apply to dev before applying to prod
- You need to give different people different levels of access to different environments
- You have shared infrastructure patterns across environments (a VPC layout, a Cloud Run template, a set of IAM roles)
Simpler structures are still fine for:
- A solo learning project with a single environment
- A short-lived prototype you will tear down in days
- A one-off resource that does not map to any ongoing service
If you are building infrastructure you intend to keep and grow, start with the multi-environment layout from day one. Refactoring a flat structure into a proper layout later is tedious work that always gets deprioritised. It takes an hour to set up correctly at the start and days to untangle if you wait.
Terraform workspaces vs separate directories
This comes up often enough to deserve a clear explanation.
Terraform workspaces let you maintain multiple state files from a single configuration directory. You run terraform workspace new staging to create a staging state, and terraform workspace select staging to switch into it before running plan or apply.
The limitation: a single configuration must handle all environment differences through conditional expressions.
# This logic accumulates fast and becomes hard to review
min_instances = terraform.workspace == "prod" ? 2 : 0
machine_type = terraform.workspace == "prod" ? "n2-standard-4" : "e2-micro"
deletion_protection = terraform.workspace == "prod" ? true : falseAs the configuration grows, workspace-based conditional logic spreads through every resource and module. It becomes easy to forget to switch workspaces before running apply. That mistake applies prod-intended changes to staging, or staging changes to prod, with no structural safety net to catch you.
Comparison: workspaces vs separate directories
| Factor | Workspaces | Separate directories |
|---|---|---|
| State isolation | Separate state per workspace | Separate state per directory |
| Environment differences | Conditional logic in config | Separate tfvars per environment |
| Risk of wrong-environment apply | High: requires remembering to switch | Low: you run apply from the right folder |
| Code review clarity | Harder: all environments in one config | Easier: each environment is self-contained |
| IAM access control per environment | Not supported natively | Achievable with per-environment CI/CD permissions |
| Best for | Ephemeral feature environments, identical configs | Long-lived environments with different settings or security requirements |
For dev, staging, and prod: use separate directories. For short-lived feature environments where the configuration is genuinely identical, workspaces are a reasonable choice.
Module sources and version pinning
For local modules within your repo, use a relative path as the source:
module "api_service" {
source = "../../modules/cloud-run-service"
# ...
}For modules from the Terraform Registry (including Google’s official modules), always pin to a version:
module "gcs_bucket" {
source = "terraform-google-modules/cloud-storage/google"
version = "~> 6.0"
project_id = var.project_id
names = ["my-app-prod-assets"]
location = "EU"
}Without a version constraint, terraform init can download a breaking major version on any future run. The ~> constraint allows patch and minor updates within the major version but blocks major upgrades. The same principle applies to provider versions in versions.tf. Always pin the Google provider to a major version like ~> 5.0.
Unpinned versions are a common source of unexpected pipeline failures. A pipeline that worked last month can start failing after an uncontrolled module or provider upgrade on the next terraform init. See Terraform State Management for more on keeping state and provider versions under control.
Common mistakes with Terraform project structure
All environments in one state file. A single root module containing dev and prod means a corrupted state or accidental destroy can affect everything. Separate directories with separate state files is non-negotiable for anything beyond solo experimentation.
Building modules before you have repetition. Copy resource blocks across environments once. When you do it a third time, consider a module. Building a complex module for one use case adds maintenance overhead without payoff.
Committing secrets in terraform.tfvars. Variable files get added to git carelessly. Ensure they never contain passwords, API keys, or sensitive data. Use
TF_VAR_environment variables in CI/CD or pull secrets from Secret Manager at runtime.Not pinning provider or module versions. An unpinned provider can upgrade to a breaking major version on the next
terraform init. Pin major versions inversions.tfand review upgrades deliberately.Unclear naming conventions. Directories named
env1/andenv2/are confusing for reviewers and new team members. Name directories after the environment they represent:dev/,staging/,prod/. Name modules after what they provision:cloud-run-service/,vpc-network/.Environment directories that do not match actual GCP project boundaries. If your GCP setup uses separate projects per environment, your Terraform directories should match. A single Terraform environment directory managing resources across multiple GCP projects becomes confusing quickly.
Mixing shared and environment-specific resources in the same root module. Shared networking or organisation-level IAM bindings should live in their own root module, not mixed into an environment’s
main.tf. This makes it clearer what is shared and reduces the blast radius of mistakes.Not running
terraform initafter adding a module. When you add a new module source, runterraform initbefore planning. Forgetting this causes “module not found” errors that are easy to diagnose once you know the pattern but confusing the first time.
Recommended approach for most GCP teams
Start with this layout on day one if you know you will have multiple environments:
- Three separate GCP projects: dev, staging, prod
- Three matching Terraform environment directories, each with its own state file stored in GCS. See Terraform State Management for how to configure remote state.
- A
modules/directory for shared patterns. Add modules as repetition appears, not before. - Non-sensitive variable values in
terraform.tfvars, secrets viaTF_VAR_or Secret Manager - A CI/CD pipeline that plans on pull request and applies after merge, with environment promotion controlled by pipeline stages. GitHub Actions for GCP shows one way to wire this up.
- Provider and module versions pinned in
versions.tf
This structure is not the only correct answer. Some teams add a shared/ root module for organisation-level resources. Some teams split modules into a separate repository once the codebase grows large. The key decisions (separate state per environment, shared modules for reuse, secrets out of version control) apply regardless of the exact layout you choose.
For teams adopting Policy as Code, this structure maps cleanly to automated policy enforcement: policies can be applied per-environment directory and validated in CI/CD before any apply runs.
Summary
- Use separate directories per environment, each with its own Terraform state
- Standard files in each environment:
main.tf,variables.tf,outputs.tf,versions.tf,terraform.tfvars - Shared patterns live in
modules/and are called from each environment root - Each environment should map to its own GCP project for hard IAM and billing boundaries
- Supply non-sensitive values in
terraform.tfvars. Never commit secrets there. - Separate directories are safer than workspaces for long-lived environments with different settings or security requirements
- Pin provider and module versions to avoid uncontrolled upgrades
- Introduce modules when you see repetition, not before
Frequently asked questions
How should I organise Terraform for dev, staging, and production environments?
Use separate directories (environments/dev/, environments/staging/, environments/prod/), each with its own state file. Each directory calls shared modules with environment-specific variable values. Isolation means a terraform destroy in dev cannot touch prod. It also means you can grant different people access to different environment directories without mixing responsibilities.
Should I use Terraform workspaces or separate directories for long-lived environments?
Separate directories for long-lived environments. Workspaces keep a single configuration and handle environment differences through conditional logic like terraform.workspace == "prod" ? 2 : 0. That logic accumulates fast and makes it easy to forget to switch workspaces before applying. Workspaces are reasonable for ephemeral feature environments where the configuration is genuinely identical and short-lived.
What files should every Terraform environment directory contain?
At minimum: main.tf (resources and module calls), variables.tf (input variable declarations), outputs.tf (exported values), and versions.tf (provider and Terraform version constraints). Add terraform.tfvars for non-sensitive variable values specific to that environment. Never put passwords or API keys in tfvars files committed to git.
When should I create a Terraform module?
When you find yourself copying the same resource blocks across two or three places. Do not create modules speculatively. Build them when the repetition makes the maintenance cost clear. Usually that is when you are setting up the same thing in a third environment and realise a change would need to happen in three places.
Should each environment map to a separate GCP project?
Yes, for any real team. Separate GCP projects give you hard IAM boundaries, separate billing, separate audit logs, and separate quotas. Dev mistakes stay in the dev project. Production has its own project that only a limited set of people can access. Terraform maps cleanly to this: the project_id variable in each environment directory points to the correct GCP project.