Infrastructure as Code with Terraform: Crafting Configuration Files for Resources Creation

Infrastructure as Code with Terraform: Crafting Configuration Files for Resources Creation

Introduction

Infrastructure as Code (IaC) tools enable you to manage and provision infrastructure using configuration files instead of relying on a graphical user interface. IaC facilitates the creation, modification, and management of infrastructure in a safe, consistent, and repeatable manner by allowing you to define resource configurations that can be versioned, reused, and shared.

Terraform, developed by HashiCorp, is a powerful IaC tool that allows you to describe resources and infrastructure in human-readable, declarative configuration files. It streamlines the lifecycle management of your infrastructure, ensuring consistency and reliability. Crafting configuration files effectively is key to maximizing its potential.

Advantages of Terraform

  • Terraform can manage infrastructure on multiple cloud platforms.

  • The human-readable configuration language helps you write infrastructure code quickly.

  • Terraform's state allows you to track resource changes throughout your deployments.

  • You can commit your configurations to version control to safely collaborate on infrastructure.

This article provides a general guide for beginners on how to build configuration files for resources creation using terraform with Azure, AWS and GCP providers example. Follow through and you are on your way to becoming a professional in using terraform for resources deployment.

Understanding Terraform Configuration Files

A Terraform configuration file is a declarative text file used to define infrastructure resources that Terraform will manage. Written in HashiCorp Configuration Language (HCL), it specifies the desired state of resources and their relationships. The configuration file acts as the blueprint for your infrastructure, enabling you to define, manage, and deploy resources consistently and reliably using Terraform.

Key Components of a Configuration File:

  1. Provider Block:

    • Defines the cloud or service providers and their configurations (e.g., AWS, Azure).

    • Example:

        provider "azurerm" {
          features {}
        }
      
  2. Resource Block:

    • Represents the infrastructure resources to be created, such as virtual machines, networks, or storage accounts.

    • Example:

        resource "azurerm_resource_group" "example" {
          name     = "example-rg"
          location = "East US"
        }
      
  3. Variable Block:

    • Declares dynamic inputs to parameterize configurations and improve reusability.

    • Example:

        variable "location" {
          default = "East US"
        }
      
  4. Output Block:

    • Outputs values like resource attributes after deployment for reference or integration with other systems.

    • Example:

        output "resource_group_name" {
          value = azurerm_resource_group.example.name
        }
      
  5. Data Block:

    • Fetches existing infrastructure information for use in the configuration.

    • Example:

        data "azurerm_subscription" "current" {}
      
  6. Terraform Block:

    • Sets project-wide configurations like backend storage for state files and required provider versions.

    • Example:

        terraform {
          required_version = ">= 1.5.0"
        }
      

File Extensions and Structure

  • Terraform configuration files typically use the .tf extension.

  • A single file (main.tf) can contain all blocks, but best practices suggest splitting into logical files (e.g., provider.tf, variables.tf, outputs.tf) for maintainability. There is also Terraform.tfvars file which contains variable values for different environments.

Understanding Provider and Resource Blocks

Providers block

The provider block is used to configure the specific provider Terraform will use to interact with cloud platforms, SaaS services, or other APIs. Providers are plugins that enable Terraform to manage resources across a wide range of platforms, such as Azure, AWS, Google Cloud, Docker, Kubernetes, and many more. Every Terraform configuration must declare the required providers, allowing Terraform to download and use the appropriate plugins. Additionally, you can define multiple provider blocks in a single configuration to manage resources across different platforms simultaneously, enabling seamless integration and unified infrastructure management.

The Required Providers

The required_providers block is used to declare provider requirements in a Terraform configuration. It specifies the local provider name, its source location, and an optional version constraint. This block must be nested within the top-level terraform block, which can also include other settings. Defining a version constraint for each provider in the required_providers block is strongly recommended. While the version attribute is optional, setting it helps enforce a specific provider version, ensuring stability. Without it, Terraform defaults to the latest provider version, which could introduce breaking changes.

In example 1a below, the local name is docker, source is “kreuzwerker/docker" and version is "~> 3.0.1"

while in example 1b, the local name is azurerm, source is “hashicorp/azurerm" and version is "~> 3.0"

# Example 1a: Docker provider declaration 
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0.1"
    }
  }
}
# Example 1b: Azure provider declaration 
terraform {
  required_version = ">=1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~>3.0"
    }
  }
}

Referencing Providers

In Terraform configurations, providers are always referenced by their local names outside of the required_providers block. In example 2a below , the configuration declares azurerm as the local name for hashicorp/azurerm, then referenced that local name when configuring the provider:

# Example 2a: Referencing Azure providers
terraform {
  required_version = ">=1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~>3.0"
   }
  }
}

provider "azurerm" {
  features {}
}
#Example 2b: Referencing AWS providers
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  required_version = ">= 1.2.0"
}

provider "aws" {
  region  = "us-west-2"
}
# Example 2c: Referencing GCP providers
terraform {
  required_providers {
    google = {
      source = "hashicorp/google"
      version = "6.8.0"
    }
  }
}

provider "google" {
  project = "<PROJECT_ID>"
  region  = "us-central1"
  zone    = "us-central1-c"
}

Resource Block

Resource blocks have two strings before the block

  • 1. resource type

    2. resource name.

Terraform Resource Block Format

resource "<provider>_<resource_type>" "<resource_name>" {
  # Required attributes
  name                = "<resource_name>"
  location            = "<resource_location>"
  resource_group_name = "<resource_group>"

  # Optional attributes
  tags = {
    Environment = "Development"
    Owner       = "YourName"
  }

  # Nested blocks (if applicable)
  some_nested_block {
    attribute_1 = "value"
    attribute_2 = "value"
  }
}

General Guidelines

  1. Resource Identifier:
    The first string <provider>_<resource_type> specifies the provider and resource type e.g., azurerm_virtual_network.

  2. Resource Name:
    The <resource_name> is an internal identifier you use to reference the resource in the code. e.g., vnet

  3. Attributes:
    First Include required attributes (e.g., name, location), followed by optional ones (e.g., tags, nested blocks).

  4. Tags:
    Adding tags is a best practice for organizing and identifying resources.

  5. Nested Blocks:
    Some resources include nested configurations, such as subnet in a virtual network.

  6. Variables and Locals:
    Use variables or local values for reusable and parameterized code:

  7. Outputs:
    Export important values for other modules or users:

     # Example 3a: Resource declaration 1
     resource "azurerm_resource_group" "rg" {
      name     = "myResourceGroup"  
     location = "westus2"
      }
    
  • In Example 3a above, the resource type is azurerm_resource_group, the resource name is rg, the name attribute is myResourceGroup and location attribute is westus.

  • Together, the resource type and resource name form a unique ID for the resource. The ID for the resource in the example here is azurerm_resource_group.rg.

  • Resource blocks contain arguments enclosed within {} which are used to configure the resource. Arguments to include depend on the resource. For the example in context, the arguments are the resource name and location.

# Example 3b: Resource declaration 2
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  required_version = ">= 1.2.0"
}

provider "aws" {
  region  = "us-west-2"
}

resource "aws_instance" "app_server" {
  ami           = "ami-830c94e3"
  instance_type = "t2.micro"

  tags = {
    Name = "ExampleAppServerInstance"
  }
}
  • In Example 3b above, the resource type is aws_instance and the name is app_server.

  • The ID for the AWS instance is aws_instance.app_server

  • The resource blocks arguments included are ami ,instance_type, tags and Name .

# Example 3c: Resource declaration 3
resource "azurerm_resource_group" "rg" {
 name     = "myResourceGroup"  
location = "westus2"

}

resource "azurerm_storage_account" "storage_account" {
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  name = "myStorageAccount"
  account_tier             = "Standard"
  account_replication_type = "LRS"
  account_kind             = "StorageV2"
}
  • In Example 3c above, there are two resources: Resource group and storage.

  • For storage, the resource type is azurerm_storage_account and the resource name is storage_account forming an ID azurerm_storage_account.storage_account for the resource.

  • The Arguments for storage here is resource_group_name, location, name, account_tier, account_replication_type and account_kind.

Terraform Resource Creation Templates

Templates in Terraform are highly adaptable, allowing you to create any resource by simply modifying the a few things to match your specific requirements. Reliable sources for Terraform resource templates vary based on the resource type and cloud provider. A great starting point is the Terraform Registry and official documentation from cloud providers, as these resources are regularly updated and follow best practices. Once you have found a template, you can customize it to meet your project’s unique needs. Below is a list of recommended sources for Terraform templates:

  1. Terraform Registry

Terraform registry is the official source for verified Terraform modules and providers. It includes:

  • Modules: Prebuilt configuration for common use cases.

  • Documentation: Example for individual resource blocks.

  1. GitHub

Search for Terraform templates and examples on GitHub. Many organizations and developers share their Terraform repositories.

  1. Cloud Provider Documentation

Cloud providers like AWS, Azure and GCP have Terraform-specific examples in their documentation

  1. Community Modules

The Terraform community contributes to reusable modules you can find and customize for your needs:

  1. HashiCorp Learn Platform

HashiCorp Learn provides step-by-step guides and tutorials, including ready-to-use templates for

  • Infrastructure on AWS, Azure, and GCP

  • Kubernetes deployment

  • Docker configurations

  1. Blog and Tutorials

    Several blogs and tutorials sites publish Terraform examples and best practices

Key Points in Using Terraform Templates

  1. Provider Block: This block is required . Here you specify the provider (e.g., AWS, Azure, GCP).

  2. Resource Naming: Here

    • the <resource type> is usually named by using underscore (_) to combine the provider name ( e.g. azurerm) and the specific resource ( e.g virtual_network) e.g. azurerm_virtual_network.

    • The customizable part is the <resource_name>. You can edit it by replacing it with your desired internal name for referencing it e.g., vnet.

  3. Attributes:

    • Include mandatory attributes such as name, location, and resource_group_name.

    • The name attribute is also customizable. You can edit it by replacing it with your desired name e.g., my-vnet.

    • Add optional attributes or nested blocks as needed.

  4. Tags:

    • Always use tags

    • Always use tags for resource identification and cost management.

  5. Outputs:

    • Define outputs to expose resource details like IDs or endpoints for other modules or debugging.
  6. Terraform Commands:

    • Initialize: terraform init

    • Preview changes: terraform plan

    • Apply changes: terraform apply

Azure Resource Creation Template

Below is a general template for creating a resource using Terraform with Azure (azurerm) as the provider:

# Provider configuration
provider "azurerm" {
  features {} # Required block for the AzureRM provider
}

# Resource definition
resource "azurerm_<resource_type>" "<resource_name>" {
  # Required attributes
  name                = "<resource_name>"
  location            = "<resource_location>"            # Example: "East US"
  resource_group_name = "<resource_group_name>"          # Existing resource group

  # Optional attributes
  tags = {
    Environment = "Development"                          # Example tags
    Owner       = "YourName"
  }

  # Nested blocks (if applicable)
  <nested_block_name> {
    attribute_1 = "value"
    attribute_2 = "value"
  }
}

# Outputs (Optional)
output "<output_name>" {
  value = "<resource_reference>"                        # Example: azurerm_<resource_type>.<resource_name>.id
}

Example 1: Azure Storage Account

# Provider configuration
provider "azurerm" {
  features {} # Required block
}

# Storage account resource
resource "azurerm_storage_account" "project" {
  name                     = "project-storaccount12"       # Must be globally unique
  resource_group_name      = "myResourceGroup"
  location                 = "East US"
  account_tier             = "Standard"
  account_replication_type = "LRS"

  tags = {
    Environment = "Production"
    Owner       = "Celestina Odili"
  }
}

# Output: Storage account primary endpoint
output "storage_account_primary_endpoint" {
  value = azurerm_storage_account.example.primary_blob_endpoint
}

Example 2: Azure Virtual Network

# Provider configuration
provider "azurerm" {
  features {}
}

# Virtual network resource
resource "azurerm_virtual_network" "VNet" {
  name                = "vnet1"
  location            = "East US"
  resource_group_name = "myResourceGroup"
  address_space       = ["10.0.0.0/16"]

  tags = {
    Environment = "Development"
    Owner       = "Celestina Odili"
  }

  # Optional nested subnet block
  subnet {
    name           = "db-subnet"
    address_prefix = "10.0.1.0/24"
  }
}

# Output: Virtual network ID
output "vnet_id" {
  value = azurerm_virtual_network.VNet.id
}

AWS Resource Creation Template

Here is a general template for creating a resource using Terraform with AWS as the provider:

# Provider configuration
provider "aws" {
  region = "<region>"  # Example: "us-east-1"
}

# Resource definition
resource "aws_<resource_type>" "<resource_name>" {
  # Required attributes
  name = "<resource_name>"  # Name of the resource

  # Optional attributes
  tags = {
    Environment = "Development"  # Example tags
    Owner       = "YourName"
  }

  # Nested blocks (if applicable)
  <nested_block_name> {
    attribute_1 = "value"
    attribute_2 = "value"
  }
}

# Outputs (Optional)
output "<output_name>" {
  value = "<resource_reference>"  # Example: aws_<resource_type>.<resource_name>.id
}

Example 1: AWS S3 Bucket

# Provider configuration
provider "aws" {
  region = "us-east-1"
}

# S3 bucket resource
resource "aws_s3_bucket" "example" {
  bucket = "example-bucket-name"  # Must be globally unique
  acl    = "private"             # Access Control List

  tags = {
    Environment = "Production"
    Owner       = "Celestina Odili"
  }

  # Enable versioning
  versioning {
    enabled = true
  }

  # Lifecycle rules
  lifecycle_rule {
    id      = "delete-old-versions"
    enabled = true

    noncurrent_version_expiration {
      days = 30
    }
  }
}

# Output: S3 bucket ARN
output "s3_bucket_arn" {
  value = aws_s3_bucket.example.arn
}

Example 2: AWS EC2 Instance

# Provider configuration
provider "aws" {
  region = "us-west-2"
}

# EC2 instance resource
resource "aws_instance" "example" {
  ami           = "ami-0c55b159cbfafe1f0"  # Example AMI ID
  instance_type = "t2.micro"

  tags = {
    Environment = "Development"
    Owner       = "Celestina Odili"
  }

  # Optional: Add a key pair for SSH access
  key_name = "example-key"

  # Optional: Associate a security group
  vpc_security_group_ids = ["sg-0123456789abcdefg"]
}

# Output: EC2 instance public IP
output "instance_public_ip" {
  value = aws_instance.example.public_ip
}

Google Cloud Resource Creation Template

Here is a general template for creating a resource using Terraform with Google Cloud Platform (GCP) as the provider:

# Provider configuration
provider "google" {
  project = "<project_id>"  # Your GCP project ID
  region  = "<region>"      # Example: "us-central1"
}

# Resource definition
resource "google_<resource_type>" "<resource_name>" {
  # Required attributes
  name    = "<resource_name>"  # Name of the resource
  region  = "<resource_region>"  # If applicable

  # Optional attributes
  labels = {
    Environment = "Development"  # Example labels
    Owner       = "YourName"
  }

  # Nested blocks (if applicable)
  <nested_block_name> {
    attribute_1 = "value"
    attribute_2 = "value"
  }
}

# Outputs (Optional)
output "<output_name>" {
  value = "<resource_reference>"  # Example: google_<resource_type>.<resource_name>.id
}

Example 1: GCP Compute Instance

# Provider configuration
provider "google" {
  project = "your-gcp-project-id"
  region  = "us-central1"
}

# Compute instance resource
resource "google_compute_instance" "example" {
  name         = "example-instance"
  machine_type = "e2-medium"
  zone         = "us-central1-a"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"  # Predefined image
    }
  }

  network_interface {
    network = "default"

    access_config {
      # Ephemeral external IP
    }
  }

  labels = {
    Environment = "Production"
    Owner       = "Celestina Odili"
  }

  metadata = {
    ssh-keys = "user:ssh-rsa AAAAB3NzaC1..."
  }
}

# Output: Compute instance external IP
output "instance_external_ip" {
  value = google_compute_instance.example.network_interface[0].access_config[0].nat_ip
}

Example 2: GCP Storage Bucket

# Provider configuration
provider "google" {
  project = "your-gcp-project-id"
  region  = "us-central1"
}

# Storage bucket resource
resource "google_storage_bucket" "example" {
  name          = "example-storage-bucket"  # Must be globally unique
  location      = "US"
  storage_class = "STANDARD"

  labels = {
    Environment = "Development"
    Owner       = "Celestina Odili"
  }

  versioning {
    enabled = true
  }

  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age = 30
    }
  }
}

# Output: Bucket URL
output "bucket_url" {
  value = google_storage_bucket.example.url
}

Resource Referencing

In Terraform, resource referencing is the process of connecting resources by passing attributes or outputs from one resource to another within the same configuration. This is key to building dynamic and dependent infrastructure, as it allows resources to interact with each other seamlessly.

Format for Referencing Resources

resource_type.resource_name.attribute
  • resource_type: The type of resource (e.g., aws_instance, azurerm_storage_account).

  • resource_name: The unique name you gave the resource in the configuration. (e.g., rg, storage_account, app_server).

  • attribute: The specific property or output of the resource you want to reference (e.g., id, name, ip_address).

Example

Here is an example of referencing in Terraform:

Creating an Azure Resource Group and Storage Account

resource "azurerm_resource_group" "project" {
  name     = "projectRG"
  location = "East US"
}

resource "azurerm_storage_account" "project" {
  name                     = "project-storageacct"
  resource_group_name      = azurerm_resource_group.project.name
  location                 = azurerm_resource_group.project.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

Explanation

  • The azurerm_storage_account resource references the azurerm_resource_group resource for:

    • resource_group_name: Refers to azurerm_resource_group.project.name.

    • location: Refers to azurerm_resource_group.project.location.

This ensures that the storage account will always use the same resource group and location as defined in the resource group resource.

Using a Single Configuration File

Using a single configuration file in Terraform is ideal for small projects, quick prototyping, or simple setups because it reduces complexity, simplifies management, and speeds up configuration. It works well when all resources are logically related. However, as the infrastructure grows or when working in teams, a single file becomes harder to maintain, debug, and scale, making it unsuitable for larger, complex projects.

The example below is a Terraform configuration that deploys three Azure resources: a resource group, a virtual network, and a storage account in a single main.tf file.

# Configure the Azure provider
provider "azurerm" {
  features {}
}

# Resource Group
resource "azurerm_resource_group" "project" {
  name     = "projectRG"
  location = "East US"
}

# Virtual Network
resource "azurerm_virtual_network" "vnet" {
  name                = "project-vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.project.location
  resource_group_name = azurerm_resource_group.project.name
}

# Storage Account
resource "azurerm_storage_account" "projectStorage" {
  name                     = "projectstorage123" # Must be globally unique
  resource_group_name      = azurerm_resource_group.project.name
  location                 = azurerm_resource_group.project.location
  account_tier             = "Standard"
  account_replication_type = "LRS"

  tags = {
    environment = "Development"
  }
}

# Outputs
output "resource_group_name" {
  value = azurerm_resource_group.project.name
}

output "vnet_name" {
  value = azurerm_virtual_network.vnet.name
}

output "storage_account_name" {
  value = azurerm_storage_account.projectStorage.name
}

Modularity, Reusability, and Clarity

Best practices recommend splitting configurations into logical files to enhance modularity, reusability, and collaboration. While a single file is convenient for beginners or lightweight use cases, a multi-file structure is better for long-term maintainability and team workflows. If you want to ensure modularity, reusability, and clarity in your Terraform configurations, you need to structure your configuration files. It can be broken into provider.tf, main.tf, variable.tf, output.tf files.

Below is example of a multi Terraform configuration that deploys three Azure resources: a resource group, a virtual network, and a storage account. The configuration is divided into four files: provider.tf, main.tf, variables.tf, and outputs.tf.

1. provider.tf (Provider Configuration)

provider "azurerm" {
  features {} # Required block for the AzureRM provider
}

2. variables.tf (Variable Definitions)

variable "resource_group_name" {
  description = "The name of the resource group."
  default     = "projectRG"
}

variable "location" {
  description = "The Azure region where resources will be created."
  default     = "East US"
}

variable "vnet_name" {
  description = "The name of the virtual network."
  default     = "project-vnet"
}

variable "vnet_address_space" {
  description = "The address space for the virtual network."
  default     = ["10.0.0.0/16"]
}

variable "storage_account_name" {
  description = "The name of the storage account."
  default     = "projectstorage123" # Must be globally unique
}

3. main.tf (Resource Definitions)

# Resource Group
resource "azurerm_resource_group" "project" {
  name     = var.resource_group_name
  location = var.location
}

# Virtual Network
resource "azurerm_virtual_network" "vnet" {
  name                = var.vnet_name
  address_space       = var.vnet_address_space
  location            = azurerm_resource_group.project.location
  resource_group_name = azurerm_resource_group.project.name

  tags = {
    Environment = "Development"
    Owner       = "Celestina Odili"
  }
}

# Storage Account
resource "azurerm_storage_account" "projectStorage" {
  name                     = var.storage_account_name
  resource_group_name      = azurerm_resource_group.project.name
  location                 = azurerm_resource_group.project.location
  account_tier             = "Standard"
  account_replication_type = "LRS"

  tags = {
    Environment = "Development"
    Owner       = "Celestina Odili"
  }
}

4. outputs.tf (Output Definitions)

# Output: Resource Group Name
output "resource_group_name" {
  description = "The name of the created resource group."
  value       = azurerm_resource_group.project.name
}

# Output: Virtual Network ID
output "vnet_id" {
  description = "The ID of the created virtual network."
  value       = azurerm_virtual_network.vnet.id
}

# Output: Storage Account Primary Endpoint
output "storage_account_primary_endpoint" {
  description = "The primary endpoint of the storage account."
  value       = azurerm_storage_account.projectStorage.primary_blob_endpoint
}

Best Practices

  1. Use Outputs for Cross-Module References: If you are working across modules, use outputs to expose attributes.

     output "resource_group_name" {
       value = azurerm_resource_group.project.name
     }
    
  2. Avoid Hardcoding: Use references instead of hardcoding values to make your configuration more dynamic and reusable.

  3. Use Terraform’s depends_on for Explicit Dependencies: While Terraform typically determines dependencies automatically, sometimes you need to specify them explicitly:

     depends_on = [azurerm_resource_group.project]
    
  4. Follow Terraform's terraform plan: Always run terraform plan before terraform apply to ensure your references are resolving correctly.

Conclusion

Infrastructure as Code (IaC) with Terraform provides a robust and efficient approach to managing infrastructure, enabling consistency, scalability, and automation. By adhering to general guidelines for resource creation such as organizing configurations into modular files, using input variables for flexibility, and implementing best practices, you can ensure a structured and maintainable infrastructure setup. Terraform's declarative nature and support for multiple providers make it a powerful tool for both small-scale projects and complex, multi-cloud environments.

Check What is Infrastructure as Code with Terraform for more.