Terraform Basics: HCL, Providers, and Your First Resource
Learn the foundations of Terraform: writing HCL, configuring providers, managing state, and creating your first cloud resource the right way.
What you'll learn
- ✓What HCL syntax looks like and how to read it
- ✓How providers and resources fit together
- ✓How Terraform state tracks the real world
- ✓How to use variables and outputs cleanly
- ✓How plan, apply, and destroy actually work
Prerequisites
- •Comfortable on the command line
- •Familiar with cloud concepts — see What is AWS
Infrastructure as code is the practice of describing cloud resources in text files that you check into git, review, and apply. Terraform is the most common tool for it. The language is HCL, the unit of work is a resource, and the engine compares your code to a state file and figures out the minimum changes needed. This post walks through the parts and builds a small example.
Install and verify
Install via your package manager or the official tarball.
brew install terraform
terraform -v
You should see a version near 1.9.x or newer. The CLI is the entry point for every operation.
The shape of a config
Terraform reads every .tf file in the current directory and treats them as one module. A minimal config has three sections.
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "logs" {
bucket = "codeloom-logs-2026"
tags = {
Environment = "prod"
Owner = "platform"
}
}
terraformblock — pins the CLI version and lists the providers you need.providerblock — configures one provider, here AWS in a region.resourceblock — declares a piece of infrastructure. The labels areTYPE NAME.aws_s3_bucketis the type,logsis the local name you use to reference it.
Initialize
Run terraform init once per directory. It downloads provider plugins and sets up the backend.
terraform init
You will see a .terraform directory and a .terraform.lock.hcl file. Commit the lock file so teammates get the same provider versions.
Plan and apply
terraform plan shows what will change. terraform apply makes it happen.
terraform plan -out tfplan
terraform apply tfplan
The plan output uses three symbols:
+create-destroy~modify in place
If something would be destroyed and recreated, you see -/+. Read every plan before applying. Treat surprise destroys as a stop-the-line event.
State, the source of truth
Terraform records what it created in a state file (terraform.tfstate). On the next run, it compares state, config, and live infrastructure to compute the diff. The local file is fine for solo experiments but dangerous for teams. Use a remote backend like S3 plus DynamoDB locking, or HCP Terraform.
terraform {
backend "s3" {
bucket = "codeloom-tfstate"
key = "prod/network.tfstate"
region = "us-east-1"
dynamodb_table = "codeloom-tflock"
encrypt = true
}
}
Never edit state by hand. Use terraform state subcommands when you must.
Variables and outputs
Hardcoded values are fine until they are not. Use variables for things that change between environments and outputs for values you need to share.
variable "region" {
type = string
default = "us-east-1"
}
variable "bucket_name" {
type = string
description = "Globally unique S3 bucket name"
}
output "bucket_arn" {
value = aws_s3_bucket.logs.arn
}
Set values via a terraform.tfvars file:
bucket_name = "codeloom-logs-2026"
Or via the command line: terraform apply -var bucket_name=foo.
References and dependencies
Terraform builds a dependency graph automatically from references. If resource B mentions aws_s3_bucket.logs.arn, B will be created after the bucket. You almost never need depends_on — only when the relationship is hidden from the graph (IAM policies, eventual consistency).
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
Modules
A module is a folder of .tf files. The root directory is one. Call other modules with module blocks.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "codeloom-vpc"
cidr = "10.0.0.0/16"
}
Use modules for parts you reuse across projects. Do not over-modularize early; flat configs are easier to read until you have proven duplication.
Lifecycle controls
Inside a resource, the lifecycle block gives you safety knobs.
resource "aws_db_instance" "primary" {
identifier = "primary"
lifecycle {
prevent_destroy = true
create_before_destroy = true
ignore_changes = [password]
}
}
prevent_destroy stops terraform destroy and replacement plans for that resource. Use it on stateful systems.
A realistic workflow
A team workflow looks like:
- Open a branch, edit
.tffiles. - Run
terraform fmtandterraform validatelocally. - Push and let CI run
terraform planagainst a per-branch workspace. - Review the plan in the pull request comment.
- Merge and let CI run
terraform applyagainst the target environment.
This is just CI/CD applied to infrastructure. The plan is the artifact, the apply is the deploy.
Destroying
For sandbox environments, terraform destroy tears everything down. Always run a plan -destroy first, especially against shared accounts.
terraform plan -destroy -out destroy.plan
terraform apply destroy.plan
Common mistakes
- Editing infrastructure in the cloud console after Terraform owns it. The next plan will try to revert your change. Either bring the change into code or remove the resource from state.
- Mixing many environments in one root module. Use workspaces or separate directories per environment.
- Storing secrets in
.tffiles. Use a secret manager and reference values via data sources.
Wrap up
Terraform is a small language and a big idea. HCL describes the world you want. Providers know how to talk to APIs. State is the ledger. Plan and apply are the verbs. Once you have a remote backend, a CI pipeline, and the habit of reading every plan, infrastructure stops being a thing that “someone configured once” and becomes the same kind of artifact as your application code.