DeepThought.sh
Infrastructure

Infrastructure as Code (IaC) Part 1: Introduction to Terraform

Part 1 of our Infrastructure as Code series, where we use Terraform and libvirt to build a 5-VM homelab and lay the foundation for practical, tool-based IaC workflows with real-world relevance.

Aaron Mathis
20 min read
Infrastructure as Code (IaC) Part 1: Introduction to Terraform

In this tutorial, we’ll explore Infrastructure as Code (IaC) fundamentals by using Terraform to deploy a 5-virtual machine cluster in your homelab environment. You’ll learn to create one master node and four worker nodes using libvirt and KVM, establishing the foundation for a future Kubernetes cluster deployment.

By following this hands-on guide, you’ll master core Terraform concepts including:

  • Providers and their role in infrastructure management
  • Resources as infrastructure building blocks
  • Variables for flexible, reusable configurations
  • Locals for computed values and complex expressions
  • Outputs for extracting useful information from your infrastructure

This practical approach teaches you to define infrastructure declaratively, manage state effectively, and create reproducible deployments—essential skills for modern DevOps and systems administration. Unlike imperative scripts that describe “how” to build infrastructure, you’ll learn Terraform’s declarative approach that focuses on “what” your infrastructure should look like.

As always, you can find all the code examples and configuration files in our GitHub repository.

Prerequisites

Before we begin, ensure your Linux machine has the following components installed and configured:

Required Software

  • Virtualization Stack: libvirt, qemu, virt-manager
  • KVM Support: Verify /dev/kvm exists and is accessible
  • Utility Tools: genisoimage, cloud-image-utils, unzip, net-tools
  • User Permissions: Your user account must be in the libvirt group

Installation Commands

The following commands assume Ubuntu or another Debian-based distribution. For RHEL/CentOS/Fedora systems, substitute dnf or yum for apt.

Install the virtualization stack and utilities:

sudo apt update && sudo apt install -y \
  qemu-kvm \
  libvirt-daemon-system \
  libvirt-clients \
  virtinst \
  bridge-utils \
  virt-manager \
  genisoimage \
  cloud-image-utils \
  net-tools \
  unzip

User Configuration

Add your current user to the required groups for libvirt access:

sudo usermod -aG libvirt,kvm "$USER"

Service Configuration

Ensure the libvirt daemon is running and enabled:

sudo systemctl enable --now libvirtd

Activate Group Membership

Apply the group changes to your current shell session:

newgrp libvirt

Installing Terraform

We’ll install Terraform using HashiCorp’s official APT repository, which ensures you receive authentic, up-to-date packages with proper GPG verification.

Step 1: Prepare Your System

Install required packages for repository management and GPG verification:

sudo apt-get update && sudo apt-get install -y gnupg software-properties-common

Step 2: Add HashiCorp’s GPG Key

Download and install HashiCorp’s GPG signing key:

wget -O- https://apt.releases.hashicorp.com/gpg | \
gpg --dearmor | \
sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null

Step 3: Verify GPG Key Integrity

Confirm the GPG key fingerprint matches HashiCorp’s official key:

gpg --no-default-keyring \
--keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg \
--fingerprint

The command should display output similar to this:

/usr/share/keyrings/hashicorp-archive-keyring.gpg
-------------------------------------------------
pub   rsa4096 XXXX-XX-XX [SC]
AAAA AAAA AAAA AAAA
uid         [ unknown] HashiCorp Security (HashiCorp Package Signing) <[email protected]>
sub   rsa4096 XXXX-XX-XX [E]

Step 4: Add HashiCorp Repository

Configure your system to use HashiCorp’s official APT repository:

# Add HashiCorp repository
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com \
$(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) \
main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

Step 5: Install Terraform

Update your package index and install Terraform:

sudo apt update

Install Terraform:

sudo apt-get install terraform

Verify Installation and Configure Shell

Test that Terraform is properly installed by checking available commands:

terraform -help

For detailed help on any specific command, use the -help flag:

terraform plan -help

Enable tab completion for improved productivity:

terraform -install-autocomplete

Source: Installation steps adapted from the official Terraform documentation by HashiCorp.


Understanding Terraform Fundamentals

Before diving into configuration, let’s establish a solid understanding of Terraform’s core concepts and workflow.

What is Terraform?

Terraform is an Infrastructure as Code (IaC) tool that allows you to define and provision infrastructure using HashiCorp Configuration Language (HCL), a declarative configuration language. Unlike imperative approaches where you specify the exact steps to achieve a desired state, Terraform’s declarative model lets you describe your target infrastructure, and Terraform determines the optimal way to create, update, or destroy resources to match that state.

The Terraform Workflow

Terraform follows a predictable three-phase workflow:

  1. Write: Define your infrastructure in .tf configuration files using HCL
  2. Plan: Preview the changes Terraform will make to reach your desired state
  3. Apply: Execute the planned changes to provision or modify your infrastructure

State Management

A crucial concept in Terraform is state management. Terraform maintains a state file that tracks the current state of your managed infrastructure. This state file enables Terraform to:

  • Map your configuration to real-world resources
  • Determine what changes are needed when you modify your configuration
  • Improve performance by caching resource attributes
  • Enable collaboration when stored remotely

This approach provides consistency, reproducibility, and version control for your infrastructure, making it easier to manage complex environments and collaborate with teams.


Project Setup and Structure

Let’s organize our project with a clean directory structure that follows Terraform best practices.

Create Project Directory

Create a dedicated directory for your Terraform project:

mkdir -p introduction-to-terraform/cloud-init
cd introduction-to-terraform

Final Project Structure

When complete, your project will have this organized structure:

introduction-to-terraform/
├── main.tf           # Primary resource definitions
├── variables.tf      # Input variable declarations
├── outputs.tf        # Output value definitions
├── locals.tf         # Local value computations
└── cloud-init/       # VM initialization templates
    ├── user-data.tpl     # User and SSH configuration
    └── network-config.tpl # Static IP configuration

This structure separates concerns and makes the configuration more maintainable and readable.


Configuring the Libvirt Provider

Understanding Providers

In Terraform, a provider is a plugin that enables Terraform to interact with APIs of external services, cloud platforms, or infrastructure tools. Providers act as translators, converting Terraform’s generic resource operations into service-specific API calls.

For our homelab setup, we’ll use the dmacvicar/libvirt provider, which allows Terraform to manage KVM/QEMU virtual machines through the libvirt API.

Create Main Configuration

Create the primary Terraform configuration file:

touch main.tf

Provider Configuration

Add the provider configuration to main.tf:

terraform {
  required_providers {
    libvirt = {
      source  = "dmacvicar/libvirt"
      version = "0.7.1"
    }
  }
}

provider "libvirt" {
  uri = "qemu:///system"
}

Configuration Breakdown

This configuration serves two critical functions:

  • terraform block: Declares provider requirements and version constraints for reproducibility
  • provider block: Configures the libvirt provider to connect to the system-level QEMU instance (qemu:///system), granting Terraform access to manage virtual machines on your local hypervisor

Version pinning ensures consistent behavior across different environments and team members.


Implementing Infrastructure with Resources

Understanding Resources

A resource in Terraform represents a piece of infrastructure—such as a virtual machine, network, or storage volume. Resources are the fundamental building blocks of your infrastructure configuration. Each resource has:

  • A type (like libvirt_domain for VMs or libvirt_volume for storage)
  • A unique name within your configuration
  • Arguments that define the resource’s properties
  • Attributes that are computed after creation

Terraform uses resource definitions to understand what infrastructure components to create, modify, or destroy.

Step 1: Prepare Cloud-Init Templates

Before defining our VM resources, we need to create cloud-init templates for automated VM initialization.

Understanding Cloud-Init

Cloud-init is a widely-used tool for initializing cloud instances during their first boot. It enables automated configuration of users, packages, files, and networking without manual intervention. In our setup, cloud-init will configure each VM with SSH keys, static IP addresses, and basic system settings, enabling immediate access and proper network configuration.

Create User Configuration Template

Create the user data template file:

#cloud-config
hostname: ${hostname}
users:
  - name: ubuntu
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    # For debugging console access, you can set a password. Remove for production.
    #password: "super-secret-password"
    #lock_passwd: false
    ssh_authorized_keys:
      - ${ssh_key}

This cloud-config template creates an ubuntu user with your SSH public key, grants sudo privileges, and enables passwordless authentication. The template uses Terraform variable interpolation (${variable} syntax) to dynamically inject the hostname and SSH key for each VM.

Create Network Configuration Template

Create the network configuration template:

version: 2
ethernets:
  ${interface}:
    dhcp4: false
    addresses: [${ip}/${prefix}]
    gateway4: ${gateway}
    nameservers:
      addresses: [${nameserver}]

This network configuration template uses Netplan format (version 2) to disable DHCP and configure static IP addressing. It sets the IP address, subnet prefix, gateway, and DNS server for the primary network interface, ensuring each VM has predictable network connectivity.

Step 2: Define Infrastructure Resources

Now we’ll define the actual infrastructure resources that Terraform will manage.

Cloud-Init ISO Resource

Add this resource to your main.tf file to create cloud-init ISOs for each VM:

# Create cloud-init ISO per VM
resource "libvirt_cloudinit_disk" "cloudinit" {
  for_each  = local.nodes

  name      = "${each.key}-cloudinit.iso"
  network_config = templatefile("${path.module}/cloud-init/network-config.tpl", {
    ip         = each.value.ip
    gateway    = var.network_gateway
    interface  = var.network_interface_name
    prefix     = tonumber(split("/", var.network_cidr)[1])
    nameserver = var.network_nameserver
  })
  user_data = templatefile("${path.module}/cloud-init/user-data.tpl", {
    hostname    = each.key
    ssh_key     = file(pathexpand(var.ssh_public_key_path))
  })
}

This resource uses the for_each meta-argument to create one cloud-init ISO per node definition. The templatefile() function processes our templates, substituting variables for each VM’s specific configuration.

Base Image Volume Resource

A base image volume serves as the foundation for all our VMs, containing a pre-configured operating system. Rather than installing an OS from scratch on each VM, we use a cloud-ready Ubuntu image that boots quickly and includes cloud-init support. This approach significantly reduces deployment time and ensures consistency across all nodes.

Add this resource to download and prepare the base image:

resource "libvirt_volume" "base_image" {
  name   = var.base_image_name
  pool   = var.storage_pool_name
  source = var.base_image_source
  format = "qcow2"
}

This resource downloads the Ubuntu cloud image on first run and stores it in the default libvirt storage pool. The QCOW2 format provides efficient storage with copy-on-write capabilities.

Individual VM Disk Volumes

Create individual writable disk volumes for each VM:

# Create OS disk per VM (backed by base image)
resource "libvirt_volume" "disk" {
  for_each = local.nodes
  name     = "${each.key}.qcow2"
  pool     = var.storage_pool_name
  base_volume_id   = libvirt_volume.base_image.id
  size     = each.value.disk_size
  format   = "qcow2"
}

This resource creates individual disk volumes for each VM using for_each to iterate over our node definitions. Each disk is backed by the base image volume, leveraging QCOW2’s copy-on-write functionality to share the common base image while maintaining separate, writable storage for each VM. This approach saves disk space and accelerates VM creation.

Virtual Machine Resource

Define the virtual machines themselves by adding this resource to main.tf:

# Define the VM
resource "libvirt_domain" "vm" {
  for_each = local.nodes

  name   = each.key
  memory = each.value.memory
  vcpu   = each.value.vcpu

  disk {
    volume_id = libvirt_volume.disk[each.key].id
  }

  cloudinit = libvirt_cloudinit_disk.cloudinit[each.key].id

  network_interface {
    network_name = var.network_name
  }

  console {
    type        = "pty"
    target_type = "serial"
    target_port = "0"
  }

  graphics {
    type        = "vnc"
    listen_type = "address"
    autoport    = true
  }
}

This resource definition creates the actual virtual machines by specifying:

  • Compute resources: Memory and vCPU allocation per VM
  • Storage: Attachment of the OS disk and cloud-init ISO
  • Networking: Connection to the default libvirt network
  • Access: Console and VNC graphics configuration

The for_each meta-argument creates one VM per node definition, with each VM receiving its specific configuration values from the local.nodes map.


Parameterizing with Variables

Understanding Variables

Variables in Terraform serve as input parameters that make your configurations flexible and reusable. Instead of hardcoding values like IP addresses or resource sizes, variables allow you to parameterize your infrastructure code, enabling the same configuration to work across different environments.

Key benefits of variables include:

  • Flexibility: Same configuration for different environments
  • Reusability: Share configurations across teams
  • Security: Separate sensitive values from code
  • Maintainability: Central location for configuration values

Creating Variable Definitions

Create the variables file:

touch variables.tf

Node Configuration Variable

Define the variable that describes our VM cluster configuration:

variable "nodes" {
  type = map(object({
    memory = number
    vcpu   = number
    disk_size = number
    ip     = string
  }))

  default     = null
  description = "A map of objects describing the nodes to create. If null, a default cluster is created."
}

This complex variable uses Terraform’s map(object()) type to define a structured data type. Each node has memory, vCPU, disk size, and IP configuration. Setting the default to null allows us to provide fallback values using locals.

SSH Configuration Variable

Add the SSH public key variable for secure access:

variable "ssh_public_key_path" {
  type        = string
  description = "Path to your SSH public key file (e.g., ~/.ssh/id_rsa.pub)."
}

Network Configuration Variables

Define the networking parameters for our VMs:

variable "network_gateway" {
  type        = string
  description = "The gateway for the libvirt network."
  default     = "192.168.122.1"
}

variable "network_cidr" {
  type        = string
  description = "The CIDR block for the libvirt network."
  default     = "192.168.122.0/24"
}

variable "network_nameserver" {
  type        = string
  description = "The nameserver for the VMs."
  default     = "8.8.8.8"
}

variable "network_name" {
  type        = string
  description = "The name of the libvirt network to attach VMs to."
  default     = "default"
}

variable "network_interface_name" {
  type        = string
  description = "The name of the primary network interface inside the VM (e.g., ens3, enp1s0)."
  default     = "ens3" # A common default for recent Ubuntu cloud images
}

These variables configure the network settings for our VMs. The defaults align with libvirt’s standard network configuration, but can be customized as needed.

Storage and Image Variables

Complete the variable definitions with storage and base image configuration:

variable "storage_pool_name" {
  type        = string
  description = "The name of the libvirt storage pool to use for VM disks."
  default     = "default"
}

variable "base_image_name" {
  type        = string
  description = "The name for the base OS image volume."
  default     = "ubuntu-noble-base"
}

variable "base_image_source" {
  type        = string
  description = "The URL from which to download the base OS image."
  default     = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
}

Working with Locals

Understanding Locals vs Variables

Locals differ from variables in that they’re computed values derived from variables, resource attributes, or other locals. While variables are inputs to your configuration, locals are internal calculations or transformations. They’re particularly useful for:

  • Complex expressions referenced multiple times
  • Conditional logic and data transformations
  • Reducing duplication and improving readability
  • Computed values that don’t need external input

Variable Best Practices

When working with variables, follow these guidelines:

  • Type safety: Always specify variable types
  • Documentation: Include clear descriptions
  • Logical grouping: Organize related variables together
  • Validation: Use validation blocks for input constraints
  • Sensitive data: Mark sensitive variables appropriately
  • Defaults: Provide sensible defaults where appropriate

Creating Locals Configuration

touch locals.tf

Add the cluster configuration logic:

locals {
  # Define common disk sizes for readability
  gb = 1024 * 1024 * 1024

  # The final map of nodes to create. This uses the user-provided `var.nodes` if it's not null,
  # otherwise it falls back to a default configuration.
  nodes = var.nodes != null ? var.nodes : {
    "master-1" = { memory = 2048, vcpu = 2, disk_size = 20 * local.gb, ip = "192.168.122.100" }
    "worker-1" = { memory = 2048, vcpu = 2, disk_size = 20 * local.gb, ip = "192.168.122.101" }
    "worker-2" = { memory = 2048, vcpu = 2, disk_size = 20 * local.gb, ip = "192.168.122.102" }
    "worker-3" = { memory = 2048, vcpu = 2, disk_size = 20 * local.gb, ip = "192.168.122.103" }
    "worker-4" = { memory = 2048, vcpu = 2, disk_size = 20 * local.gb, ip = "192.168.122.104" }
  }
}

This locals configuration demonstrates several key concepts:

  • Computed constants: The gb local creates a readable constant for byte calculations
  • Conditional logic: The ternary operator (?:) chooses between user-provided nodes or default configuration
  • Default cluster: Provides a sensible 5-node setup (1 master, 4 workers) if no custom configuration is specified

The default configuration creates VMs suitable for a Kubernetes cluster with appropriate resource allocation and sequential IP addressing.

Variable Assignment Methods

Terraform provides multiple ways to set variable values, listed in order of precedence:

  1. Command-line flags: -var and -var-file options
  2. Auto-loaded files: *.auto.tfvars and terraform.tfvars
  3. Environment variables: Prefixed with TF_VAR_
  4. Default values: Specified in variable declarations

Examples for our SSH key variable:

  • CLI:
terraform apply -var="ssh_public_key_path=~/.ssh/id_rsa.pub"
  • File:
ssh_public_key_path = "~/.ssh/id_rsa.pub"` in `terraform.tfvars
  • Environment:
export TF_VAR_ssh_public_key_path="~/.ssh/id_rsa.pub"

Choose the method that best fits your security requirements and workflow.


Extracting Information with Outputs

Understanding Outputs

Outputs in Terraform expose information about your infrastructure after creation, making important details available for:

  • Integration: Sharing data between Terraform configurations
  • Automation: Feeding information into scripts and tools
  • Documentation: Displaying connection details and important values
  • Debugging: Extracting resource attributes for troubleshooting

Creating Output Definitions

Create the outputs file:

touch outputs.tf

Define useful outputs for our infrastructure:

output "master_ips" {
  description = "A map of master VM names to their configured IP addresses."
  value = {
    for name, node in local.nodes : name => node.ip
    if startswith(name, "master-")
  }
}

output "worker_ips" {
  description = "A map of worker VM names to their configured IP addresses."
  value = {
    for name, node in local.nodes : name => node.ip
    if startswith(name, "worker-")
  }
}

output "ssh_commands" {
  description = "A map of VM names to the SSH command needed to connect to them."
  value = {
    for name, node in local.nodes : name => "ssh ubuntu@${node.ip}"
  }
}

output "ssh_user" {
  description = "The SSH user for connecting to VMs"
  value       = "ubuntu"
}

output "ssh_private_key_path" {
  description = "The SSH private key path for connecting to VMs"
  value       = replace(var.ssh_public_key_path, ".pub", "")
}

These outputs use Terraform’s for expression to iterate over our nodes and create maps containing:

  • IP addresses: Essential for network connectivity planning
  • SSH commands: Ready-to-use connection strings for immediate access

After running terraform apply, these values appear in the terminal output and can be retrieved later using terraform output [output_name].


Deploying Your Infrastructure

Now that we have all components defined, let’s deploy our 5-node cluster infrastructure.

Infrastructure Summary

Our complete infrastructure includes:

  • Provider configuration: libvirt integration
  • Cloud-init templates: Automated VM initialization
  • Resource definitions: Base images, VM disks, and virtual machines
  • Variables: Flexible configuration management
  • Locals: Computed values and defaults
  • Outputs: Access to important infrastructure details

Configuration Verification

Before deployment, let’s verify our project structure:

ls -la

You should see:

main.tf           # Resource definitions
variables.tf      # Variable declarations  
locals.tf         # Local value computations
outputs.tf        # Output definitions
cloud-init/       # Template directory

Variable Configuration

Choose your preferred method for setting the SSH key variable:

Method 1: Environment Variable (Recommended)

export TF_VAR_ssh_public_key_path="~/.ssh/id_rsa.pub"

Method 2: Configuration File

echo 'ssh_public_key_path = "~/.ssh/id_rsa.pub"' > terraform.tfvars

Method 3: Command-Line Flag

# Use this flag with terraform apply later
terraform apply -var="ssh_public_key_path=~/.ssh/id_rsa.pub"

Security Note: Environment variables are recommended for sensitive values like SSH keys as they don’t persist in files or command history.

Initialize the Terraform Project

Install the required libvirt provider and prepare your working directory:

terraform init

Preview the Infrastructure Plan

The terraform plan command is crucial for understanding what changes Terraform will make. It compares your desired configuration against the current state and creates an execution plan:

terraform plan

Understanding Plan Output:

  • + indicates resources to be created
  • ~ indicates resources to be modified
  • - indicates resources to be destroyed
  • The summary shows total resources affected

Review the plan carefully to ensure it matches your expectations before proceeding.

Deploy the Infrastructure

Execute the planned changes to create your infrastructure:

terraform apply

Note: If using the command-line flag method for variables, add the -var flag.

Terraform will display the plan and prompt for confirmation. Type yes to proceed with the deployment.

Verify Deployment

After successful deployment, verify your VMs are running:

# Check VM status
virsh list --all

# Test SSH connectivity  
ssh [email protected]  # master-1
ssh [email protected]  # worker-1

Your infrastructure is now ready for the next phase: Kubernetes cluster setup!


Troubleshooting Common Issues

Deployment Interruptions

If terraform apply encounters errors and stops midway, clean up the created resources:

terraform destroy

Libvirt Resource Cleanup

Due to a known issue, interrupted deployments may leave orphaned resources in libvirt. Clean them manually if needed:

for vm in master-1 worker-1 worker-2 worker-3 worker-4; do  
    sudo virsh destroy "$vm" 2>/dev/null;   
    sudo virsh undefine "$vm" --remove-all-storage || sudo virsh undefine "$vm"; 
done

This command forcefully removes any lingering VM definitions and storage from libvirt.


What You’ve Accomplished

Congratulations! You’ve successfully implemented Infrastructure as Code using Terraform to deploy a 5-node virtual machine cluster. Through this comprehensive tutorial, you’ve mastered:

Core Terraform Concepts

  • Providers: Integrating with external APIs and services
  • Resources: Defining infrastructure components declaratively
  • Variables: Creating flexible, parameterized configurations
  • Locals: Computing derived values and implementing logic
  • Outputs: Extracting useful information from your infrastructure

Practical Skills

  • State Management: Understanding how Terraform tracks infrastructure
  • Planning and Deployment: Safe infrastructure changes with preview capabilities
  • Troubleshooting: Resolving common deployment issues
  • Best Practices: Organizing code and managing configuration

Infrastructure Foundation

Your cluster provides an excellent foundation for:

  • Container Orchestration: Ready for Kubernetes deployment
  • DevOps Practices: Infrastructure as Code workflows
  • Homelab Experimentation: Safe, reproducible test environments
  • Learning Platform: Hands-on infrastructure management experience

Looking Forward

Next Tutorial: Kubernetes with Ansible

In our upcoming tutorial, Infrastructure as Code Part 2: Configuration Management with Ansible, we’ll build upon this infrastructure foundation by using Ansible to automate the configuration and deployment of a Kubernetes cluster using kubeadm. You’ll learn how to:

  • Combine Terraform’s infrastructure provisioning with Ansible’s configuration management
  • Automate Kubernetes cluster bootstrap and node joining
  • Implement end-to-end infrastructure and application deployment pipelines
  • Demonstrate the power of tool integration in the DevOps ecosystem

This combination showcases how different tools complement each other to achieve comprehensive automation.

Infrastructure as Code Journey

You’ve taken the first crucial step in your Infrastructure as Code journey. The principles and patterns you’ve learned here apply broadly across cloud platforms and infrastructure tools. Consider exploring:

  • Multi-cloud deployments with different providers
  • Remote state management for team collaboration
  • Terraform modules for reusable infrastructure components
  • CI/CD integration for automated infrastructure pipelines

Additional Resources

Essential Documentation

Learning Resources

Community and Support

Aaron Mathis

Aaron Mathis

Systems administrator and software engineer specializing in cloud development, AI/ML, and modern web technologies. Passionate about building scalable solutions and sharing knowledge with the developer community.

Related Articles

Discover more insights on similar topics