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
Table of Contents
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
.tffiles 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.tfstateon 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 planis a dry run. Review the diff -- especially destroys and replacements -- beforeapplytouches 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.
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.
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.
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_providersand pinned with a version constraint; downloaded byterraform init. - Resources & data sources --
resourceblocks create/manage objects;datablocks read existing ones. References likeaws_vpc.main.idcreate dependencies automatically. - Variables -- Typed inputs (
variable) with defaults, descriptions, andvalidationblocks. Set via.tfvars,-var, orTF_VAR_*environment variables. - Outputs & locals --
outputexposes values (and feeds module composition);localsname 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.
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.
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.
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 init → plan → apply. 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 withterraform test; each file contains one or morerunblocks executed in order. - run blocks & assertions -- Each
rundoes aplanorapplyand checksassert { condition = ... }.command = planis fast and hits no cloud;command = applyprovisions real (or mocked) resources then tears them down. - Mocks & overrides --
mock_providerfakes an entire provider so unit tests run offline and free;override_resource/override_datapin 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.
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.
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.
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.
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 tfplanTerraform 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.