Skip to content
>_higan
This post has a translation:Read in Tiếng Việt

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 → destroy workflow. 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 default

Comments

# single-line (most common)
// also single-line
/* multi-line
   comment */

1.2. The main block types

BlockPurpose
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:

SyntaxMeaning
= 5.31.0exactly this version
>= 5.05.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.40range

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 configstateactual 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 = true makes terraform destroy fail 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 │
                                                             └──────────┘
CommandWhat it doesWhen to use
terraform initDownload providers/modules, initialize backendFirst time, or after changing providers/backend
terraform fmtAuto-format codeBefore every commit
terraform validateCheck syntax and references (no API calls)In CI, before plan
terraform planCalculate and display pending changesBefore every apply
terraform applyExecute the changesWhen you're sure about the plan
terraform destroyRemove all managed resourcesCleanup
terraform showShow state or a saved planDebugging
terraform state listList resources in stateDebugging

Production tip: save the plan, apply that exact plan

terraform plan -out=tfplan      # save plan to file
terraform apply tfplan          # apply EXACTLY the reviewed plan

This 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

SymptomCauseFix
Error: Inconsistent dependency lock fileChanged provider without re-running initterraform 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 lockPrevious run crashed, lock wasn't releasedterraform force-unlock <LOCK_ID> (be careful)
Hardcoded AMI not found in regionAMIs are region-specificUse data "aws_ami" instead
Resource unexpectedly recreatedcount/for_each index shiftUse for_each with stable keys (Module 05)

1.9. Hands-on Labs

Go to hands-on/ and follow LAB.md:

  1. Lab 1 — Create multiple local files + use the random provider (no cloud needed).
  2. Lab 2 — (Optional, needs LocalStack/AWS) Create your first S3 bucket.
  3. Break-it challenge — Change a force-replacement attribute and watch the plan.

✅ Module 01 completion criteria

  • Can explain the difference between resource and data.
  • Can read +, ~, -/+, - symbols in terraform plan output.
  • Know when to use create_before_destroy and prevent_destroy.
  • Understand why .terraform.lock.hcl should be committed.
  • Completed Lab 1 and the break-it challenge.

➡️ Next: Module 02 — State Management

Share: