Terraform is the most widely adopted infrastructure-as-code tool, used by over 1.2 million organizations globally. However, moving from a working proof-of-concept to a reliable production setup requires deliberate structure. Teams that skip module architecture and remote state management spend an average of 14 extra hours per week on manual remediation and failed deployments.
This guide covers the patterns that separate experimental Terraform from production-grade infrastructure: module layering, remote state with locking, workspace strategy, CI/CD automation, drift detection, and a go-live checklist you can apply immediately.
What Is the Right Module Structure for Production Terraform?
The correct answer for most teams is a three-tier module hierarchy: root configurations that orchestrate everything, child modules that encapsulate reusable resources, and registry modules for community-vetted patterns.
Root module — The entry point per environment. Lives in a directory like `infra/environments/prod/`. Calls child modules, sets provider versions, and references remote state outputs.
Child modules — Logical groupings of resources you own. Examples: `modules/networking/`, `modules/database/`, `modules/compute/`. These accept variables, expose outputs, and are called by one or more root configurations.
Registry modules — Pre-built modules from the Terraform Registry (e.g., `terraform-aws-modules/vpc/aws`). Pin to an exact version. Never use `source = "…"` without a `version` constraint in production.
infra/
├── environments/
│ ├── prod/
│ │ ├── main.tf # Calls child modules
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── backend.tf # Remote state config
│ └── staging/
│ └── main.tf
└── modules/
├── networking/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── database/
└── compute/
Teams using this structure report 40% fewer cross-environment configuration errors compared to flat, monolithic configurations. See Arxentra Services for how this maps to managed infrastructure.
How Should You Configure Remote State in Terraform?
Remote state with locking is non-negotiable for any team with more than one engineer. Local state files cause split-brain scenarios when two engineers run `terraform apply` concurrently.
The most common production setup uses AWS S3 for storage and DynamoDB for state locking:
<h1>backend.tf</h1>
terraform {
backend "s3" {
bucket = "acme-terraform-state-prod"
key = "networking/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
Create the DynamoDB table with a `LockID` string primary key. Enable S3 versioning so you can roll back a corrupted state file. Use separate S3 keys per environment and per module — never share a state file across environments.
For GCP teams, use the `gcs` backend with a dedicated service account. For Azure, use the `azurerm` backend with a storage account and container per environment.
State file access control: Grant least-privilege IAM policies. CI/CD pipelines need `s3:GetObject`, `s3:PutObject`, `dynamodb:GetItem`, `dynamodb:PutItem`, `dynamodb:DeleteItem`. Engineers running local plans need read-only access.
Workspaces vs. Directory-per-Environment: Which Should You Use?
| Dimension | Terraform Workspaces | Directory per Environment |
| State isolation | Shared backend, separate keys | Fully separate backends |
| Configuration drift | High risk (same code, different vars) | Low risk (explicit per-env files) |
| RBAC support | Limited | Full (separate IAM per dir) |
| Blast radius on mistakes | High | Low |
| Suitable for | Temporary feature environments | Stable prod/staging/dev |
| Common use case | Ephemeral PR environments | Long-lived environments |
The industry consensus, backed by HashiCorp's own recommendations, is to use directories for long-lived environments (prod, staging, dev) and workspaces only for short-lived environments like pull request previews. 73% of enterprise Terraform users surveyed in 2025 used directory-per-environment for production.
How Do You Run Terraform Safely in CI/CD?
A safe CI/CD pipeline separates plan (read) from apply (write) and requires explicit approval before any destructive change.
<h1>.github/workflows/terraform.yml</h1>
name: Terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/TerraformPlanRole
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.5"
- run: terraform init
working-directory: infra/environments/prod
- run: terraform plan -out=tfplan | tee plan.txt
working-directory: infra/environments/prod
- name: Comment plan on PR
uses: actions/github-script@v7
with:
script: |
const plan = require("fs").readFileSync("infra/environments/prod/plan.txt", "utf8");
github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: "
\n" + plan + "\n"
});
apply:
needs: plan
if: github.ref == "refs/heads/main"
environment: production # Requires manual approval
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/TerraformApplyRole
aws-region: us-east-1
- run: terraform apply tfplan
working-directory: infra/environments/prod
Use OIDC federation (not long-lived AWS access keys) for the CI role. Separate IAM roles for plan vs. apply with the apply role carrying write permissions only on the production account.
Setting Up Atlantis for Pull-Request-Driven Terraform
Atlantis is an open-source server that comments `terraform plan` output directly on pull requests and applies on merge. It enforces the workflow without requiring each developer to have local cloud credentials.
Install Atlantis as a Kubernetes Deployment with a ServiceAccount, or use the Atlantis Helm chart. Configure a `atlantis.yaml` in your repo root:
<h1>atlantis.yaml</h1>
version: 3
projects:
- name: prod-networking
dir: infra/environments/prod
workspace: default
autoplan:
when_modified: ["<strong>/*.tf", "../../modules/</strong>/*.tf"]
enabled: true
apply_requirements: [approved, mergeable]
The `apply_requirements: [approved]` gate means no infrastructure change can be applied without a code review approval — the most effective single control for preventing accidental destruction. Teams using Atlantis see 60% fewer unauthorized infrastructure changes.
How Does Drift Detection Work with Terraform?
Infrastructure drift occurs when real cloud resources diverge from the Terraform state — caused by manual console changes, automated scaling events, or other tools writing to the same resources.
terraform plan as a drift detector: Running `terraform plan` in a scheduled CI job and alerting when the output is non-empty is the simplest approach. It works but only detects resources Terraform already manages.
driftctl: An open-source CLI that scans cloud provider APIs and compares against Terraform state files, identifying both managed resources with drift and unmanaged resources entirely absent from state.
<h1>Install driftctl</h1>
curl -L https://github.com/snyk/driftctl/releases/latest/download/driftctl_linux_amd64 -o driftctl
chmod +x driftctl
<h1>Scan against S3 backend state</h1>
driftctl scan --from tfstate+s3://acme-terraform-state-prod/networking/terraform.tfstate
Spacelift and env0: Commercial platforms that embed drift detection, policy enforcement, and audit trails. Spacelift checks for drift every 1-24 hours and can auto-remediate by triggering a plan+apply. Suitable for teams managing 50+ stacks.
Surveys show 68% of cloud infrastructure incidents in 2024 involved some form of undetected drift. Schedule drift scans at minimum daily. See Arxentra Cloud Computing for complementary infrastructure monitoring.
Version Pinning and Pre-Commit Hooks
Always pin the Terraform CLI version, the provider versions, and module versions. Use a `.terraform-version` file (picked up by tfenv) alongside provider version constraints:
<h1>versions.tf</h1>
terraform {
required_version = "~> 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
Pre-commit hooks catch formatting and validation errors before they reach CI, saving 8-12 minutes per failed pipeline run. Use the `pre-commit-terraform` hook collection:
<h1>.pre-commit-config.yaml</h1>
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.90.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
- id: terraform_docs
Importing Existing Resources into Terraform
When adopting Terraform for infrastructure that already exists, use `terraform import` to bring resources under management without recreating them:
<h1>Import an existing S3 bucket</h1>
terraform import aws_s3_bucket.state_bucket acme-terraform-state-prod
<h1>Import an existing VPC</h1>
terraform import aws_vpc.main vpc-0a1b2c3d4e5f6a7b8
Terraform 1.5+ supports import blocks directly in HCL, enabling import as code reviewed in PRs rather than imperative CLI commands. Write the import block, run `terraform plan`, verify the import generates no changes, then remove the import block and commit.
Production Terraform Checklist
| Category | Requirement | Status |
| State | Remote backend with encryption | Required |
| State | DynamoDB or equivalent locking | Required |
| State | Versioning on state bucket | Required |
| Modules | Three-tier hierarchy | Required |
| Versions | All providers pinned | Required |
| CI/CD | Plan on PR, apply on merge | Required |
| CI/CD | Manual approval gate for prod | Required |
| Security | OIDC federation (no static keys) | Required |
| Drift | Scheduled daily plan scan | Required |
| Secrets | No secrets in tfvars or state | Required |
| Formatting | terraform fmt in pre-commit | Recommended |
| Docs | terraform-docs for all modules | Recommended |
Frequently Asked Questions
Q: Should I use Terraform Cloud or self-host the state backend?
A: Terraform Cloud (now HCP Terraform) is the fastest path to remote state, locking, and run history. Self-hosted S3+DynamoDB is cheaper at scale and avoids vendor lock-in. Choose Terraform Cloud if your team is fewer than 20 engineers and values time-to-production; choose S3+DynamoDB if you need cost predictability at high volume.
Q: How many resources should a single Terraform state file manage?
A: Keep state files under 200 resources. Beyond that, `terraform plan` becomes slow (often 5+ minutes) and the blast radius of a mistake is too large. Split by domain: networking, database, compute, security.
Q: Is it safe to use `terraform apply -auto-approve` in CI?
A: Only after a plan has been reviewed and approved via pull request, and only in a pipeline with strict branch protections. Never use it interactively or in scripts triggered by any non-reviewed code path.
Q: What is the best way to handle Terraform secrets?
A: Pass secrets into Terraform via environment variables (`TF_VAR_db_password`) sourced from a secrets manager (AWS Secrets Manager, Vault, 1Password Secrets Automation). Never write secrets in `.tfvars` files or commit them to state.
Q: How do I refactor a large monolithic Terraform configuration?
A: Extract resources into child modules one logical group at a time. Use `terraform state mv` to relocate resources into the new module path without destroying and recreating them. Test with `terraform plan` after each move to confirm zero net changes.
Q: When should I use `terraform taint` vs. `terraform destroy` on a resource?
A: `terraform taint` (or `terraform apply -replace` in 1.x) marks a single resource for replacement in the next plan — use it when a resource is corrupted but you want Terraform to manage the replacement. Use `terraform destroy` only when you intend to permanently delete the resource and all dependents.
For teams looking to deploy production infrastructure without managing Terraform state and CI/CD pipelines themselves, see Arxentra Pricing for managed infrastructure tiers. Learn more in our Documentation or contact us to discuss your setup.