
Ned Bellavance
Founder, Ned in the Cloud
Terraform is the most popular tool for managing the lifecycle of infrastructure by defining the desired environment using code. In this article we will take a look at why Terraform was created, how to install Terraform on your system, and walk through an example configuration using Microsoft Azure to illustrate the workflow of using Terraform to manage your infrastructure as code.
Terraform is an open source tool created by HashiCorp to provision infrastructure and manage resources throughout their lifecycle. Terraform is driven by three critical design principles:
Before you decide whether Terraform is the right tool to assist you in your infrastructure management efforts, it's useful to zoom out and understand why you might choose to use Infrastructure as Code (IaC) to begin with.
Prior to the advent of cloud computing, data center management was a relatively static and manual process for most organizations. Assets like servers, storage, and networking did not change frequently; allowing you to provision and manage systems with manual processes and procedures.
The introduction of virtualization made data center infrastructure more dynamic, as resources like virtual machines, software defined storage, and virtual networks could now be created and altered on demand using REST-based APIs.
Then cloud platforms like AWS, Azure, and Google Cloud arrived on the scene and suddenly the rate of change and scale of infrastructure grew exponentially. Previous processes of managing the deployment and configuration of resources manually became untenable for an organization.
New tools had to be developed for the dynamic operations required in a cloud computing era, and in that crucible, infrastructure as code tools were created to solve the challenges of infrastructure automation.
Terraform embraces the benefits of using infrastructure as code, while adding some additional features that give it an edge over other solutions.
Terraform isn't the only tool out there to provision infrastructure, and it might not even be the best solution for your organization. While it does bring the key benefits of cloud agnosticism, declarative configuration, and agentless operations; there are other popular solutions that can also solve the challenges of managing infrastructure as code.
Some of the most popular tools include:
That's by no means an exhaustive list, but if you're curious about how these solutions compare to Terraform, I'd recommend reading this article.
For our example, we are going to use Terraform to deploy infrastructure on Microsoft Azure. The core principles in this example are broadly applicable to any cloud provider, like AWS or Google Cloud. First and foremost, we need to install Terraform.
The Terraform CLI client is a single executable binary compiled from Go. There's no installation package or wizard to walk through. You simply download the appropriate binary for your operating system and architecture from the Terraform website, or through your package manager of choice.
Since it's open source, you can also go directly to the GitHub repository and download the latest release or build it yourself from source. Once you've downloaded the Terraform binary, simply place it in a location on your system included in the PATH environment variable.
Since we will be using Microsoft Azure for the example, you will need to have a subscription on Microsoft Azure to follow along and the Azure CLI installed locally. You can alternatively leverage the Azure Cloud Shell, if you prefer not to install anything on your local system.
The Azure Cloud Shell environment includes both the Azure CLI and the Terraform binary as part of the pre-built environment, and it even has a built-in code editor. Honestly, a pretty good choice for trying something out.
If you choose to run Terraform on your local system, you will need to log into Microsoft Azure using the CLI to provide authentication credentials and select a subscription for Terraform use.
Run the following commands substituting your subscription name for the [.code]SUB_NAME[.code] placeholder:
Terraform will automatically find your stored Azure CLI credentials and selected subscription, and use those for the deployment of infrastructure.
We're also going to need somewhere to store our declarative configuration files. From a terminal prompt, run the following commands to create a directory and files that will hold our Terraform configuration.
# Login into Azure in a browseraz login# Select the Azure subscription to useaz account set -s SUB_NAME
You will now have a directory called [.code]azure_configuration[.code] with the files [.code]main.tf[.code] and [.code]terraform.tf[.code] inside.
With these prerequisites in place, we are ready to start defining some infrastructure as code!
Terraform describes infrastructure declaratively using either Javascript Object Notation (JSON) or HashiCorp Configuration Language (HCL). Use of HCL is far more common, so that is what we'll use for our example.
HashiCorp designed HCL to be human-readable, simple to write, and declarative in nature. The core construct of HCL is the configuration block, which defines an object in HCL and the arguments that will configure it. For instance, a resource configuration block takes the form:
The first term in the block defines what type of object is being declared, in this case we are creating a resource. The next term defines what type of resource we are creating, an Azure resource group. And the third term defines a name label we can use to reference the resource elsewhere in our configuration.
Inside of the block- denoted by the curly braces {}- are the arguments that configure properties of the resource. We are setting a name for the resource group and the location in Azure where it should be created. If you're following along, go ahead and add the block above to your main.tf file.
The more general syntax for configuration blocks in HCL looks like this:
Any object type you create with Terraform will follow this syntax. Speaking of which, let's add a new resource to our infrastructure as code!
Now that we have an Azure resource group in our example configuration, we should put something in that resource group! Why don't we deploy an Azure Container Instance?
You might wonder what arguments are available for a given resource. The documentation for resources can be found on the Terraform public registry under the provider that manages them.
The resource documentation includes the argument, attributes, and example usage of the resource. For instance, the example provided for the [.code]azurerm_container_group[.code] looks like this:
At the beginning of the block, the location argument is referencing the [.code]azurerm_resource_group[.code] object. Terraform uses special expressions to refer to other objects in the configuration. For resources, the format is:
If we want to reference the name attribute of the resource group in our configuration, the syntax would be:
In fact, why don't we use a simpler version of the [.code]azurerm_container_group[.code] resource and include references to our resource group for the [.code]location[.code] and [.code]resource_group_name[.code].
The reference to our resource group within the [.code]azurerm_container_group[.code] block creates an implicit dependency between the resource group and the container instance.
When planning changes, Terraform creates a dependency graph of all objects in the infrastructure code to determine the order of operations. The reference expression tells Terraform that the resource group needs to exist before the container instance can be created.
While our infrastructure code so far is functional, it isn't very dynamic. All of the values are hard-coded, reducing flexibility and code reuse. Fortunately, we can define input variables in our Terraform code, allowing us to provide values at run time.
An input variable is defined using a configuration block (what else?!) with the keyword [.code]variable[.code] and a name label used to refer to the variable elsewhere in the code. For instance, let's create a variable to make our Azure region configurable:
Technically, the variable configuration block doesn't require any arguments inside, but that's not really a best practice. At the very least, we should define what type of input value we're expecting and a description of what the variable is for.
That's definitely better. Another optional argument is setting a default value for the input variable. Terraform requires that all variables have a value at run time. By providing a default value in the configuration block, we won't require the user to provide one.
Now our [.code]azure_region[.code] variable will use the East US region by default, which just happens to be the value we're currently using for our resource group. Speaking of which, we should update our resource group to use our shiny new input variable.
The syntax to reference a variable is [.code]var.<name_label>[.code], making our reference [.code]var.azure_region[.code]. Go ahead and add the complete [.code]azure_region[.code] variable block to your [.code]main.tf[.code] file and update the [.code]location[.code] argument for the resource group.
We may also want to get some information out of our Terraform code. We can do that with output values, which are defined using- any guesses?- an output configuration block.
Let's say that we would like to get the public IP address of our container instance once the resource is created. We can do that by defining an output block like this:
The keyword for the block is [.code]output[.code] followed by a name label to identify the output. The only required argument inside the output block is a [.code]value[.code], since an output wouldn't be very useful if it had no value. I've also included a description to provide clarity around what the output is meant to contain.
When Terraform is done creating the container instance, it will populate the output value, print it to the terminal, and save it to state data for later reference. Go ahead and add the output block to your configuration in the [.code]main.tf[.code] file.
That should just about finish our basic infrastructure configuration. Let's get this infrastructure as code deployed!
Deploying infrastructure with Terraform follows a general workflow like this:
In the first stage, Terraform is initialized with the init command to prepare the infrastructure code for deployment. Then an execution plan is generated with the [.code]plan[.code] command and verified to confirm the desired changes are included. Once the plan is approved, Terraform [.code]apply[.code] uses providers to provision infrastructure in the target environment.
As the Terraform code is changed and updated, the plan and apply stages are repeated to validate and execute changes on the managed resources in the target environment to match what’s in the code.
If the resources are no longer needed- as is the case in a development or testing scenario- the [.code]destroy[.code] command can be used to delete all managed resources.
To prepare the code for deployment, Terraform performs several actions when the command [.code]terraform init[.code] is run. If we run the command from our [.code]azure_configuration[.code] directory, we'll see the following output:
The process starts by inspecting the configuration and discovering any providers we are using. The plugins for those providers are then downloaded into the [.code].terraform[.code] subdirectory of our configuration.
How does Terraform know where to get the provider plugins? By default, it looks on the public Terraform registry for provider plugins that match the resource types used in our code. We can be more explicit and specify a particular version to use by adding the following code to our [.code]terraform.tf[.code] file.
This set of configuration blocks tells Terraform where to get the [.code]azurerm[.code] provider and what version we would like to use for our configuration. In this case, we're okay with any version [.code]3.60.0[.code] or newer of the provider plugin.
During the initialization process, Terraform also prepares the backend for state data storage. We'll come back to state data in a moment.
Before we try to generate an execution plan, we should probably check and make sure our code is valid and formatted properly. Terraform has two commands to help with this.
Running both commands fixes any formatting in our files and verifies our code is valid.
Now we're ready to create an execution plan.
At this point your [.code]main.tf[.code] should look like this:
Note the addition of a [.code]provider[.code] block for the [.code]azurerm[.code] provider at the beginning of the code.
We can use the provider block to configure various aspects of the Azure provider. At a minimum the Azure provider requires that a nested features block is included. Each provider plugin will have different required and optional arguments available to configure the provider.
Also, you may notice that the input variable block comes after the resource block that references it. Because HCL is declarative in nature, it doesn’t care what order the blocks appear in. Terraform will create a dependency graph to determine the order in which to parse and apply the configuration.
When we run [.code]terraform plan[.code], we will be presented with an execution plan detailing the changes Terraform will make to the target environment infrastructure to match what's in our code. The truncated output is shown below.
We can save the execution plan to a file by using the [.code]-out=<filename>[.code] flag when running [.code]terraform plan[.code]. The saved plan can be passed to the next phase when we run [.code]terraform apply[.code]. Otherwise, when the [.code]apply[.code] command is run Terraform will generate a new execution plan and prompt us to approve it.
Terraform will never make changes to the target environment without having an execution plan to follow. You can think of the execution plan as a promise from Terraform to create, update, or destroy only what's in that plan.
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
Since we didn't save the execution plan from the previous step, running [.code]terraform apply[.code] will generate a fresh execution plan for us to approve.
Once we approve the plan by entering "yes", the resource group and container instance will be created and the public IP address of the instance will be printed to the terminal window.
If we visit the public IP address from the output, we'll see the follow webpage:
Sweet!
Looking at the [.code]azure_configuration[.code] directory, you'll see several new files and a new folder.
We've covered provider plugins in some detail already, but what is this mysterious state file?
When you deploy infrastructure with Terraform, it needs some way of tracking which resources are being managed by Terraform and their properties. Terraform does this by creating state data that maps the resource identifier in the code to a unique identifier of the managed resource.
For example, our Azure resource group has a resource identifier of [.code]azurerm_resource_group.main[.code] inside our configuration, which maps to the Resource ID of the actual resource group in Azure.
Within the state data entry for our resource group is a listing of its attributes. We can view them by using the [.code]terraform state show[.code] command.
Without state data, Terraform would not be able to continue managing resources beyond their initial deployment. State data is quite literally the glue that binds the code to the target environment.
We can see this in action by making a change to our code, and seeing the change reflected in the execution plan.
We didn't add any tags to our resource group! Let's fix that now by updating the resource block:
Now we can run a [.code]terraform apply[.code] and preview the changes that Terraform will make.
Before Terraform plans out the changes, it loads the state data and refreshes its values using the mapping of managed resources.
Then it details the planned changes to our infrastructure, which unsurprisingly is adding the [.code]environment[.code] tag to our resource group.
Once you approve the plan, Terraform will make the necessary changes and write the results to the state data.
At this point, we're done with the example. You can run [.code]terraform destroy[.code] to remove the deployed resources. Just like the apply command, an execution plan to delete all resources will be generated and you will be prompted to approve the plan before Terraform makes any changes to your infrastructure.
Since Terraform cannot function without state data, if you don't specify a location to store state data, it will create the file [.code]terraform.tfstate[.code] in the current directory and store the state data there. Such behavior makes it easy to get started with Terraform, but it's not a good idea for any kind of production environment.
Aside from the default local backend, Terraform supports many remote backend options, like Azure Storage, Terraform Cloud, and env0. Moving to a remote backend has many advantages over the local backend:
Each instance of state data represents a mapping between the code and exactly one environment. The same Terraform code could be used to deploy infrastructure to multiple environments by toggling to a different instance of state data.
Terraform includes support for multiple environments through the use of workspaces in the core binary. This functionality is intended to support short-lived environments that are being leveraged for testing.
To support long-term environments and additional flexibility, there are many options beyond Terraform workspaces. A common pattern is to use automation and code branches to manage multiple deployments from the same code base. Another popular option is to use environment specific directories in the same code base, each referencing a common set of modules.
Using the same code base for multiple environments helps to enhance consistency, security, and reliability. Updating and testing your Terraform code in a lower environment and then promoting it to your production environment can help improve uptime and limit surprises when a change is implemented.
We've just barely scratched the surface when it comes to Terraform, but before you head out on your voyage of infrastructure as code discovery, here are a few quick tips and best practices to consider when writing terraform code:
There's a reason Terraform is consistently ranked as one of the most popular DevOps tools out there for managing infrastructure. In this tutorial we covered the following:
I hope you've found this tutorial helpful. You can find the full example code on my GitHub repository. There's still plenty more to learn! Keep an eye out for future posts that dig into some of the best practices and advanced topics referenced above.
Use custom workflows to model any process
Visualize all IaC changes pre and post-deployment
Gain code-to-cloud visibility and governance
Improve developer experience and collaboration
Use custom workflows to model any process
Visualize all changes pre and post-deployment
Gain code-to-cloud visibility and governance
Improve developer experience and collaboration
env0 is the best way to deploy, scale, and manage your Terraform and other Infrastructure as Code tools.