Terraform #01 — Fundamentals: HCL, Providers, Resources & Lifecycle
HCL syntax, providers and aliases, resource vs data source, resource lifecycle, and the core init → plan → apply → destroy workflow.
Goal: Get comfortable with HCL syntax, the resource lifecycle, and the core
init → plan → apply → destroyworkflow. This is the foundation everything else builds on.
1.1. HCL — HashiCorp Configuration Language
Terraform uses HCL, a declarative language: you describe the desired state, not the steps to get there.
Basic block structure
<BLOCK_TYPE> "<LABEL_1>" "<LABEL_2>" {
<ARGUMENT> = <VALUE>
<NESTED_BLOCK> {
...
}
}A real example:
resource "aws_instance" "web" {
# ▲ ▲ ▲
# | | └── local name — you choose this
# | └────────── resource type — defined by the AWS provider
# └───────────────────── block type
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}The resource address is
aws_instance.web— you use this when referencing it in code or in CLI commands.
HCL data types
string = "hello"
number = 42
bool = true
list = ["a", "b", "c"] # ordered
set = toset(["a", "b"]) # unordered, no duplicates
map = { env = "prod", tier = 1 } # key-value pairs
object = { name = "x", port = 80 }
tuple = ["a", 1, true] # mixed-type list
null = null # "no value" → provider uses its defaultComments
# single-line (most common)
// also single-line
/* multi-line
comment */1.2. The main block types
| Block | Purpose |
|---|---|
terraform { } | Core config: version constraints, required providers, backend |
provider "x" { } | Configure a provider (AWS, Azure...) |
resource "type" "name" { } | Create and manage a real resource |
data "type" "name" { } | Read existing resource info (read-only) |
variable "name" { } | Input parameters (Module 03) |
output "name" { } | Exported values (Module 03) |
locals { } | Local computed values (Module 03) |
module "name" { } | Call a child module (Module 06) |
The terraform block — always include this
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}Version constraint syntax:
| Syntax | Meaning |
|---|---|
= 5.31.0 | exactly this version |
>= 5.0 | 5.0 or higher |
~> 5.0 | >= 5.0 and < 6.0 (no major bumps) |
~> 5.31.0 | >= 5.31.0 and < 5.32.0 (patch only) |
>= 5.0, < 5.40 | range |
In production, use
~>to avoid auto-upgrading to a breaking major version.
1.3. Providers
Providers are plugins that let Terraform talk to a specific platform's API (AWS, GCP, Kubernetes, GitHub, Datadog...). There are over 3000 on registry.terraform.io.
provider "aws" {
region = "us-east-1"
}Provider aliases — multiple configs for the same provider
Useful when deploying to multiple regions at once:
provider "aws" {
region = "us-east-1" # default provider
}
provider "aws" {
alias = "tokyo"
region = "ap-northeast-1"
}
resource "aws_s3_bucket" "us" {
bucket = "my-app-us"
# uses the default provider
}
resource "aws_s3_bucket" "jp" {
bucket = "my-app-jp"
provider = aws.tokyo # uses the alias
}1.4. Resource vs Data Source
resource "aws_instance" "web" {...} → Terraform CREATES/UPDATES/DELETES
data "aws_ami" "ubuntu" {...} → Terraform only READS (existing info)
Data sources let you look up existing things instead of hardcoding IDs:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # reference the data source
instance_type = "t3.micro"
}1.5. Resource lifecycle
Terraform figures out what to do by comparing config ↔ state ↔ actual infrastructure:
+ create : in config, not in state → create new
~ update : difference between config and reality → update in-place
-/+ replace : changed an attribute that can't be → destroy + recreate
updated in-place
- destroy : removed from config → delete
The lifecycle meta-argument
resource "aws_instance" "web" {
# ...
lifecycle {
create_before_destroy = true # create the new one before destroying the old (zero-downtime)
prevent_destroy = true # block accidental destroy (good for production DBs)
ignore_changes = [tags] # ignore changes to this attribute (e.g. tags managed by another tool)
# Terraform 1.2+: fail early if a condition isn't met
precondition {
condition = var.instance_type != ""
error_message = "instance_type cannot be empty."
}
}
}⚠️
prevent_destroy = truemakesterraform destroyfail entirely. Great for databases, but remember to remove it when you actually want to delete the resource.
Other meta-arguments
depends_on— force ordering when Terraform can't infer it (Module 04).count/for_each— create multiple copies (Module 05).provider— specify a provider alias.
1.6. Core workflow
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ init │──►│ fmt │──►│ validate │──►│ plan │──►│ apply │
│ download │ │ format │ │ check │ │ preview │ │ execute │
│ plugins │ │ code │ │ syntax │ │ changes │ │ changes │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│
┌──────────┐
│ destroy │
│ clean up │
└──────────┘
| Command | What it does | When to use |
|---|---|---|
terraform init | Download providers/modules, initialize backend | First time, or after changing providers/backend |
terraform fmt | Auto-format code | Before every commit |
terraform validate | Check syntax and references (no API calls) | In CI, before plan |
terraform plan | Calculate and display pending changes | Before every apply |
terraform apply | Execute the changes | When you're sure about the plan |
terraform destroy | Remove all managed resources | Cleanup |
terraform show | Show state or a saved plan | Debugging |
terraform state list | List resources in state | Debugging |
Production tip: save the plan, apply that exact plan
terraform plan -out=tfplan # save plan to file
terraform apply tfplan # apply EXACTLY the reviewed planThis is a CI/CD best practice: review the plan in one step, apply it in the next — avoids the situation where things change between plan and apply.
1.7. .terraform.lock.hcl — Dependency lock file
When you run init, Terraform creates .terraform.lock.hcl recording the exact versions and checksums of providers downloaded.
- ✅ Commit this file to Git — ensures your whole team and CI use the same provider versions.
- Update when needed:
terraform init -upgrade. - Add hashes for multiple platforms:
terraform providers lock -platform=....
1.8. Common mistakes when starting out
| Symptom | Cause | Fix |
|---|---|---|
Error: Inconsistent dependency lock file | Changed provider without re-running init | terraform init -upgrade |
| Plan always wants to "replace" | Changed an attribute that forces replacement (e.g. availability_zone) | Check docs for "Forces new resource" |
Error acquiring the state lock | Previous run crashed, lock wasn't released | terraform force-unlock <LOCK_ID> (be careful) |
| Hardcoded AMI not found in region | AMIs are region-specific | Use data "aws_ami" instead |
| Resource unexpectedly recreated | count/for_each index shift | Use for_each with stable keys (Module 05) |
1.9. Hands-on Labs
Go to hands-on/ and follow LAB.md:
- Lab 1 — Create multiple local files + use the
randomprovider (no cloud needed). - Lab 2 — (Optional, needs LocalStack/AWS) Create your first S3 bucket.
- Break-it challenge — Change a force-replacement attribute and watch the plan.
✅ Module 01 completion criteria
- Can explain the difference between
resourceanddata. - Can read
+,~,-/+,-symbols interraform planoutput. - Know when to use
create_before_destroyandprevent_destroy. - Understand why
.terraform.lock.hclshould be committed. - Completed Lab 1 and the break-it challenge.
➡️ Next: Module 02 — State Management