Terraform Configuration Language Basics
Learn HCL syntax, expressions, functions, and how to write maintainable Terraform configurations
TLDR: Terraform uses HCL (HashiCorp Configuration Language), which looks like JSON but is more readable. You write blocks (like resource, variable, output) with arguments and nested blocks. Use interpolation ${} to reference other values, and built-in functions for string manipulation, file reading, and data transformation.
Terraform's configuration language (HCL) is designed to be human-readable while remaining machine-friendly. Understanding its syntax and features helps you write cleaner, more maintainable infrastructure code.
Basic Syntax
HCL looks similar to JSON but uses a more relaxed syntax. Here's a resource definition:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
}
}
Breaking down the syntax:
- Blocks start with a type (
resource), optional labels ("aws_instance","web"), and contain a body in braces - Arguments assign values to names (
ami = "ami-0c55b159cbfafe1f0") - Strings use double quotes
- Maps/Objects use braces with key-value pairs
- Lists use brackets:
["item1", "item2"] - Comments use
#for single lines or/* */for multiple lines
Block Types
Terraform has several block types, each serving a specific purpose:
Resource Blocks
Resources are the main block type - they define infrastructure objects:
resource "resource_type" "local_name" {
argument1 = "value1"
argument2 = "value2"
nested_block {
nested_argument = "value"
}
}
Variable Blocks
Variables let you parameterize your configuration:
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
Output Blocks
Outputs expose values after Terraform runs:
output "instance_ip" {
description = "Public IP of the instance"
value = aws_instance.web.public_ip
}
Locals Blocks
Locals define intermediate values used in your configuration:
locals {
common_tags = {
Project = "web-app"
Environment = "production"
ManagedBy = "terraform"
}
instance_name = "${var.project}-${var.environment}-web"
}
Expressions and Interpolation
You can reference other values and compute new ones using expressions.
References
Access resource attributes using resource_type.name.attribute:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type # Reference a variable
subnet_id = aws_subnet.public.id # Reference another resource
}
Reference types:
var.name- input variableslocal.name- local valuesresource_type.name.attribute- resource attributesdata.data_type.name.attribute- data source attributesmodule.module_name.output_name- module outputs
String Interpolation
Use ${} to interpolate expressions into strings:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "${var.project}-web-${var.environment}" # Interpolation
}
}
For simple references, you can omit the interpolation syntax:
# These are equivalent
subnet_id = aws_subnet.public.id
subnet_id = "${aws_subnet.public.id}"
Use interpolation when combining multiple values:
name = "${var.prefix}-server-${count.index}"
Arithmetic and Comparison
Terraform supports basic arithmetic and comparison operators:
locals {
# Arithmetic
total_instances = var.web_count + var.api_count
memory_gb = var.memory_mb / 1024
# Comparison
is_production = var.environment == "production"
needs_backup = var.environment != "development"
large_instance = var.cpu_count >= 4
}
Operators include:
- Arithmetic:
+,-,*,/,% - Comparison:
==,!=,<,>,<=,>= - Logical:
&&,||,!
Conditional Expressions
Use ternary operators for conditional logic:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
monitoring = var.environment == "production" ? true : false
}
The syntax is condition ? true_value : false_value.
Built-in Functions
Terraform provides many functions for string manipulation, data transformation, and file operations. You call them without a namespace:
locals {
uppercase_name = upper(var.project_name)
file_content = file("${path.module}/config.json")
merged_tags = merge(var.common_tags, var.specific_tags)
}
String Functions
Manipulate and format strings:
locals {
# Change case
upper_env = upper(var.environment) # "PRODUCTION"
lower_env = lower(var.environment) # "production"
# Split and join
name_parts = split("-", "web-app-server") # ["web", "app", "server"]
full_name = join("-", ["web", "app", var.environment])
# Templates and formatting
greeting = format("Hello, %s!", var.username)
padded = format("%05d", 42) # "00042"
# Substring operations
short_id = substr(var.resource_id, 0, 8)
trimmed = trim(var.user_input, " ")
}
Collection Functions
Work with lists and maps:
locals {
# Lists
all_subnets = concat(var.public_subnets, var.private_subnets)
unique_zones = distinct(var.availability_zones)
first_zone = element(var.availability_zones, 0)
zone_count = length(var.availability_zones)
# Maps
combined_tags = merge(
var.common_tags,
{
Name = var.instance_name
}
)
tag_keys = keys(var.tags)
tag_vals = values(var.tags)
# Lookup with default
size = lookup(var.instance_sizes, var.environment, "t3.micro")
}
File Functions
Read files and paths:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
# Read a file's content
user_data = file("${path.module}/scripts/init.sh")
}
locals {
# Path references
module_path = path.module # Path to the current module
root_path = path.root # Path to the root module
# Read and parse JSON
config = jsondecode(file("${path.module}/config.json"))
# Read and parse YAML
settings = yamldecode(file("${path.module}/settings.yaml"))
# Template files with variables
rendered = templatefile("${path.module}/template.tpl", {
hostname = var.hostname
port = var.port
})
}
Encoding Functions
Convert between formats:
locals {
# JSON encoding/decoding
json_string = jsonencode({
name = "web-server"
port = 8080
})
config_object = jsondecode(file("config.json"))
# YAML encoding/decoding
yaml_string = yamlencode(var.configuration)
# Base64 encoding/decoding
encoded_secret = base64encode(var.api_key)
decoded_secret = base64decode(var.encoded_value)
}
Dynamic Blocks
Sometimes you need to generate repeated nested blocks based on data. Dynamic blocks let you do this:
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
# Without dynamic blocks - repetitive
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# With dynamic blocks - cleaner
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
variable "allowed_ports" {
type = list(number)
default = [80, 443, 8080]
}
The dynamic block iterates over a collection. Inside the content block, you access the current item with block_name.value (or block_name.key for maps).
For more complex data:
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = [
{
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP traffic"
},
{
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS traffic"
}
]
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
}
For Expressions
Transform and filter collections using for expressions:
locals {
# Create a list from another list
uppercase_names = [for name in var.server_names : upper(name)]
# Filter a list
production_servers = [
for server in var.servers : server
if server.environment == "production"
]
# Create a map from a list
server_map = {
for server in var.servers : server.name => server.ip
}
# Transform and filter
large_instance_names = [
for name, config in var.instances : name
if config.size == "large"
]
}
Real-world example:
variable "servers" {
type = list(object({
name = string
environment = string
size = string
}))
}
locals {
# Get production server names
prod_names = [
for s in var.servers : s.name
if s.environment == "production"
]
# Build tags for each server
server_tags = {
for s in var.servers : s.name => {
Name = s.name
Environment = s.environment
Size = s.size
ManagedBy = "terraform"
}
}
}
Type Constraints
Specify types for variables to catch errors early:
variable "instance_count" {
type = number
description = "Number of instances to create"
default = 1
}
variable "instance_type" {
type = string
description = "EC2 instance type"
default = "t3.micro"
}
variable "enable_monitoring" {
type = bool
description = "Enable detailed monitoring"
default = false
}
variable "availability_zones" {
type = list(string)
description = "AZs to deploy into"
default = ["us-east-1a", "us-east-1b"]
}
variable "tags" {
type = map(string)
description = "Resource tags"
default = {}
}
variable "server_config" {
type = object({
name = string
size = string
port = number
})
description = "Server configuration"
}
variable "subnet_configs" {
type = list(object({
cidr = string
az = string
name = string
}))
description = "Subnet configurations"
}
Available types:
- Primitive:
string,number,bool - Complex:
list(type),set(type),map(type),object({...}),tuple([...]) - Special:
any(accepts any type)
Comments and Documentation
Write clear comments to explain non-obvious decisions:
# Create VPC with DNS support enabled
# We need DNS hostnames for RDS endpoints
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true # Required for RDS
enable_dns_support = true
tags = {
Name = "main-vpc"
}
}
/*
Security group for web tier
Allows inbound HTTP and HTTPS from anywhere
Allows outbound traffic to database tier only
Note: Don't allow SSH (22) from 0.0.0.0/0 in production
*/
resource "aws_security_group" "web" {
name = "web-sg"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
# ... rules ...
}
Use variable descriptions to document inputs:
variable "instance_type" {
type = string
description = "EC2 instance type. Use t3.micro for dev, t3.medium for prod"
default = "t3.micro"
}
Practical Example: Building a Web Server
Here's a complete example combining these concepts:
# Variables for customization
variable "environment" {
type = string
description = "Environment name (dev, staging, prod)"
}
variable "project" {
type = string
default = "webapp"
}
variable "allowed_ssh_ips" {
type = list(string)
description = "IPs allowed to SSH"
default = []
}
# Local values
locals {
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
instance_name = "${var.project}-${var.environment}-web"
}
# Security group with dynamic ingress rules
resource "aws_security_group" "web" {
name = "${local.instance_name}-sg"
description = "Security group for ${local.instance_name}"
vpc_id = aws_vpc.main.id
# HTTP and HTTPS for everyone
dynamic "ingress" {
for_each = [80, 443]
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow ${ingress.value == 80 ? "HTTP" : "HTTPS"}"
}
}
# SSH only for specified IPs
dynamic "ingress" {
for_each = length(var.allowed_ssh_ips) > 0 ? [1] : []
content {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_ssh_ips
description = "SSH access"
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound"
}
tags = merge(
local.common_tags,
{
Name = "${local.instance_name}-sg"
}
)
}
# Web server instance
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = local.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
subnet_id = aws_subnet.public.id
user_data = templatefile("${path.module}/init.sh", {
hostname = local.instance_name
environment = var.environment
})
tags = merge(
local.common_tags,
{
Name = local.instance_name
}
)
}
# Output useful information
output "instance_id" {
value = aws_instance.web.id
description = "ID of the web server instance"
}
output "public_ip" {
value = aws_instance.web.public_ip
description = "Public IP address"
}
output "ssh_command" {
value = "ssh ubuntu@${aws_instance.web.public_ip}"
description = "Command to SSH into the instance"
}
This example demonstrates:
- Variables with type constraints and defaults
- Local values for computed and shared values
- Conditional expressions for environment-specific settings
- Dynamic blocks for flexible resource configuration
- String interpolation and functions
- Merging maps for tags
- Template files for user data
- Clear outputs with descriptions
Understanding these language features lets you write flexible, maintainable Terraform configurations. Next, we'll explore resources and data sources in more depth, seeing how to create and query infrastructure.
Found an issue?