INFRASTRUCTURE AS CODE

Terraform & OpenTofu: Infrastructure as Code in 2026

The BSL license split and the OpenTofu fork, HCL and providers, reusable modules, remote state with locking, the plan/apply loop, workspaces, import, the native terraform test framework, CI/CD, and how IaC compares to Ansible and Pulumi -- with real, current HCL.

By Jose Nobile | Updated 2026-07-01 | 14 min read

Infrastructure as Code in 2026

Infrastructure as Code (IaC) means describing your cloud -- networks, VMs, databases, DNS, IAM -- in version-controlled text instead of clicking through consoles. Terraform (from HashiCorp, now part of IBM) and its open-source fork OpenTofu (governed by the Linux Foundation) are the two dominant declarative, provider-agnostic IaC tools. You write the desired end state in HashiCorp Configuration Language (HCL); the tool computes the diff against reality and makes the minimal set of API calls to converge.

Both tools are drop-in compatible today: the same HCL, the same providers, the same state format. The core practices that keep IaC safe in production:

  • Everything in Git -- Every resource lives in .tf files under version control. The repository is the single source of truth; changes flow through pull requests and code review, never a console.
  • Remote, locked state -- Never keep terraform.tfstate on a laptop. Store it in a remote backend (S3, GCS, Terraform Cloud/HCP) with state locking so two applies can never corrupt it.
  • Plan before apply -- terraform plan is a dry run. Review the diff -- especially destroys and replacements -- before apply touches anything. In CI, post the plan on the pull request.
  • Small, reusable modules -- Factor common patterns (a VPC, a bucket, a GKE cluster) into versioned modules. Compose infrastructure instead of copy-pasting resource blocks.
  • Policy & scanning in CI -- Gate applies with policy-as-code (OPA/Conftest, Sentinel) and static scanning (tfsec, Checkov, Trivy) to catch open security groups and public buckets before they ship.

A minimal but complete configuration -- pin the tool version, declare the provider, create one resource:

# main.tf -- works identically on Terraform and OpenTofu
terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "state_demo" {
  bucket = "josenobile-iac-demo-2026"
  tags   = { Environment = "dev", ManagedBy = "terraform" }
}

Terraform vs OpenTofu: The Fork

In August 2023 HashiCorp relicensed Terraform from the open-source MPL-2.0 to the Business Source License (BSL/BUSL 1.1) -- a source-available license that forbids using Terraform to build a competing product, converting to open source only four years after each release. The community responded with a fork: OpenTofu, accepted into the Linux Foundation in September 2023 and kept under MPL-2.0. IBM's $6.4B acquisition of HashiCorp closed in February 2025 and did not change the license. As of mid-2026 Terraform ships the 1.14.x line while OpenTofu ships 1.12.x -- the codebases have started to diverge, but everyday HCL still works on both.

BSL 1.1

Terraform (HashiCorp / IBM)

Source-available under the Business Source License. Single-vendor roadmap, tight integration with HCP Terraform (formerly Terraform Cloud), the Terraform Registry, and Sentinel policy. Recent 1.14 additions: an actions block and List Resources (*.tfquery.hcl + terraform query). Free to use unless you build a competing IaC product.

MPL-2.0

OpenTofu (Linux Foundation)

True open source, vendor-neutral governance, no competitive-use restriction. Ships features Terraform lacks: client-side state encryption, early variable/provider evaluation, and (1.12) a dynamic prevent_destroy plus removed { ... } that drops a resource from state without destroying it. Drop-in CLI: run tofu instead of terraform.

HOW TO CHOOSE

Choosing in 2026

Pick OpenTofu for an OSI-approved license, state encryption, or independence from a single vendor. Pick Terraform if you need HCP Terraform/Sentinel or an enterprise support contract. Migration is usually just swapping the binary and running tofu init -- test in a non-prod workspace first.

HCL: Providers, Resources, Variables, Outputs

HashiCorp Configuration Language (HCL) is declarative: you describe blocks of desired state, and the engine figures out the order of operations from an implicit dependency graph built by reference. You rarely write loops or conditionals imperatively -- for_each, count, and expressions cover most of it.

The building blocks of every configuration:

  • Providers -- Plugins that talk to an API (aws, google, azurerm, kubernetes, cloudflare). Declared in required_providers and pinned with a version constraint; downloaded by terraform init.
  • Resources & data sources -- resource blocks create/manage objects; data blocks read existing ones. References like aws_vpc.main.id create dependencies automatically.
  • Variables -- Typed inputs (variable) with defaults, descriptions, and validation blocks. Set via .tfvars, -var, or TF_VAR_* environment variables.
  • Outputs & locals -- output exposes values (and feeds module composition); locals name intermediate expressions so you don't repeat yourself.

Variables, a resource driven by for_each, and an output:

# variables.tf
variable "environment" {
  type        = string
  description = "Deployment environment"
  default     = "dev"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

variable "bucket_names" {
  type    = set(string)
  default = ["logs", "assets", "backups"]
}

# main.tf
resource "aws_s3_bucket" "app" {
  for_each = var.bucket_names
  bucket   = "josenobile-${var.environment}-${each.key}"
}

# outputs.tf
output "bucket_arns" {
  value = { for k, b in aws_s3_bucket.app : k => b.arn }
}

Modules: Reusable Infrastructure

A module is just a directory of .tf files with inputs (variables) and outputs. Every configuration is already the "root module"; you compose it from child modules to avoid copy-pasting. Modules are how you turn a proven pattern -- a VPC, a database, a Kubernetes cluster -- into a versioned, reviewed, reusable building block shared across teams and environments.

Structure & Interface

Keep a clean contract: variables.tf (inputs), main.tf (resources), outputs.tf (returns), and a README. Callers depend only on the interface, so you can refactor internals freely. Keep modules small and single-purpose.

Sources & Registry

Reference modules from a local path, a Git URL (pin with ?ref=v1.4.0), or a registry. The public Terraform Registry and OpenTofu Registry publish battle-tested modules (e.g. terraform-aws-modules/vpc/aws) you can consume instead of writing your own.

Versioning & Best Practice

Semver every module; pin the version in callers so an upstream change never surprises a prod apply. Expose only what callers need, provide sensible defaults, and validate inputs. Prefer composition of small modules over one monolithic "does-everything" module.

Consuming a registry module and wiring its output into another resource:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"

  name            = "prod-vpc"
  cidr            = "10.0.0.0/16"
  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]
  enable_nat_gateway = true
}

resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = module.vpc.vpc_id   # output from the module
}

State Management & Remote Backends

State is how Terraform and OpenTofu map your HCL to real-world objects. The state file records resource IDs, attributes, and metadata so the tool knows what it already manages and can compute an accurate diff. Lose or corrupt state and the tool no longer knows what it owns -- so treating state as critical, shared, protected data is the single most important operational discipline in IaC.

Remote backends and locking. A backend defines where state lives. Local state is fine for a solo experiment; a team needs a remote backend so everyone reads and writes the same state. The essential guarantee is state locking: while one apply holds the lock, everyone else blocks, so two people can't mutate infrastructure concurrently and corrupt state. As of Terraform 1.11 / OpenTofu 1.11+, the S3 backend locks natively via a use_lockfile object in the bucket -- the old separate DynamoDB lock table is no longer required (though still supported). GCS locks on the object automatically; HCP Terraform and Terraform Cloud lock server-side.

Encryption & drift. State often contains secrets (DB passwords, keys), so encrypt it at rest -- enable bucket SSE, and on OpenTofu use built-in client-side state encryption so the file is unreadable even to whoever holds the bucket. Detect drift (out-of-band console changes) by running terraform plan (or plan -refresh-only) on a schedule in CI; a non-empty plan means reality diverged from code. Keep versioning on the state bucket so you can roll back a bad write.

AWS

S3 Backend

The most common backend. Store state in a versioned, encrypted S3 bucket; enable native use_lockfile = true for locking. Scope one state key per environment/component. Grant least-privilege IAM to the CI role that reads/writes it.

GCP

GCS Backend

Google Cloud Storage backend with automatic object-level locking -- no extra lock resource needed. Turn on bucket versioning and CMEK encryption. Use a prefix to separate state objects per environment inside one bucket.

MANAGED

HCP Terraform / TACOS

HCP Terraform (formerly Terraform Cloud) stores state, locks it, runs plan/apply remotely, and adds RBAC, policy, and a run UI. Open alternatives -- Spacelift, Scalr, env0, or self-hosted Atlantis -- offer the same "TACOS" workflow around either binary.

# backend.tf -- S3 with native locking (no DynamoDB table needed)
terraform {
  backend "s3" {
    bucket       = "josenobile-tfstate"
    key          = "prod/network/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true          # native S3 state locking
  }
}

Rule of thumb: one small, independently-locked state per environment and component (network, data, app) rather than one giant state for everything. Smaller states mean faster plans, a smaller blast radius, and locks that don't block the whole org on every change.

Plan/Apply, Workspaces, Import, Drift

The core loop is initplanapply. init downloads providers and configures the backend; plan shows exactly what will be created, changed, or destroyed; apply executes it. destroy tears it down. Save a plan to a file and apply that exact plan so what you reviewed is what runs:

terraform init                 # or: tofu init
terraform fmt -recursive       # canonical formatting
terraform validate             # static config check
terraform plan -out=tfplan     # review the diff
terraform apply tfplan         # apply the reviewed plan
terraform destroy              # tear everything down

The plan/apply loop

Always read the plan before applying -- watch for -/+ (replace) and - (destroy) on stateful resources. Use -target sparingly for surgical fixes, and lifecycle { prevent_destroy = true } to guard critical resources like databases.

Workspaces

CLI workspaces (terraform workspace new staging) keep multiple states from one config -- handy for ephemeral copies. For real dev/staging/prod separation, most teams prefer separate directories or .tfvars + distinct backends over CLI workspaces, to avoid one config secretly branching on terraform.workspace.

Import

Bring existing, click-created infrastructure under management. Prefer the declarative import { } block (plannable, reviewable, generates config with -generate-config-out) over the older imperative terraform import. Terraform 1.14's List Resources / terraform query can discover importable resources in bulk.

Drift detection

Drift is out-of-band change. Run terraform plan -refresh-only on a schedule; a non-empty result means reality diverged from code. Re-apply to reconcile, or codify the change. Refactor safely with moved { } blocks so renames don't destroy and recreate.

Testing with terraform test

Terraform ships a native testing framework (generally available since 1.6, with provider/resource mocking added in 1.7; OpenTofu has an equivalent tofu test). Tests live in *.tftest.hcl files and let you assert on plan or apply results without a separate language. This is how you validate modules before publishing them.

  • .tftest.hcl files -- Test files sit beside your module (or in tests/). Run them with terraform test; each file contains one or more run blocks executed in order.
  • run blocks & assertions -- Each run does a plan or apply and checks assert { condition = ... }. command = plan is fast and hits no cloud; command = apply provisions real (or mocked) resources then tears them down.
  • Mocks & overrides -- mock_provider fakes an entire provider so unit tests run offline and free; override_resource/override_data pin specific values. No cloud credentials or spend required for logic tests.
  • CI gating -- Run fmt -check, validate, test, and a security scan (tfsec/Checkov) on every pull request. Fail the build on any test or policy violation before the plan is ever reviewed.

A test that mocks the provider (no AWS account needed) and asserts on the plan:

# tests/bucket.tftest.hcl
mock_provider "aws" {}

run "bucket_name_is_prefixed" {
  command = plan

  variables {
    environment  = "prod"
    bucket_names = ["logs"]
  }

  assert {
    condition     = aws_s3_bucket.app["logs"].bucket == "josenobile-prod-logs"
    error_message = "Bucket name was not prefixed with the environment."
  }
}

CI/CD Integration

The safe pattern: run plan automatically on every pull request and post the diff as a comment; run apply only on merge to the main branch, using short-lived cloud credentials via OIDC (never long-lived keys in secrets). Layer policy-as-code on top so risky changes are blocked automatically.

PIPELINE

Plan on PR, apply on merge

On pull request: fmt -check, validate, test, then plan with the output surfaced for review. On merge to main: apply the saved plan. This gives reviewers the exact diff and keeps humans out of the credential path.

PIPELINE

OIDC, not static keys

GitHub Actions and GitLab CI can assume a cloud IAM role via OIDC, so no long-lived AWS/GCP keys sit in CI secrets. Scope the role to exactly the resources that pipeline manages, and use a distinct role per environment.

PIPELINE

Policy as code

Gate applies with OPA/Conftest or Sentinel against the plan JSON (terraform show -json tfplan): block public buckets, untagged resources, or oversized instances. Add tfsec/Checkov for static security scanning before plan.

PIPELINE

Remote runners

Atlantis (self-hosted) or a TACOS platform (HCP Terraform, Spacelift, Scalr, env0) centralizes runs, enforces locking, and adds an approval UI. Works with either the terraform or tofu binary.

# .github/workflows/terraform.yml (plan on PR, apply on merge)
permissions:
  id-token: write   # OIDC -> assume the cloud role, no static keys
  contents: read
  pull-requests: write

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3   # or opentofu/setup-opentofu@v1
      - run: terraform init
      - run: terraform fmt -check -recursive
      - run: terraform validate
      - run: terraform plan -out=tfplan
      - if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan

Terraform vs Ansible vs Pulumi

These tools overlap but solve different problems. Terraform/OpenTofu are declarative provisioners for cloud infrastructure. Ansible is a procedural configuration-management and orchestration tool. Pulumi is a declarative provisioner like Terraform but you write it in a general-purpose programming language. Many teams use two together -- Terraform to build the servers and network, Ansible to configure what runs on them.

Terraform / OpenTofu

Declarative HCL, explicit state file, huge provider ecosystem (AWS, GCP, Azure, Kubernetes, and thousands more). Best for provisioning and managing the full lifecycle of cloud resources. The de-facto standard for IaC.

Ansible

Procedural, agentless (SSH), no state file. Excels at configuration management -- installing packages, editing files, running commands on existing hosts -- and app deployment orchestration. Weaker at tracking and reconciling cloud resource lifecycles than a state-based tool.

Pulumi

Declarative like Terraform but authored in TypeScript, Python, Go, or C# -- real loops, types, and IDE support instead of HCL. Great when teams want to reuse a general-purpose language and its test tooling; smaller ecosystem and community than Terraform/OpenTofu.

More Guides