← DevOps← DevOps
DevOpsDevOps18 Th7, 2026Jul 18, 202618 phút đọc15 min read

Infrastructure as CodeInfrastructure as Code

Thuộc bộ tài liệu DevOps Roadmap.

Tổng quan

Infrastructure as Code (IaC) là thực hành định nghĩa hạ tầng — network, compute, database, DNS, IAM policy — trong các file định nghĩa mà máy đọc được, nằm trong version control, thay vì provision bằng cách click qua console hay chạy lệnh thủ công rời rạc. Định nghĩa hạ tầng trở thành nguồn chân lý (source of truth): review được qua pull request, test được trong pipeline, tái tạo được giữa các môi trường, và audit được qua lịch sử git.

IaC giải quyết các “bệnh kinh niên” của vận hành: server “snowflake” không ai dám đụng vào, drift giữa staging và production, kiến thức truyền miệng kiểu “hồi đó em setup thì nó chạy”, và kế hoạch disaster recovery chỉ tồn tại trên giấy. Khi hạ tầng là code, dựng lại một môi trường chỉ cách một lệnh terraform apply, và mọi thay đổi đều có tác giả, người review, và một bản diff.

Với DevOps engineer, IaC là nền móng: đó là cách pipeline CI/CD có môi trường để deploy vào, cách platform team cung cấp hạ tầng self-service, và cách tổ chức thực thi chính sách bảo mật và chi phí ngay tại thời điểm provision thay vì chữa cháy sau đó.

Mô hình tư duy: desired state và reconciliation

Ý tưởng cốt lõi làm IaC trở nên mạnh mẽ là reconciliation theo desired state. Bạn viết ra thế giới nên trông như thế nào; tool kiểm tra thế giới đang thực sự trông như thế nào; nó tính toán và áp dụng phần chênh lệch. Đây chính là vòng lặp vận hành các controller Kubernetes và GitOps. Khi đã thấm ý này, mọi tool IaC đều trở thành một biến thể của một câu hỏi: “desired state được lưu ở đâu, và làm sao tool biết current state?” Terraform lưu current state trong một state file; CloudFormation lưu trong AWS; Kubernetes lưu trong etcd; Crossplane lưu trong etcd và reconcile liên tục. Mọi khác biệt về trải nghiệm đều bắt nguồn từ một lựa chọn thiết kế đó.

Kiến thức nền tảng

Declarative vs imperative

Các tool hiện đại làm mờ ranh giới này: Pulumi và CDK cho phép bạn viết code trông có vẻ imperative (vòng lặp, hàm, class) nhưng sinh ra một desired state khai báo, để engine diff và apply. Code imperative chạy để dựng lên một graph; graph mới là thứ được reconcile. Đó là lý do một chương trình Pulumi vẫn idempotent dù trông giống một script.

ImperativeDeclarative
Bạn chỉ địnhCác bướcTrạng thái đích
Chạy lạiThường không an toàn / bị nhân đôiAn toàn (no-op nếu đã converge)
Xử lý driftThủ côngPhát hiện ở lần plan tiếp theo
Nỗ lực tư duy”Làm sao dựng cái này?""Cái gì nên tồn tại?”
Khôi phục khi lỗiTiếp tục từ chỗ hỏng (khó)Re-apply từ bất kỳ đâu

Provisioning vs configuration

Khía cạnhProvisioningConfiguration management
Câu hỏi”Resource này có tồn tại với các thuộc tính này không?""Phần mềm trên máy này đã được cấu hình đúng chưa?”
Đối tượngVPC, VM, load balancer, database, IAMPackage, file cấu hình, service, user
Tool điển hìnhTerraform, OpenTofu, Pulumi, CloudFormationAnsible, Chef, Puppet, Salt
Vòng đờiTạo/cập nhật/hủy resourceĐưa hệ thống đang chạy về đúng trạng thái mong muốn

Hai loại này bổ trợ nhau: Terraform tạo VM; Ansible (hoặc image đã bake sẵn, hoặc container) làm cho VM đó hữu dụng. Trong môi trường cloud-native, phần configuration ngày càng “biến mất” vào container image và managed service, để provisioning trở thành hoạt động IaC chủ đạo.

Workflow cốt lõi của Terraform

Quy trình Terraform
  1. 01viết file .tf
  2. 02terraform init
  3. 03terraform plan
  4. 04review
  5. 05terraform apply

Các động từ của plan — hãy đọc chúng cẩn thận

Mỗi dòng trong một plan là một trong các loại sau, và những loại nguy hiểm xứng đáng được dừng lại kỹ khi review:

Ký hiệuÝ nghĩaRủi ro
+tạo mớiThấp
~cập nhật tại chỗThấp–vừa
-hủyCao — đặc biệt với resource chứa state
-/+hủy rồi tạo lại (replace)Cao — có thể downtime và mất dữ liệu
<=đọc (data source)Không

Cụm forces replacement bên cạnh một thuộc tính là điều quan trọng nhất cần để ý trong một plan: nó có nghĩa là không thể thay đổi tại chỗ và Terraform sẽ xóa rồi tạo lại resource. Với một database hay persistent volume, đó là một sự kiện mất dữ liệu ẩn trong một bản diff trông có vẻ bình thường.

Ví dụ Terraform

terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "ap-southeast-1"
    use_lockfile = true          # khóa state native trên S3 (TF 1.10+)
    encrypt      = true
  }
}

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

  name            = "prod-vpc"
  cidr            = "10.0.0.0/16"
  azs             = ["ap-southeast-1a", "ap-southeast-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
  single_nat_gateway = true

  tags = { Environment = "prod", ManagedBy = "terraform" }
}

resource "aws_s3_bucket" "artifacts" {
  bucket = "acme-prod-artifacts"
  tags   = { Environment = "prod" }
}

# Bảo vệ một resource chứa state khỏi bị hủy nhầm
resource "aws_db_instance" "main" {
  identifier     = "acme-prod-db"
  engine         = "postgres"
  instance_class = "db.t3.medium"
  # ... các đối số khác ...
  lifecycle {
    prevent_destroy = true       # apply sẽ fail thay vì xóa DB
  }
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

Nối các tầng với nhau bằng remote state

Các hệ thống lớn tách thành các stack phân tầng (network → data → app), mỗi tầng có state riêng. Tầng dưới đọc output của tầng trên qua remote state data source:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-terraform-state"
    key    = "prod/network/terraform.tfstate"
    region = "ap-southeast-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
  # ...
}

Cách này giữ blast radius nhỏ: một thay đổi ở tầng app không thể vô tình hủy network, và mỗi lần plan vẫn nhanh vì nó chỉ refresh resource của riêng nó.

Khái niệm chính

Bức tranh công cụ

Công cụNgôn ngữPhạm viGhi chú
TerraformHCLĐa cloud + SaaSChuẩn de facto; registry provider khổng lồ; license BUSL từ 2023
OpenTofuHCLĐa cloud + SaaSFork mã nguồn mở (MPL) của Terraform dưới Linux Foundation; tương thích gần như trọn vẹn, thêm tính năng như mã hóa state phía client
PulumiTypeScript, Python, Go, C#, Java, YAMLĐa cloud + SaaSNgôn ngữ lập trình thật — vòng lặp, test, hỗ trợ IDE; engine vẫn diff theo desired state
CloudFormationYAML/JSONChỉ AWSNative AWS; state do AWS quản lý (stack); có sẵn drift detection; StackSets cho multi-account
AWS CDKTypeScript, Python, v.v.AWS (synthesize ra CloudFormation)Code imperative → template declarative; construct đóng gói best practice
BicepBicep DSLChỉ AzureBản thay thế sạch hơn cho ARM JSON của Azure; transpile ra ARM
CrossplaneKubernetes CRDĐa cloudHướng control-plane: hạ tầng là resource Kubernetes, được reconcile liên tục; phù hợp cho platform team xây API nội bộ

Khi nào chọn cái nào:

Chuyện tách license Terraform / OpenTofu

Năm 2023 HashiCorp đổi license Terraform từ MPL (mã nguồn mở) sang BUSL (source-available, có hạn chế với việc dùng thương mại cạnh tranh). Cộng đồng fork phiên bản MPL cuối cùng thành OpenTofu, nay là một dự án của Linux Foundation. Với đa số người dùng, hai bên tương thích drop-in (tofu thay cho terraform), và OpenTofu về sau đã có những tính năng Terraform chưa có, nổi bật là mã hóa state phía client. Khi chọn, hãy cân nhắc: độ trưởng thành của hệ sinh thái/công cụ (Terraform vẫn lớn hơn), ràng buộc license với doanh nghiệp của bạn, và bạn cần tính năng nào.

Những cạm bẫy khi quản lý state

State là “gót chân Achilles” của Terraform — hầu hết sự cố production với IaC đều truy về nó:

Ví dụ: đổi tên an toàn với block moved

# Bạn đổi tên aws_instance.web → aws_instance.frontend.
# Không có block này, Terraform sẽ hủy "web" và tạo "frontend".
moved {
  from = aws_instance.web
  to   = aws_instance.frontend
}

Terraform coi đây là “cùng một resource, địa chỉ mới” — không hủy, không downtime.

Testing IaC

Kiểm chứng theo tầng, rẻ nhất chạy trước:

  1. Kiểm tra tĩnhterraform fmt -check, terraform validate, linter (tflint), và scanner bảo mật (trivy, checkov, tfsec) bắt lỗi cú pháp, pattern lỗi thời và cấu hình sai (S3 bucket public, security group mở toang, volume không mã hóa) trước khi gọi bất kỳ API nào.
  2. Review plan — chạy terraform plan trong CI cho mỗi PR và post plan làm comment; con người (và một policy engine) review chính xác những gì sẽ thay đổi. Nhìn các dòng -destroyforces replacement với con mắt nghi ngờ.
  3. Policy as code — tự động đánh giá plan theo quy tắc của tổ chức:
    • OPA / Rego (với conftest hoặc OPA trực tiếp) — policy engine mã nguồn mở, đa dụng.
    • Sentinel — ngôn ngữ policy của HashiCorp, tích hợp trong Terraform Cloud/Enterprise.
    • Ví dụ quy tắc: “mọi resource phải có tag Owner”, “không security group nào được mở 0.0.0.0/0 trên port 22”, “instance type giới hạn trong danh sách được duyệt”, “S3 bucket phải bật mã hóa”.
  4. Integration test — dựng hạ tầng thật (hoặc tạm thời), assert trên đó bằng Terratest (Go) hoặc terraform test (native, viết bằng HCL từ Terraform 1.6), rồi destroy. Chậm và tốn kém nhất, nên dành cho các module tái sử dụng và luồng quan trọng.
# policy/deny_public_ssh.rego (OPA/conftest)
package terraform.security

deny[msg] {
  r := input.resource_changes[_]
  r.type == "aws_security_group_rule"
  r.change.after.cidr_blocks[_] == "0.0.0.0/0"
  r.change.after.to_port == 22
  msg := sprintf("%s cho phép SSH từ internet", [r.address])
}

deny[msg] {
  r := input.resource_changes[_]
  r.type == "aws_s3_bucket"
  not r.change.after.server_side_encryption_configuration
  msg := sprintf("%s phải bật mã hóa", [r.address])
}

Chạy nó với một plan JSON: terraform show -json tfplan | conftest test -.

Ví dụ terraform test native

# tests/vpc.tftest.hcl
run "creates_private_subnets" {
  command = plan

  assert {
    condition     = length(module.vpc.private_subnets) == 2
    error_message = "cần đúng hai private subnet"
  }
}

Policy as code: OPA vs Sentinel

OPA / RegoSentinel
LicenseMã nguồn mở (CNCF)Độc quyền (HashiCorp)
Phạm viBất kỳ JSON nào — Terraform, Kubernetes, CI, APITerraform Cloud/Enterprise
Ngôn ngữRego (khai báo, giống truy vấn)Sentinel (hơi imperative)
Mức enforcementBạn tự nối vàoCó sẵn: advisory / soft-mandatory / hard-mandatory
Phù hợp khiPolicy đa công cụ, stack mởDồn toàn bộ vào Terraform Cloud/Enterprise

Best Practices

  1. Lưu toàn bộ IaC trong version control và chỉ thay đổi qua pull request. Lịch sử git là audit log; review chính là quy trình duyệt thay đổi; không gì thay đổi production mà không có một bản diff.
  2. Dùng remote state với locking và mã hóa ngay từ ngày đầu. State local trên laptop là điểm chết đơn (single point of failure), một rào cản cộng tác, và một file secret không mã hóa chờ ngày rò rỉ.
  3. Không bao giờ để secret trong code hay chỉ dựa vào việc state được giấu kín. Lấy secret từ secret manager (Vault, AWS Secrets Manager, SSM) lúc apply/runtime; dù vậy vẫn coi backend state là nhạy cảm, vì secret được sinh ra vẫn nằm trong state.
  4. Tách state theo môi trường và blast radius. State nhỏ, phân tầng giúp plan nhanh, review dễ đọc, và sai lầm còn cứu vãn được; nối các tầng bằng remote state output.
  5. Pin version ở mọi nơi. Version của Terraform/OpenTofu, provider và module — và commit file lock .terraform.lock.hcl — để plan tái tạo được và một bản phát hành upstream không thể âm thầm thay đổi hạ tầng của bạn.
  6. Chạy plan trong CI và bắt buộc review trước khi apply — rồi apply đúng plan đã lưu. Không ai nên apply từ laptop vào môi trường dùng chung, và CI nên apply chính xác plan đã được duyệt.
  7. Thực thi policy as code. Quy tắc về tagging, mã hóa, phơi bày network và chi phí thuộc về các gate OPA/Sentinel chặn một plan tồi, không phải trang wiki không ai đọc.
  8. Cấm thay đổi qua console với resource đang được quản lý. Drift biến nguồn chân lý thành tiểu thuyết; phát hiện drift theo lịch và reconcile một cách chủ động thay vì để lần apply không liên quan sau đó revert bản sửa tay của ai đó.
  9. Thiết kế module với interface nhỏ và ổn định. Chỉ expose một số variable và output chọn lọc; tránh kiểu “module nhận 80 biến” còn khó dùng hơn cả resource thô.
  10. Refactor bằng block moved/import, không bao giờ sửa state một cách mù quáng. Làm cho việc đổi tên và tái cấu trúc review được trong code để chúng không biến thành một lệnh destroy vô tình.
  11. Ưu tiên tính bất biến (immutability) cho mọi thứ thay thế được, và bảo vệ phần còn lại. Thay instance stateless thay vì sửa tại chỗ; giữ resource chứa dữ liệu (database, volume) trong state riêng với lifecycle guard prevent_destroy.
  12. Soi kỹ mọi forces replacementdestroy trong plan. Đây là nơi mất dữ liệu và downtime cư ngụ; một buổi review plan lướt qua chúng thì không phải là review.
  13. Diễn tập khôi phục. Định kỳ dựng lại toàn bộ một môi trường từ code trong tài khoản sandbox — đó mới là bài test thật sự cho độ hoàn chỉnh của IaC và khả năng khôi phục state.
  14. Quét IaC tìm cấu hình sai bảo mật ngay trong pipeline. Phát hiện của Checkov/Trivy/tfsec sửa trước khi apply rẻ hơn rất nhiều so với sau khi bị xâm nhập, và chúng bắt được những lỗi con người bỏ sót khi review.

Tài liệu tham khảo

Part of the DevOps Roadmap knowledge base.

Overview

Infrastructure as Code (IaC) is the practice of defining infrastructure — networks, compute, databases, DNS, IAM policies — in machine-readable definition files that live in version control, instead of provisioning it by clicking through consoles or running ad-hoc commands. The infrastructure definition becomes the source of truth: reviewable in pull requests, testable in pipelines, reproducible across environments, and auditable through git history.

IaC solves the classic operations pathologies: snowflake servers nobody dares touch, environment drift between staging and production, “it worked when I set it up” tribal knowledge, and disaster recovery plans that exist only on paper. When infrastructure is code, rebuilding an environment is a terraform apply away, and every change has an author, a reviewer, and a diff.

For DevOps engineers, IaC is foundational: it is how CI/CD pipelines get environments to deploy into, how platform teams offer self-service infrastructure, and how organizations enforce security and cost policy at the point of provisioning rather than after the fact.

The mental model: desired state and reconciliation

The core idea that makes IaC powerful is desired-state reconciliation. You write down what the world should look like; the tool inspects what the world actually looks like; it computes and applies the difference. This is the same loop that powers Kubernetes controllers and GitOps. Once you internalize it, every IaC tool becomes a variation on one question: “where is the desired state stored, and how does the tool know the current state?” Terraform stores current state in a state file; CloudFormation stores it in AWS; Kubernetes stores it in etcd; Crossplane stores it in etcd and reconciles continuously. The differences in ergonomics all flow from that one design choice.

Fundamentals

Declarative vs imperative

Modern tools blur the line: Pulumi and CDK let you write imperative-looking code (loops, functions, classes) that produces a declarative desired state which the engine then diffs and applies. The imperative code runs to build a graph; the graph is what gets reconciled. This is why a Pulumi program is still idempotent even though it looks like a script.

ImperativeDeclarative
You specifyThe stepsThe end state
Re-runningOften unsafe / duplicatesSafe (no-op if already converged)
Drift handlingManualDetected on next plan
Mental effort”How do I build this?""What should exist?”
Failure recoveryResume from where it broke (hard)Re-apply from anywhere

Provisioning vs configuration

ConcernProvisioningConfiguration management
Question”Does this resource exist with these properties?""Is the software on this machine set up correctly?”
ObjectsVPCs, VMs, load balancers, databases, IAMPackages, config files, services, users
Typical toolsTerraform, OpenTofu, Pulumi, CloudFormationAnsible, Chef, Puppet, Salt
LifecycleCreate/update/destroy resourcesConverge running systems to a desired state

They complement each other: Terraform creates the VM; Ansible (or a baked image, or a container) makes it useful. In cloud-native setups, configuration increasingly disappears into container images and managed services, leaving provisioning as the dominant IaC activity.

Terraform core workflow

Terraform workflow
  1. 01write .tf files
  2. 02terraform init
  3. 03terraform plan
  4. 04review
  5. 05terraform apply

The plan verbs — read them carefully

Every line in a plan is one of these, and the dangerous ones deserve a full stop during review:

SymbolMeaningRisk
+createLow
~update in placeLow–medium
-destroyHigh — especially for stateful resources
-/+destroy then create (replace)High — downtime and data loss possible
<=read (data source)None

The phrase forces replacement next to an attribute is the single most important thing to notice in a plan: it means an in-place change is impossible and Terraform will delete and recreate the resource. For a database or persistent volume, that is a data-loss event hiding in a routine-looking diff.

Example Terraform snippet

terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "ap-southeast-1"
    use_lockfile = true          # native S3 state locking (TF 1.10+)
    encrypt      = true
  }
}

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

  name            = "prod-vpc"
  cidr            = "10.0.0.0/16"
  azs             = ["ap-southeast-1a", "ap-southeast-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
  single_nat_gateway = true

  tags = { Environment = "prod", ManagedBy = "terraform" }
}

resource "aws_s3_bucket" "artifacts" {
  bucket = "acme-prod-artifacts"
  tags   = { Environment = "prod" }
}

# Guard a stateful resource against accidental destruction
resource "aws_db_instance" "main" {
  identifier     = "acme-prod-db"
  engine         = "postgres"
  instance_class = "db.t3.medium"
  # ... other args ...
  lifecycle {
    prevent_destroy = true       # apply fails rather than deleting the DB
  }
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

Wiring layers together with remote state

Large estates split into layered stacks (network → data → app), each with its own state. Downstream layers read upstream outputs via a remote state data source:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-terraform-state"
    key    = "prod/network/terraform.tfstate"
    region = "ap-southeast-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
  # ...
}

This keeps blast radius small: a change to the app layer cannot accidentally destroy the network, and each plan stays fast because it only refreshes its own resources.

Key Concepts

Tool landscape

ToolLanguageScopeNotes
TerraformHCLMulti-cloud + SaaSDe facto standard; huge provider registry; BUSL license since 2023
OpenTofuHCLMulti-cloud + SaaSOpen-source (MPL) fork of Terraform under the Linux Foundation; drop-in compatible for most configs, adds features like client-side state encryption
PulumiTypeScript, Python, Go, C#, Java, YAMLMulti-cloud + SaaSReal programming languages — loops, tests, IDE support; engine still diffs desired state
CloudFormationYAML/JSONAWS onlyNative AWS; state managed by AWS (stacks); drift detection built in; StackSets for multi-account
AWS CDKTypeScript, Python, etc.AWS (synthesizes CloudFormation)Imperative code → declarative template; constructs encapsulate best practices
BicepBicep DSLAzure onlyAzure’s cleaner replacement for ARM JSON templates; transpiles to ARM
CrossplaneKubernetes CRDsMulti-cloudControl-plane approach: infrastructure as Kubernetes resources, continuously reconciled; good for platform teams building internal APIs

When to reach for which:

The Terraform / OpenTofu license split

In 2023 HashiCorp relicensed Terraform from MPL (open source) to BUSL (source-available, with restrictions on competing commercial use). The community forked the last MPL version as OpenTofu, now a Linux Foundation project. For most users the two are drop-in compatible (tofu replaces terraform), and OpenTofu has since shipped features Terraform lacks, notably client-side state encryption. When choosing, weigh: ecosystem/tooling maturity (Terraform still larger), licensing constraints for your business, and which features you need.

State management pitfalls

State is Terraform’s Achilles heel — most production incidents with IaC trace back to it:

Example: safe rename with a moved block

# You renamed aws_instance.web → aws_instance.frontend.
# Without this block, Terraform destroys "web" and creates "frontend".
moved {
  from = aws_instance.web
  to   = aws_instance.frontend
}

Terraform treats this as “same resource, new address” — no destroy, no downtime.

Testing IaC

Layered validation, cheapest first:

  1. Static checksterraform fmt -check, terraform validate, linters (tflint), and security scanners (trivy, checkov, tfsec) catch syntax errors, deprecated patterns, and misconfigurations (public S3 buckets, open security groups, unencrypted volumes) before any API call.
  2. Plan review — run terraform plan in CI on every PR and post the plan as a comment; a human (and a policy engine) reviews exactly what will change. Treat -destroy lines and forces replacement with suspicion.
  3. Policy as code — evaluate plans against organizational rules automatically:
    • OPA / Rego (with conftest or OPA directly) — open-source, general-purpose policy engine.
    • Sentinel — HashiCorp’s policy language, integrated into Terraform Cloud/Enterprise.
    • Example rules: “all resources must be tagged with Owner”, “no security group may allow 0.0.0.0/0 on port 22”, “instance types limited to an approved list”, “S3 buckets must have encryption enabled”.
  4. Integration tests — spin up real (or ephemeral) infrastructure and assert on it with Terratest (Go) or terraform test (native, HCL-based since Terraform 1.6), then destroy. Slowest and most expensive, so reserve for reusable modules and critical paths.
# policy/deny_public_ssh.rego (OPA/conftest)
package terraform.security

deny[msg] {
  r := input.resource_changes[_]
  r.type == "aws_security_group_rule"
  r.change.after.cidr_blocks[_] == "0.0.0.0/0"
  r.change.after.to_port == 22
  msg := sprintf("%s allows SSH from the internet", [r.address])
}

deny[msg] {
  r := input.resource_changes[_]
  r.type == "aws_s3_bucket"
  not r.change.after.server_side_encryption_configuration
  msg := sprintf("%s must have encryption enabled", [r.address])
}

Run it against a JSON plan: terraform show -json tfplan | conftest test -.

Native terraform test example

# tests/vpc.tftest.hcl
run "creates_private_subnets" {
  command = plan

  assert {
    condition     = length(module.vpc.private_subnets) == 2
    error_message = "expected exactly two private subnets"
  }
}

Policy as code: OPA vs Sentinel

OPA / RegoSentinel
LicenseOpen source (CNCF)Proprietary (HashiCorp)
ReachAny JSON — Terraform, Kubernetes, CI, APIsTerraform Cloud/Enterprise
LanguageRego (declarative, query-like)Sentinel (imperative-ish)
Enforcement levelsYou wire them upBuilt-in: advisory / soft-mandatory / hard-mandatory
Best whenMulti-tool policy, open stackAll-in on Terraform Cloud/Enterprise

Best Practices

  1. Store all IaC in version control and change it only via pull requests. The git history is your audit log; the review is your change-approval process; nothing changes production without a diff.
  2. Use remote state with locking and encryption from day one. Local state on a laptop is a single point of failure, a collaboration blocker, and an unencrypted secrets file waiting to leak.
  3. Never put secrets in code or rely on state secrecy alone. Pull secrets from a manager (Vault, AWS Secrets Manager, SSM) at apply/run time; treat the state backend as sensitive regardless, because generated secrets still land in state.
  4. Split state by environment and blast radius. Small, layered states make plans fast, reviews readable, and mistakes survivable; wire layers with remote state outputs.
  5. Pin versions everywhere. Terraform/OpenTofu version, provider versions, and module versions — and commit the .terraform.lock.hcl lock file — so plans are reproducible and an upstream release cannot silently change your infrastructure.
  6. Run plan in CI and require it to be reviewed before apply — then apply the saved plan. Nobody should apply from a laptop against shared environments, and CI should apply exactly the plan that was approved.
  7. Enforce policy as code. Tagging, encryption, network exposure, and cost rules belong in OPA/Sentinel gates that block a bad plan, not in wiki pages nobody reads.
  8. Prohibit console changes to managed resources. Drift turns your source of truth into fiction; detect it on a schedule and reconcile deliberately rather than letting the next unrelated apply revert someone’s manual fix.
  9. Design modules with small, stable interfaces. Expose a few well-chosen variables and outputs; resist the “module that takes 80 variables” that is harder to use than raw resources.
  10. Refactor with moved/import blocks, never by editing state blindly. Make renames and restructures reviewable in code so they cannot turn into an accidental destroy.
  11. Prefer immutability for anything replaceable, and guard the rest. Replace stateless instances rather than mutating them; keep data-bearing resources (databases, volumes) in their own state with prevent_destroy lifecycle guards.
  12. Scrutinize every forces replacement and destroy in a plan. These are where data loss and downtime live; a plan review that skims past them is not a review.
  13. Practice recovery. Periodically rebuild a full environment from code in a sandbox account — that is the only real test of whether your IaC is complete and your state is recoverable.
  14. Scan IaC for security misconfigurations in the pipeline. Checkov/Trivy/tfsec findings are far cheaper to fix pre-apply than post-breach, and they catch the mistakes humans miss in review.

References