Day 09: HCL (HashiCorp Configuration Language) — The Terraform Syntax Guide I Wish I Had.

Abdul RaheemAbdul Raheem
4 min read

Terraform uses HCL—a clean, declarative language—to describe infrastructure. Once you understand HCL’s building blocks, every Terraform repo starts to make sense.

HCL at a glance

  • Blocks: TYPE "LABEL" "LABEL" { ... }

  • Arguments: key = value

  • Expressions: references (var.region, local.tags), functions (merge(), format()), conditionals, loops.

  • Types: string, number, bool, list(...), set(...), map(...), object({...}), tuple([...])

  • Comments: #, //, or /* ... */


1) terraform block

Declares provider dependencies, Terraform version, and (optionally) backend.

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = { source = "hashicorp/aws",   version = "6.9.0" }
    random = { source = "hashicorp/random", version = "3.7.2" }
  }

  # backend "s3" { ... }  # optional: store tfstate remotely
}

Why: pins versions for reproducibility.


2) provider block

Configures how Terraform talks to a cloud.

provider "aws" {
  region = var.region
  # alias = "west"  # use aliases to configure multiple regions/accounts
}

Pro tip: use env vars (AWS_PROFILE, AWS_ACCESS_KEY_ID…) for credentials.


3) variable blocks

Inputs you pass to your configuration.

variable "region" {
  description = "AWS region"
  type        = string
  default     = "ap-south-1"
}

variable "bucket_name_prefix" {
  description = "Prefix for S3 bucket name"
  type        = string
  validation {
    condition     = length(var.bucket_name_prefix) >= 3
    error_message = "Prefix must be at least 3 chars."
  }
}

Tips:

  • sensitive = true hides values in CLI output.

  • TF_VAR_region=ap-south-1 terraform apply can set vars via env.


4) locals block

Reusable computed values.

locals {
  name_prefix = "hcl101"
  common_tags = {
    Project = "Terraform-HCL"
    Owner   = "Abdul Raheem"
    Env     = "dev"
  }
}

Why: keeps code DRY.


5) data blocks (read existing things)

Query cloud for read-only info (no creation).

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter { name = "name", values = ["amzn2-ami-hvm-*-x86_64-gp2"] }
  filter { name = "virtualization-type", values = ["hvm"] }
}

Why: no hardcoded IDs; always fetch latest AMI, VPC, SG, etc.


6) resource blocks (create/update things)

Declaratively manage infrastructure.

resource "random_id" "suffix" {
  byte_length = 2
}

resource "aws_s3_bucket" "demo" {
  bucket = "${var.bucket_name_prefix}-${random_id.suffix.hex}"

  tags = merge(local.common_tags, { Name = "${local.name_prefix}-bucket" })

  lifecycle {
    prevent_destroy = false
    ignore_changes  = []   # specify attributes to ignore if needed
  }
}

Meta-arguments you should know:

  • count / for_each → multiple resources

  • depends_on → explicit dependency

  • lifecyclecreate_before_destroy, prevent_destroy, ignore_changes

  • (Provisioners exist, but prefer cloud-native bootstrapping via user data)


7) output blocks

Expose values after apply.

output "bucket_name" {
  value       = aws_s3_bucket.demo.bucket
  description = "Created S3 bucket name"
}

output "ec2_public_ip" {
  value     = aws_instance.demo.public_ip
  sensitive = false
}

8) module blocks

Package and reuse infra (from local path, Git, or Registry).

# Example (disabled by default). Set count=1 to use.
module "vpc" {
  count  = 0
  source = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "${local.name_prefix}-vpc"
  cidr = "10.1.0.0/16"

  azs             = ["ap-south-1a","ap-south-1b"]
  public_subnets  = ["10.1.1.0/24","10.1.2.0/24"]
  enable_dns_hostnames = true
  tags = local.common_tags
}

Module anatomy:

  • Inputs → variables the module expects

  • Resources → what it creates

  • Outputs → what it returns

  • Versioning → always pin version for reproducibility


9) Expressions you’ll use daily

  • Conditionals: var.env == "prod" ? "m5.large" : "t3.micro"

  • For-expressions: { for k, v in var.tags : upper(k) => v }

  • Functions: merge(), coalesce(), join(), format(), file(), length()

  • Dynamic blocks (generate nested blocks in loops)


✨ Mini Demo (complete main.tf below)

  • Variables → region, bucket_name_prefix

  • Locals → tags & name prefix

  • Data → latest Amazon Linux AMI

  • Resources → random suffix, S3 bucket, EC2 with Nginx via user data

  • Outputs → bucket name & public IP

(See GitHub section for the ready-to-run file.)


Key takeaways

  • HCL is block + arguments + expressions.

  • Use variables, locals, data to keep configs clean and dynamic.

  • Modules make infra reusable and production-grade.

  • Prefer user data/cloud-init over provisioners.

  • Pin versions and use backends for teamwork.


Follow my journey

0
Subscribe to my newsletter

Read articles from Abdul Raheem directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abdul Raheem
Abdul Raheem

Cloud DevOps | AWS | Terraform | CI/CD | Obsessed with clean infrastructure. Cloud DevOps Engineer 🚀 | Automating Infrastructure & Securing Pipelines | Bridging Gaps Between Code and Cloud ☁️ I’m on a mission to master DevOps from the ground up—building scalable systems, automating workflows, and integrating security into every phase of the SDLC. Currently working with AWS, Terraform, Docker, CI/CD, and learning the art of cloud-native development.