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.

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
oryum
forapt
.
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:
- Write: Define your infrastructure in
.tf
configuration files using HCL - Plan: Preview the changes Terraform will make to reach your desired state
- 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 reproducibilityprovider
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 orlibvirt_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:
- Command-line flags:
-var
and-var-file
options - Auto-loaded files:
*.auto.tfvars
andterraform.tfvars
- Environment variables: Prefixed with
TF_VAR_
- 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
- Terraform Official Documentation - Comprehensive guide to all Terraform concepts and features
- Terraform libvirt Provider - Detailed reference for libvirt provider usage
- Cloud-init Documentation - Complete guide to cloud-init configuration options
Learning Resources
- HashiCorp Learn Terraform - Official hands-on tutorials for various platforms and use cases
- Terraform Best Practices - Guidelines for production Terraform usage
- libvirt Documentation - Understanding the virtualization layer
Community and Support
- Terraform Community Forum - Get help and share knowledge
- Terraform GitHub Repository - Source code and issue tracking
- Infrastructure as Code Patterns - Advanced patterns and practices

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