Terraform Fundamentals

HCL Syntax & First Resources

18 min Lesson 2 of 30

HCL Syntax & First Resources

Terraform is configured entirely in HashiCorp Configuration Language (HCL) — a declarative, human-readable language purpose-built for infrastructure. Before you can write meaningful infrastructure code, you need to internalize HCL's three core constructs: blocks, arguments, and expressions. Once those click, you will also need to master the four commands that constitute Terraform's core workflow: init, plan, apply, and destroy. Every Terraform action you ever take in production is a variation on these fundamentals.

Blocks, Arguments, and Expressions

Everything in an HCL file is a block. A block has a type, zero or more labels, and a body enclosed in braces. Inside the body, you assign values to named arguments. Those values can be literal strings, numbers, booleans, lists, maps, or full expressions — references to other values, function calls, or conditional logic.

# The anatomy of an HCL block: # # block_type "label_one" "label_two" { # argument_name = expression # } # # Concrete examples: # A "resource" block with two labels: resource type + local name resource "aws_s3_bucket" "app_artifacts" { bucket = "mycompany-app-artifacts-${var.environment}" tags = { Team = "platform" Environment = var.environment ManagedBy = "terraform" } } # A "variable" block with one label: the variable name variable "environment" { type = string description = "Deployment environment (dev, staging, prod)" default = "dev" validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be dev, staging, or prod." } } # A "locals" block (no labels) grouping computed values locals { bucket_name = "mycompany-app-artifacts-${var.environment}" common_tags = { ManagedBy = "terraform" Environment = var.environment } }

Key block types you will use daily: terraform (version constraints and backend config), provider (configure a cloud API), resource (declare a real infrastructure object), data (read existing infrastructure), variable (accept inputs), output (expose values), locals (intermediate computations), and module (call reusable modules).

Expressions in Depth

HCL expressions are more powerful than they look. You can reference any resource attribute, call built-in functions, use conditionals, and iterate with for expressions — all inside an argument value.

# String interpolation — embed any expression inside "${ }" resource "aws_iam_role" "lambda_exec" { name = "lambda-exec-${var.environment}-${local.service_name}" # Heredoc for multi-line strings (common for JSON policies) assume_role_policy = <<-EOF { "Version": "2012-10-17", "Statement": [{ "Action": "sts:AssumeRole", "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" } }] } EOF } # Conditional expression: condition ? true_val : false_val resource "aws_instance" "web" { instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro" ami = data.aws_ami.amazon_linux_2023.id # For-expression to build a list from a map security_groups = [for sg in var.security_group_ids : sg if sg != ""] } # Function calls — Terraform has 100+ built-in functions locals { region_short = substr(var.aws_region, 0, 6) # "us-eas" encoded = base64encode(file("${path.module}/certs/ca.pem")) merged_tags = merge(local.common_tags, { Role = "web" }) }
References always follow the pattern type.name.attribute. A resource reference like aws_s3_bucket.app_artifacts.arn tells Terraform to look up the arn attribute of the aws_s3_bucket resource named app_artifacts. This reference also encodes an implicit dependency: Terraform will not create anything that references this bucket until the bucket itself exists. Understanding this is the foundation of correct resource ordering.

The Core Workflow: init, plan, apply, destroy

Terraform operations are always deterministic and predictable when you follow the four-step workflow. This is not a suggestion — in production, you must never run apply without first reviewing a plan.

Terraform core workflow: init, plan, apply, destroy terraform init download providers init backend terraform plan diff: desired vs state read-only, safe terraform apply create / update write state when done terraform destroy remove all resources update state once per workspace review before every apply mutates infrastructure non-prod / teardown only
The four commands of the Terraform core workflow. Always review plan output before apply; never skip this step in production.

Here is a complete walkthrough. Create a directory, write your first resource, and run all four commands:

# 1. Scaffold a minimal project mkdir tf-demo && cd tf-demo # main.tf — declares provider + one S3 bucket cat > main.tf <<'EOF' terraform { required_version = ">= 1.7" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-east-1" } resource "aws_s3_bucket" "demo" { bucket = "tf-demo-hcl-syntax-2025" tags = { Name = "tf-demo" ManagedBy = "terraform" } } output "bucket_arn" { value = aws_s3_bucket.demo.arn description = "ARN of the demo bucket" } EOF # 2. Init — downloads the AWS provider (~20 MB), sets up .terraform/ terraform init # 3. Plan — shows EXACTLY what will happen. Review carefully. # + = create ~ = update in-place - = destroy -/+ = replace terraform plan # 4. Apply — executes the plan. Requires manual confirmation ("yes") terraform apply # Skip confirmation in CI (only after plan has been reviewed and saved): terraform plan -out=tfplan terraform apply tfplan # applies exactly the saved plan, no prompt # 5. Destroy — tears down everything Terraform manages in this workspace # NEVER run this on production without a detailed review terraform destroy

What init Actually Does

terraform init performs three tasks: it downloads provider plugins into .terraform/providers/, initializes the configured backend (local by default — just a terraform.tfstate file), and installs any module sources. You must re-run init any time you change provider versions, add a new provider, or change the backend configuration. The .terraform.lock.hcl file it generates pins exact provider checksums — commit this file to Git so every team member and every CI run uses identical provider versions.

Always save plans in CI. In a pipeline, never run terraform apply -auto-approve against a freshly computed plan. The correct pattern is: terraform plan -out=tfplan in the plan stage, store the tfplan artifact, then terraform apply tfplan in the apply stage (gated by a manual approval step). This guarantees that what you reviewed in the plan is exactly what gets applied — no race conditions if infrastructure changed between stages.

Reading Plan Output

The plan output is the most important output in all of Terraform. Every senior engineer reads it word for word before approving an apply. The summary line tells you the action counts (Plan: 3 to add, 1 to change, 0 to destroy), but the detail block tells you what changes on each attribute. A ~ (update) on a resource attribute is safe; a -/+ (replacement) means the resource will be destroyed and recreated, often causing downtime if you are not careful.

Replacements are the most dangerous Terraform operation. When you see -/+ resource "aws_db_instance" "main" (forces replacement), Terraform will delete the database and create a new one. The old data is gone unless you have a snapshot. Several changes force replacement on critical resources: renaming an RDS instance, changing the engine version of an ElastiCache cluster, modifying the subnet_ids of an EKS cluster. Before applying any plan that contains a replacement, verify you have a backup and a tested restore procedure. In production, use lifecycle { prevent_destroy = true } on stateful resources to make Terraform error rather than silently replace them.

File Organisation Conventions

Terraform loads all *.tf files in a directory as a single configuration. The community convention — and what you will see in every open-source module — is to split concerns across named files: main.tf for resources, variables.tf for input declarations, outputs.tf for output declarations, providers.tf for provider and terraform blocks, and locals.tf for local value blocks. This is not enforced by the tool, but violating the convention will make your code unreadable to every other Terraform practitioner on your team.

In the next lesson you will dive into providers and versioning — how the registry works, how to pin and upgrade providers safely, and how to configure multiple provider instances (multiple AWS regions, multiple accounts) in a single root module.

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!