Skip to content

article

Terraform Infrastructure as Code Best Practices: State, Modules, CI/CD, Security and Multi-Account AWS (2026)

Terraform Infrastructure as Code Best Practices: State, Modules, CI/CD, Security and Multi-Account AWS (2026)

Terraform infrastructure as code (IaC) is how every estate we manage is built: accounts, networks, compute, databases, IAM, monitoring and the alerting that watches it all. Done well, it turns infrastructure into reviewable diffs, makes environments provably identical, and gives auditors a change log for free. Done badly, it produces a single 4,000-line file with a state file on someone's laptop and a terraform apply that nobody dares run.

These are the practices we apply on AWS, Azure and GCP estates as part of DevOps consulting and cloud infrastructure services, in the order that matters when you are starting or rescuing a codebase. They apply equally to OpenTofu, the open-source fork, which we use interchangeably.

1. State: remote, locked, encrypted, separated

State is the source of truth for what Terraform thinks exists. Never local, never in Git.

  • Remote backend with locking: S3 with native state locking (Terraform 1.10 and later use S3 conditional writes; earlier versions need a DynamoDB lock table), Azure Blob Storage with lease-based locking, or GCS. Enable versioning and encryption on the bucket, and block public access.
  • One state per blast radius. Not one state for the whole company. Separate state per environment and per layer: network, platform (clusters, shared services), data, and each application's infrastructure. A mistake in an application layer must not be able to touch the VPC.
  • State access is privileged. The bucket contains secrets in plain text (database passwords, keys) unless you are careful. Restrict read access to the pipeline role and a small group of engineers, and log access.
  • Never edit state by hand. Use terraform state mv, import, rm and, from 1.5, import and moved blocks in code, so refactors are reviewable.

2. Repository and directory layout

Two layouts work; pick one and hold to it.

Environment directories with shared modules (our default):

infra/
  modules/          # reusable, versioned internally or from a registry
    vpc/  eks/  rds/  iam-role/  monitoring/
  live/
    prod/
      network/  platform/  data/  app-checkout/
    staging/
      network/  platform/  data/  app-checkout/

Each live/<env>/<layer> directory is one root module with its own state, and consumes shared modules with explicit versions. Environments differ by variable values, never by copy-pasted resource blocks.

Workspaces are the alternative: one root module, terraform workspace select prod. Simpler for small estates; dangerous at scale because a wrong workspace selection applies staging values to production. If you use workspaces, put the workspace name in the backend key and assert it in a precondition.

Terragrunt is a reasonable wrapper for the directory layout at scale (DRY backend and provider config, dependency ordering); Terraform Stacks is HashiCorp's newer answer to the same problem. Neither is required to start.

3. Modules: small, composable, versioned

  • A module does one thing: a VPC, an EKS cluster, an RDS instance with its subnet group and parameter group. Not "the whole application".
  • Inputs are explicit and typed. Every variable has a type, a description and, where sensible, a validation block. No any.
  • Outputs expose what consumers need (IDs, ARNs, endpoints), not everything.
  • Version modules. Git tags or a private registry, referenced with a version constraint. main is not a version.
  • Prefer well-maintained public modules (terraform-aws-modules on the registry) for standard infrastructure, wrapped in a thin internal module that sets your organisation's defaults: tags, encryption, logging.
  • Do not abstract prematurely. Three similar resource blocks are fine. A module with 40 variables to cover every case is a maintenance burden.

4. Variables, secrets and configuration

  • Environment values live in terraform.tfvars per environment directory, committed, containing no secrets.
  • Secrets come from a vault at apply time: AWS Secrets Manager or SSM Parameter Store data sources, Azure Key Vault, or Vault, read by the pipeline role. Mark outputs and variables sensitive = true.
  • Tag everything through a default_tags block on the provider (owner, environment, cost centre, managed-by), and enforce it with policy rather than hoping.
  • Pin provider versions with required_providers and commit the .terraform.lock.hcl. Upgrade deliberately, in a pull request, with a plan.

5. Plan and apply through the pipeline

Nobody applies from a laptop. The workflow:

  1. Pull request opens; the pipeline runs terraform fmt -check, validate, tflint, a security scanner (Checkov, tfsec or Trivy) and plan, and posts the plan as a comment, along with the cost delta from Infracost.
  2. Reviewers read the plan, not just the code. A plan that destroys and recreates a database is a conversation.
  3. Merge triggers apply from the same plan file (terraform plan -out then apply <planfile>) so what was reviewed is what runs.
  4. Production applies require an environment approval; non-production can auto-apply.

Credentials use OIDC federation from GitHub Actions or GitLab to a cloud role scoped to the environment; no stored access keys. State locking prevents concurrent applies; the pipeline serialises them anyway.

6. Policy as code and guardrails

Scanners catch known-bad patterns (public buckets, open security groups, unencrypted volumes). Policy engines enforce your rules: OPA with Conftest, Sentinel in HCP Terraform, or cloud-side guardrails (AWS Service Control Policies, Azure Policy) that reject non-compliant resources regardless of how they were created. Put the check in the pipeline as a required status, and put the cloud guardrail underneath as the backstop. This is what makes AI-assisted or high-velocity IaC safe: the policy, not the reviewer's attention.

7. Drift, imports and rescuing existing estates

  • Detect drift on a schedule: a nightly plan in read-only mode that alerts if the plan is not empty. Drift means someone changed something in the console; the fix is to bring the change into code or revert it, and to remove the console permission that allowed it.
  • Import incrementally. For an existing hand-built estate, write the resource block, use an import block (Terraform 1.5+) or terraform import, plan until the diff is empty, and move to the next resource. Start with the layers that change least (network, IAM), then platform, then applications. Do not attempt a big-bang import.
  • Use moved blocks when refactoring resource addresses so state follows the code without manual surgery.
  • Never -target in production except during a rescue, and record why.

8. Multi-account and multi-region AWS

  • Account structure first. AWS Organizations with an account per environment and per major workload, provisioned by Terraform through the Organizations provider or Control Tower Account Factory for Terraform (AFT). The landing zone (org, SCPs, logging account, identity) is its own root module with the tightest access.
  • Provider aliases per account and region. A root module that touches two accounts or regions declares provider "aws" { alias = "prod_eu" ... } blocks with assume_role, and passes them into modules explicitly. Never rely on the ambient credentials for cross-account work.
  • Global resources are singletons. IAM, Route 53 zones, CloudFront and S3 bucket names are global or region-agnostic; put them in a dedicated layer so regional stacks can be replicated cleanly.
  • Region as a variable, replication as a module. A regional-stack module instantiated once per region with the region-specific provider, and a small global layer that wires Route 53 or Global Accelerator across them, is the pattern for DR and multi-region. Our AWS global infrastructure guide covers the region decisions behind it.

9. Testing

  • terraform validate and tflint on every change.
  • terraform test (native since 1.6) or Terratest for modules that matter: spin up, assert outputs, tear down, in a sandbox account on a schedule.
  • Ephemeral environments from the same code for pull requests where the cost is justified: the truest test of an IaC codebase is that it can build a whole environment from nothing.

10. Security and compliance evidence

Everything above produces evidence auditors ask for: the Git history is the change log, the plan comments are the approvals, the policy checks are the preventive controls, drift detection is the detective control, and default_tags plus the state files are the asset inventory. Map them once to ISO 27001, SOC 2 and PCI DSS and reuse the mapping every year. Certification work itself is delivered by our sister firm PraxisQ Consulting; the controls are built here.

The mistakes we fix most often

  • One state file for everything, so every apply is a risk to production.
  • Secrets in tfvars committed to Git.
  • Console changes that were never brought back into code, discovered when the next apply tries to undo them.
  • Modules with copy-pasted resources per environment instead of variables.
  • Unpinned providers, so a Monday init breaks a Friday-working codebase.
  • -target and -auto-approve in shell history on a production account.
  • No moved blocks, so a rename destroys and recreates a database.

Frequently asked questions

Should we use Terraform or OpenTofu?

Functionally equivalent for almost all estates. OpenTofu is fully open source under the Linux Foundation and has added features such as state encryption; Terraform has HCP Terraform and the largest ecosystem. We work with both and pick per client licensing preference.

How should we structure Terraform for multiple environments?

Separate root modules per environment and layer, each with its own remote state, sharing versioned modules and differing only in variable values. Avoid workspaces at scale.

How do we handle Terraform state for a team?

Remote backend (S3, Azure Blob or GCS) with locking, versioning and encryption, one state per blast radius, access restricted to the pipeline role and a few engineers, and every apply run from the pipeline.

Can Terraform manage infrastructure that was created manually?

Yes, through import blocks or terraform import, one resource at a time, planning until the diff is empty. Start with network and IAM, then platform, then applications.

Does Techtweek offer Terraform consulting?

Yes. Landing zones, migrations of hand-built estates into code, module libraries and pipeline design are delivered through our DevOps consulting services, and the resulting estates are run under cloud infrastructure services.

Work with Techtweek

DevOps, cloud & compliance. CERT-In empanelled, AWS Advanced Partner.

Book a consultation
Talk to an engineer