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
- Declarative (khai báo) — bạn mô tả trạng thái đích mong muốn (“một VPC, ba subnet, một cluster EKS với hai node group”) và tool tự tính toán các hành động cần thực hiện. Terraform, CloudFormation, và manifest Kubernetes đều là declarative. Reconciliation và phát hiện drift đến một cách tự nhiên.
- Imperative (mệnh lệnh) — bạn mô tả các bước (“tạo VPC, rồi tạo subnet, rồi…”). Shell script và lệnh cloud CLI thô là imperative. Thứ tự và xử lý lỗi là việc của bạn; chạy lại hiếm khi an toàn.
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.
| Imperative | Declarative | |
|---|---|---|
| Bạn chỉ định | Các bước | Trạng thái đích |
| Chạy lại | Thường không an toàn / bị nhân đôi | An toàn (no-op nếu đã converge) |
| Xử lý drift | Thủ công | Phá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ỗi | Tiếp tục từ chỗ hỏng (khó) | Re-apply từ bất kỳ đâu |
Provisioning vs configuration
| Khía cạnh | Provisioning | Configuration 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ượng | VPC, VM, load balancer, database, IAM | Package, file cấu hình, service, user |
| Tool điển hình | Terraform, OpenTofu, Pulumi, CloudFormation | Ansible, Chef, Puppet, Salt |
| Vòng đời | Tạ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
- 01viết file .tf
- 02terraform init
- 03terraform plan
- 04review
- 05terraform apply
- Providers — plugin ánh xạ resource HCL sang API thật (aws, azurerm, google, kubernetes, github, datadog — hàng nghìn provider trên registry).
terraform inittải chúng về và ghi version chính xác vào.terraform.lock.hcl, file mà bạn commit. - State — Terraform ghi lại những gì nó đã tạo vào state file, ánh xạ config với ID resource ngoài đời thật. Plan là một phép diff ba chiều: configuration (thứ bạn muốn) vs state (thứ Terraform nghĩ là tồn tại) vs thực tế trên cloud (phát hiện qua refresh).
- Plan/apply —
plantính ra execution plan (create/update/replace/destroy);applythực thi nó. Hãy lưu plan ra file (terraform plan -out=tfplan) và apply đúng file đó, để CI apply chính xác những gì đã được review. - Modules — nhóm resource tái sử dụng được, có tham số (
module "vpc" { source = "terraform-aws-modules/vpc/aws" ... }). Module là cách tổ chức mã hóa các “golden path”. - Workspaces — nhiều state file cho cùng một configuration (hữu ích cho môi trường tạm thời hoặc per-developer; nhiều team lại thích tách thư mục root riêng cho từng môi trường để cô lập mạnh hơn và tránh cái bẫy “nhầm workspace”).
- Remote state & locking — state lưu trong backend dùng chung (S3, Azure Blob, GCS, Terraform Cloud/HCP) kèm locking (native S3 lockfile, DynamoDB, hoặc backend tự lo) để hai lần apply đồng thời không làm hỏng state.
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ĩa | Rủi ro |
|---|---|---|
+ | tạo mới | Thấp |
~ | cập nhật tại chỗ | Thấp–vừa |
- | hủy | Cao — đặ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 vi | Ghi chú |
|---|---|---|---|
| Terraform | HCL | Đa cloud + SaaS | Chuẩn de facto; registry provider khổng lồ; license BUSL từ 2023 |
| OpenTofu | HCL | Đa cloud + SaaS | Fork 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 |
| Pulumi | TypeScript, Python, Go, C#, Java, YAML | Đa cloud + SaaS | Ngôn ngữ lập trình thật — vòng lặp, test, hỗ trợ IDE; engine vẫn diff theo desired state |
| CloudFormation | YAML/JSON | Chỉ AWS | Native AWS; state do AWS quản lý (stack); có sẵn drift detection; StackSets cho multi-account |
| AWS CDK | TypeScript, Python, v.v. | AWS (synthesize ra CloudFormation) | Code imperative → template declarative; construct đóng gói best practice |
| Bicep | Bicep DSL | Chỉ Azure | Bản thay thế sạch hơn cho ARM JSON của Azure; transpile ra ARM |
| Crossplane | Kubernetes CRD | Đa cloud | Hướ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:
- Một cloud duy nhất, muốn tool “chính chủ” và không phải quản lý thêm state → CloudFormation/CDK (AWS) hoặc Bicep (Azure).
- Đa cloud, hoặc muốn hệ sinh thái provider và cộng đồng lớn nhất → Terraform/OpenTofu.
- Team muốn tính năng ngôn ngữ thật (vòng lặp, unit test, abstraction) hơn là một DSL → Pulumi hoặc CDK.
- Bạn đang xây một internal platform nơi các app team yêu cầu hạ tầng qua API kiểu Kubernetes và muốn reconcile liên tục → Crossplane.
- Bạn cần tránh license BUSL hoặc muốn mã hóa state có sẵn → OpenTofu.
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ó:
- Secret nằm trong state. State lưu mọi thuộc tính dưới dạng plaintext, kể cả mật khẩu database, key được sinh ra, và private key. Hãy mã hóa backend, siết chặt quyền truy cập (bucket state nhạy cảm ngang credentials của bạn), và ưu tiên tham chiếu tới secret manager thay vì secret inline.
- Apply đồng thời không có locking. Hai pipeline apply cùng lúc sẽ làm hỏng state. Luôn dùng backend có locking; đừng bao giờ tắt locking để “fix” một lock bị kẹt mà không xác nhận là thực sự không có lần apply nào đang chạy (
terraform force-unlocklà phương án cuối cùng, không phải thói quen). - Sửa tay và drift từ console. Thay đổi trên console vô hình cho đến lần plan tiếp theo, và plan có thể tìm cách revert chúng. Phát hiện drift định kỳ (
terraform plantheo lịch); coi thay đổi ngoài luồng là một sự cố, không phải thói quen. - State nguyên khối. Một state file khổng lồ khiến mọi plan chậm chạp và mọi sai lầm trở thành thảm họa. Tách state theo môi trường và theo blast radius (tầng network / data / app), nối các tầng bằng remote state data source hoặc output tường minh.
- Refactor mà không dùng
moved/import. Đổi tên resource hoặc chuyển nó vào một module trông giống destroy+create với Terraform. Dùng blockmoved(an toàn, nằm trong code, review được), blockimport, hoặcterraform state mvđể refactor mà không hủy gì. - Mất hoặc hỏng state. Mất state file khiến resource bị “mồ côi” (vẫn tồn tại trên cloud nhưng Terraform không còn biết đến). Bật versioning cho bucket backend và backup nó để có thể roll ngược một lần ghi state tồi.
terraform applykhông có plan đã lưu trong CI. Nếu CI plan lại lúc apply, nó có thể apply thứ khác với thứ đã được review (thế giới đã thay đổi ở giữa). Hãy apply một file plan đã lưu.
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:
- Kiểm tra tĩnh —
terraform 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. - Review plan — chạy
terraform plantrong 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-destroyvàforces replacementvới con mắt nghi ngờ. - Policy as code — tự động đánh giá plan theo quy tắc của tổ chức:
- OPA / Rego (với
conftesthoặ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”.
- OPA / Rego (với
- 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 / Rego | Sentinel | |
|---|---|---|
| License | Mã nguồn mở (CNCF) | Độc quyền (HashiCorp) |
| Phạm vi | Bất kỳ JSON nào — Terraform, Kubernetes, CI, API | Terraform Cloud/Enterprise |
| Ngôn ngữ | Rego (khai báo, giống truy vấn) | Sentinel (hơi imperative) |
| Mức enforcement | Bạn tự nối vào | Có sẵn: advisory / soft-mandatory / hard-mandatory |
| Phù hợp khi | Policy đa công cụ, stack mở | Dồn toàn bộ vào Terraform Cloud/Enterprise |
Best Practices
- 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.
- 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ỉ.
- 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.
- 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.
- 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. - 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.
- 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.
- 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 đó.
- 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ô.
- 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. - Ư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. - Soi kỹ mọi
forces replacementvàdestroytrong 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. - 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.
- 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
- roadmap.sh — DevOps Roadmap
- Tài liệu Terraform
- Terraform Registry (provider & module)
- Terraform — state
- Terraform — test framework
- Tài liệu OpenTofu
- Tài liệu Pulumi
- Tài liệu AWS CloudFormation
- Tài liệu AWS CDK
- Tài liệu Azure Bicep
- Tài liệu Crossplane
- Open Policy Agent
- Conftest
- HashiCorp Sentinel
- Checkov
- tflint
- Terratest
- Sách: Terraform: Up & Running — Yevgeniy Brikman
- Sách: Infrastructure as Code — Kief Morris
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
- Declarative — you describe the desired end state (“one VPC, three subnets, an EKS cluster with two node groups”) and the tool computes the actions needed to get there. Terraform, CloudFormation, and Kubernetes manifests are declarative. Reconciliation and drift detection come naturally.
- Imperative — you describe the steps (“create VPC, then create subnet, then…”). Shell scripts and raw cloud CLI calls are imperative. Order and error handling are your problem; re-running is rarely safe.
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.
| Imperative | Declarative | |
|---|---|---|
| You specify | The steps | The end state |
| Re-running | Often unsafe / duplicates | Safe (no-op if already converged) |
| Drift handling | Manual | Detected on next plan |
| Mental effort | ”How do I build this?" | "What should exist?” |
| Failure recovery | Resume from where it broke (hard) | Re-apply from anywhere |
Provisioning vs configuration
| Concern | Provisioning | Configuration management |
|---|---|---|
| Question | ”Does this resource exist with these properties?" | "Is the software on this machine set up correctly?” |
| Objects | VPCs, VMs, load balancers, databases, IAM | Packages, config files, services, users |
| Typical tools | Terraform, OpenTofu, Pulumi, CloudFormation | Ansible, Chef, Puppet, Salt |
| Lifecycle | Create/update/destroy resources | Converge 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
- 01write .tf files
- 02terraform init
- 03terraform plan
- 04review
- 05terraform apply
- Providers — plugins that map HCL resources to real APIs (aws, azurerm, google, kubernetes, github, datadog — thousands exist in the registry).
terraform initdownloads them and records exact versions in.terraform.lock.hcl, which you commit. - State — Terraform records what it created in a state file, mapping config to real-world resource IDs. The plan is a three-way diff: configuration (what you want) vs state (what Terraform thinks exists) vs actual cloud reality (discovered by refresh).
- Plan/apply —
plancomputes an execution plan (create/update/replace/destroy);applyexecutes it. Save the plan to a file (terraform plan -out=tfplan) and apply that exact file, so CI applies precisely what was reviewed. - Modules — reusable, parameterized groups of resources (
module "vpc" { source = "terraform-aws-modules/vpc/aws" ... }). Modules are how organizations encode golden paths. - Workspaces — multiple state files for one configuration (useful for ephemeral or per-developer environments; many teams prefer separate root directories per environment instead, for stronger isolation and to avoid the “wrong workspace” foot-gun).
- Remote state & locking — state stored in a shared backend (S3, Azure Blob, GCS, Terraform Cloud/HCP) with locking (native S3 lockfile, DynamoDB, or backend-native) so two applies cannot corrupt state concurrently.
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:
| Symbol | Meaning | Risk |
|---|---|---|
+ | create | Low |
~ | update in place | Low–medium |
- | destroy | High — 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
| Tool | Language | Scope | Notes |
|---|---|---|---|
| Terraform | HCL | Multi-cloud + SaaS | De facto standard; huge provider registry; BUSL license since 2023 |
| OpenTofu | HCL | Multi-cloud + SaaS | Open-source (MPL) fork of Terraform under the Linux Foundation; drop-in compatible for most configs, adds features like client-side state encryption |
| Pulumi | TypeScript, Python, Go, C#, Java, YAML | Multi-cloud + SaaS | Real programming languages — loops, tests, IDE support; engine still diffs desired state |
| CloudFormation | YAML/JSON | AWS only | Native AWS; state managed by AWS (stacks); drift detection built in; StackSets for multi-account |
| AWS CDK | TypeScript, Python, etc. | AWS (synthesizes CloudFormation) | Imperative code → declarative template; constructs encapsulate best practices |
| Bicep | Bicep DSL | Azure only | Azure’s cleaner replacement for ARM JSON templates; transpiles to ARM |
| Crossplane | Kubernetes CRDs | Multi-cloud | Control-plane approach: infrastructure as Kubernetes resources, continuously reconciled; good for platform teams building internal APIs |
When to reach for which:
- Single cloud, want the vendor’s blessed tool and no extra state to manage → CloudFormation/CDK (AWS) or Bicep (Azure).
- Multi-cloud, or you want the largest provider ecosystem and community → Terraform/OpenTofu.
- Your team wants real language features (loops, unit tests, abstractions) more than a DSL → Pulumi or CDK.
- You are building an internal platform where app teams request infra through Kubernetes-style APIs and want continuous reconciliation → Crossplane.
- You need to avoid the BUSL license or want state encryption out of the box → OpenTofu.
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:
- Secrets in state. State stores all attributes in plaintext, including database passwords, generated keys, and private keys. Encrypt the backend, restrict access tightly (the state bucket is as sensitive as your credentials), and prefer references to secret managers over inline secrets.
- Concurrent applies without locking. Two pipelines applying at once corrupt state. Always use a locking backend; never disable locking to “fix” a stuck lock without confirming no apply is actually running (
terraform force-unlockis a last resort, not a habit). - Manual edits and console drift. Changes made in the console are invisible until the next plan, which may then try to revert them. Detect drift regularly (
terraform planon a schedule); make out-of-band changes an incident, not a habit. - Monolithic state. One giant state file makes every plan slow and every mistake catastrophic. Split state by environment and by blast radius (network / data / app layers), wiring layers together with remote state data sources or explicit outputs.
- Refactoring without
moved/import. Renaming a resource or moving it into a module looks like destroy+create to Terraform. Usemovedblocks (safe, in-code, reviewable),importblocks, orterraform state mvto refactor without destroying anything. - Lost or corrupted state. Losing the state file orphans resources (they exist in the cloud but Terraform no longer knows about them). Version the backend bucket, enable object versioning, and back it up so you can roll back a bad state write.
terraform applywith no saved plan in CI. If CI re-plans at apply time, it may apply something different from what was reviewed (the world changed in between). Apply a saved plan file.
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:
- Static checks —
terraform 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. - Plan review — run
terraform planin CI on every PR and post the plan as a comment; a human (and a policy engine) reviews exactly what will change. Treat-destroylines andforces replacementwith suspicion. - Policy as code — evaluate plans against organizational rules automatically:
- OPA / Rego (with
conftestor 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”.
- OPA / Rego (with
- 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 / Rego | Sentinel | |
|---|---|---|
| License | Open source (CNCF) | Proprietary (HashiCorp) |
| Reach | Any JSON — Terraform, Kubernetes, CI, APIs | Terraform Cloud/Enterprise |
| Language | Rego (declarative, query-like) | Sentinel (imperative-ish) |
| Enforcement levels | You wire them up | Built-in: advisory / soft-mandatory / hard-mandatory |
| Best when | Multi-tool policy, open stack | All-in on Terraform Cloud/Enterprise |
Best Practices
- 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.
- 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.
- 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.
- Split state by environment and blast radius. Small, layered states make plans fast, reviews readable, and mistakes survivable; wire layers with remote state outputs.
- Pin versions everywhere. Terraform/OpenTofu version, provider versions, and module versions — and commit the
.terraform.lock.hcllock file — so plans are reproducible and an upstream release cannot silently change your infrastructure. - 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.
- 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.
- 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.
- 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.
- Refactor with
moved/importblocks, never by editing state blindly. Make renames and restructures reviewable in code so they cannot turn into an accidental destroy. - 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_destroylifecycle guards. - Scrutinize every
forces replacementanddestroyin a plan. These are where data loss and downtime live; a plan review that skims past them is not a review. - 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.
- 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
- roadmap.sh — DevOps Roadmap
- Terraform documentation
- Terraform Registry (providers & modules)
- Terraform — state
- Terraform — test framework
- OpenTofu documentation
- Pulumi documentation
- AWS CloudFormation documentation
- AWS CDK documentation
- Azure Bicep documentation
- Crossplane documentation
- Open Policy Agent
- Conftest
- HashiCorp Sentinel
- Checkov
- tflint
- Terratest
- Book: Terraform: Up & Running — Yevgeniy Brikman
- Book: Infrastructure as Code — Kief Morris