Skip to main content

Managing Infrastructure as Code (IaC) with OpenTofu

By October 17, 2023Blog, Member

Contributed by Spacelift | originally posted on spacelift.io

đź’ˇ Read the first post in this series for 10 Strategies to Build and Manage Scalable Infrastructure

IaC is the process that enables engineers to define, configure, and manage infrastructure through code. This approach treats underlying infrastructure components, such as networks, virtual machines, storage, and databases in the same way developers treat their application code. 

When it comes to managing cloud resources, Terraform is the de facto standard. However, after the recent change in Terraform’s licensing from open source to BUSL, a new project called OpenTofu emerged. At this point, OpenTofu is a drop-in replacement for Terraform. 

OpenTofu is an open source IaC tool that allows engineers to define their infrastructure resources as code, using a declarative programming language called Hashicorp Configuration Language (HCL). It is provider-agnostic, meaning that it supports a large number of cloud providers (AWS, Azure, GCP, OCI, and many more), and the language in which you write the code doesn’t change from one provider to another.

By using OpenTofu, you are not limited to cloud providers, as you can write OpenTofu automations for Kubernetes, Helm, Artifactory, Aviatrix, and Spacelift, to name a few. You can even define your own provider — as long as your product exposes an API, an OpenTofu provider can be built on top of it.

In this article, we will cover:

  1. ClickOps vs. IaC
  2. Key OpenTofu components
  3. The minimal structure for an OpenTofu project
  4. Reasons to choose OpenTofu for your IaC
  5. Example Kubernetes deployment on Azure using OpenTofu

ClickOps vs IaC

ClickOps is the term used to describe the manual management of IT infrastructure from the UI, by clicking into the portal to achieve a desired behavior (create/edit/delete resources). One may argue this process is enough for a small architecture that needs to be deployed, but starting like this raises major issues: You cannot easily scale and replicate your configuration. 

Imagine you are creating an EC2 Instance inside AWS. With ClickOps, you will create it much faster than you would using IaC. But what happens if you need to create ten EC2 instances? Or 100 EC2 instances? 

If it takes an engineer approximately two minutes to create an EC2 through the portal, it will take more than three hours to create 100 instances. These 100 instances will also need a network, security groups, maybe some EBS storage, and other things that will again take considerable time to configure. 

Doing this manually is very error-prone because the human attention span is not designed to deal with such a large number of tasks. By using OpenTofu, you can easily define all of these components as code, validate the code, plan what will happen, and ultimately deploy all resources in one go. As well as that, you can quickly and easily scale and replicate your configuration.

Key OpenTofu components

OpenTofu is stateful, tracking the state of the infrastructure by comparing the current defined IaC configuration and a state file it generates after an OpenTofu run. This introduces a layer of complexity because you need to manage this state file. If organizations use a combination of IaC and ClickOps, this will introduce drift — a mismatch between the state of infrastructure and the defined state in the IaC configuration — and sometimes even break their infrastructure resources. So if you are getting into IaC (and you really should), forget about ClickOps.

Providers

In essence, OpenTofu providers function as plugins that enable it to communicate with specific infrastructure resources. These providers serve as a bridge between OpenTofu and the underlying infrastructure, converting configurations into relevant API calls and permitting OpenTofu to handle resources across numerous environments. 

Example provider:

provider "aws" {
 region = "us-east-1"
}

Resources

In OpenTofu, resources represent the infrastructure elements that can be managed, such as virtual machines, virtual networks, DNS entries, and pods. 

Each resource is identified by a specific type, like “aws_instance”, or “kubernetes_pod,” and possesses a range of configurable attributes, such as instance size or type. They are the building blocks of OpenTofu, and every configuration that will create a piece of the infrastructure has to contain a resource.

Example resource:

resource "azurerm_resource_group" "this" {
 name     = "rg1"
 location = "West Europe"
}

Variables

OpenTofu variables are similar to variables in any other programming language. They are used to better organize your code, make it easier to change its behavior, and make your configuration reusable. In OpenTofu, variables can have the following types: string, number, bool, list, set, map, object, and null. 

You cannot use variables to hold expression values built from resources or data sources. For that, you should use a local variable.

Example variable:

variable "vpc_name" {
 description = "name of the vpc"
 type        = string
 default     = "vpc1"
}

Outputs

An output serves as a convenient method for displaying the value of a particular data source, resource, local, or variable once OpenTofu has completed implementing infrastructure modifications. They can also be used to expose attributes from a module.

Example output:

output "vpc_name" {
 value = var.vpc_name
}

DataSources

A DataSource is an object that retrieves data from an external source and can be used in resources as arguments when they are created or updated, or you can use them in locals to manipulate the data you receive.

Example DataSource:

data "aws_ami" "ubuntu" {
 most_recent = true

 filter {
   name   = "name"
   values = ["ubuntu*"]
 }
}

Locals

A local variable assigns a name to an expression and makes it easy for the user to utilize it. Typically, in any programming language, when you have a complex expression, you don’t want to write it multiple times throughout your code. You are usually defining a variable that holds it or a function that implements it. 

Local variables work in a similar way and they can be used in conjunction with resource and DataSource attributes to build complex expressions.

Example local:

locals {
 a_list = [for i in range(10) : i]
}

Provisioners

Provisioners exist inside a resource, and they are used to run either a local command or a remote command or to copy a file from your local environment to a remote virtual machine. 

They are considered a last-resort option due to the fact they are not a part of OpenTofu’s declarative model. You should typically use cloud-init to run different scripts on your VM during the bootstrap phase, or if you can, use a configuration management tool like Ansible for this.

Example provisioner:

resource "null_resource" "this" {
 provisioner "local-exec" {
   command = "ls -l"
 }
}

Minimal structure of an OpenTofu project

A basic structure of an OpenTofu project contains the following files:

.
├── main.tf
├── variables.tf
├── outputs.tf

Typically, main.tf contains the core resource declarations and providers. This is where you define your EC2 instance configurations and the method for authenticating to the AWS provider.

The variables.tf file defines the input variables that allow engineers to customize the project. They are referenced in the main.tf and can easily change the behavior of a particular resource, making it easy to reuse the automations.

In the outputs.tf file, engineers define the outputs that are returned to the console after a tofu apply. As a best practice, they shouldn’t return all the data from a resource, only the important fields that are of interest.

Documentation is crucial, so having a README.md file inside your OpenTofu repository that explains how to use the automation (including descriptions of variables and outputs) really helps you understand what has been implemented. This is just a basic structure, but it can be customized depending on the complexity of the automation and, of course, the requirements of the organizations. 

Reasons to choose OpenTofu for Your IaC

All the resources presented above are the building blocks for creating OpenTofu automations. Declarative syntax is easy to understand and use and enables engineers to get up to speed quickly with these concepts.

OpenTofu’s init/plan/apply workflow helps prevent unintended changes to your infrastructure by previewing changes before they are actually made. The initialization part also ensures the required providers are downloaded, so you don’t need to push them to your Version Control System (VCS) repository to use them. 

The OpenTofu community is growing quickly and the whole movement is aligned toward a common goal of providing an open source IaC tool to everyone. 

The code you build with OpenTofu can be packaged as a module and shared across your organization easily. Many other features, such as for_each, count, functions, ternary operators, loops, and dynamic blocks, are available to keep your code DRY. 

Because it’s cloud-agnostic, you can use openTofu to build multi-cloud automations without having to use multiple tools together to achieve this.

Example Kubernetes deployment on Azure using OpenTofu

The example code can be found here.

In the example above, we use two resources:

  • azurerm_resource_group → to create resource groups inside of Azure
  • azurerm_kubernetes_cluster → to create the Kubernetes cluster inside of Azure

For this example, the cluster management will always be free. You pay only for the underlying nodes of the cluster.

On both of the resources, we use for_each to create the number of resources of that particular type we want.

resource "azurerm_resource_group" "this" {
 for_each = var.resource_groups
 name     = each.key
 location = each.value.location
}


resource "azurerm_kubernetes_cluster" "this" {
 for_each            = var.kube_params
 name                = each.key
 location            = azurerm_resource_group.this[each.value.rg_name].location
 resource_group_name = azurerm_resource_group.this[each.value.rg_name].name
…
}

If you look at the above resources, you can see how the link between them is created on the Kubernetes one, specifically at the location and resource_group_name parameters. The way it’s done ensures the resource group is created first and we are accessing its location and name attributed inside the Kubernetes Cluster. 

In that way, the cluster and the resource group in which the cluster will be created will reside in the same location.

To declare the variables, we use map(object) types to leverage the full capabilities of the for_each, and we also ensure optional values to make the code easier to use. 

variable "kube_params" {
 description = "AKS parameters"
 type = map(object({
   rg_name             = string
   dns_prefix          = string
   np_name             = string
   tags                = optional(map(string), {})
   vm_size             = optional(string, "Standard_B2s")
   client_id           = optional(string, null)
   client_secret       = optional(string, null)
   enable_auto_scaling = optional(bool, false)
   max_count           = optional(number, 1)
….

To keep it simple, we provide the values of these variables in the default block, but you can use a *.auto.tfvars file, or environment variables to pass these values.

You can even use a local variable to specify the values, or if you want to take it to the next level, you can use a local variable that reads the contents of a YAML file with values by using the file and yamldecode functions.

With the default values, it looks like this:

default = {
   rg1 = {
     location = "westus"
   }
 }

default = {
   aks1 = {
     rg_name    = "rg1"
     dns_prefix = "kube"

For this configuration, we have declared two outputs; one will show a map containing name and location pairs for the resource groups, and the other will have some details related to the Kubernetes cluster in the following format name => id, fqdn.

output "resource_groups" {
 description = "Resource Group Outputs"
 value       = { for rg in azurerm_resource_group.this : rg.name => rg.location }
}

output "aks" {
 description = "AKS Outputs"
 value       = { for kube in azurerm_kubernetes_cluster.this : kube.name => { "id" : kube.id, "fqdn" : kube.fqdn } }
}

To run this code, we must initialize the working directory by using tofu init.

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/azurerm...
- Installing hashicorp/azurerm v3.49.0...
- Installed hashicorp/azurerm v3.49.0 (signed by HashiCorp)

Providers are signed by their developers.

...

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Then we can apply the code, using the tofu apply command.

azurerm_resource_group.this["rg1"]: Creating...
azurerm_resource_group.this["rg1"]: Creation complete after 3s [id=/subscriptions/subid/resourceGroups/rg1]
azurerm_kubernetes_cluster.this["aks1"]: Creating...
azurerm_kubernetes_cluster.this["aks1"]: Still creating... [10s elapsed]
azurerm_kubernetes_cluster.this["aks1"]: Still creating... [20s elapsed]
azurerm_kubernetes_cluster.this["aks1"]: Still creating... [30s elapsed]
…
azurerm_kubernetes_cluster.this["aks1"]: Still creating... [3m50s elapsed]
azurerm_kubernetes_cluster.this["aks1"]: Creation complete after 3m52s [id=/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks1]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

aks = {
 "aks1" = {
   "fqdn" = "kube-pocuwyy7.hcp.westus.azmk8s.io"
   "id" = "/subscriptions/subid/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks1"
 }
}
resource_groups = {
 "rg1" = "westus"
}

You can see the cluster now in the Azure Portal:

terraform iac azure portal

Key points

IaC is the standard now in the DevOps and cloud engineering world. It makes it easier to create, update, and replicate your infrastructure while mitigating human error. OpenTofu is very powerful, it is truly open source under The Linux Foundation, and the community around it is really active.

Choosing OpenTofu as your IaC tool and enhancing your workflow with an IaC management platform to achieve GitOps reduces your time to market, makes the experience more secure, and helps you easily integrate with third-party products.