The core Terraform workflow involves a number of steps that are common to most runs of Terraform.
At a high level this includes initializing the Terraform project with terraform init
, formatting your Terraform code with terraform fmt
, validating the correctness of your Terraform code with terraform validate
, creating a plan for the changes that are to be applied with terraform plan
, executing the changes with terraform apply
, and eventually removing all the resources you have created with terraform destroy
.
At a high-level this lesson covers the following parts of the exam curriculum:
Part | Content |
---|---|
6 | Use the core Terraform workflow |
(a) | Describe Terraform workflow ( Write -> Plan -> Create ) |
(b) | Initialize a Terraform working directory (terraform init ) |
(c) | Validate a Terraform configuration (terraform validate ) |
(d) | Generate and review an execution plan for Terraform (terraform plan ) |
(e) | Execute changes to infrastructure with Terraform (terraform apply ) |
(f) | Destroy Terraform managed infrastructure (terraform destroy ) |
(g) | Apply formatting and style adjustments to a configuration (terraform fmt ) |
The core Terraform workflow
What is known as the core Terraform workflow involves the following steps, most commonly performed in the following order:
terraform init
terraform validate
terraform fmt
terraform plan
terraform apply
terraform destroy
Slight variations to this workflow order might exist. For instance, the terraform fmt
step might be performed anytime without changing any behavior of the other steps. Sometimes the terraform plan
and terraform apply
steps are performed together (because technically terraform apply
can perform the plan
step as well). The last step, terraform destroy
, is usually not performed very often for production environments.
In the rest of this article I will go through the six steps listed above. We will use the following (terrible) Terraform code (HCL) during the journey through the steps:
// main.tf
terraform {
required_version=">0.13"
required_providers {
local = {
source = "hashicorp/local"
version=""
}
}
}
resource "local_file" "file" {
content = "hello world 1"
filename = "helloworld.txt"
}
resource "local_file" "file" {
content = "hello world 2"
filename = "helloworld2.txt"
}
1. Initializing a project
We have already seen examples of running terraform init
to initialize a project in the previous lesson. This command is used for both brand new projects you just started writing, and for old projects that you just checked out from source control and want to run from your laptop.
At a high level what happens when we initialize a project is that the selected backend is set up, remote state is downloaded (if it exists), and the required providers and modules are downloaded locally.
We still have not covered what a backend is (but we will in a future lesson!). The short version is that a backend is where we store our Terraform state. We have not covered what state is either! State is a snapshot of the real world that Terraform knows about, it is a single JSON file. If we do not specify an explicit backend then we will use the current directory as our backend.
In the previous lesson we went through how we work with providers and what happens when we download the required providers that we need, so refer back to it if you need a refresher.
We must run terraform init
as a first step on any computer we wish to run Terraform from to initialize all the required files that are not committed to source control (i.e. provider binaries, state files, etc.). This is also true if you run Terraform from a deployment pipeline, e.g. GitHub Actions.
This command has a number of flags that you can list using terraform init -h
. In my experience the only one you should know is terraform init -upgrade
that is used to upgrade a provider or a module to a later version that conforms to the version constraint you have specified in your Terraform configuration. Again, refer back to the previous lesson on providers for details. We will discuss modules in a future lesson.
What happens if we run terraform init
for the sample Terraform code I presented in the introduction? To know exactly what happens we first see what our current directory contains:
$ tree -a .
.
└── main.tf
0 directories, 1 file
Next I run terraform init
:
$ terraform init
There are some problems with the configuration, described below.
The Terraform configuration must be valid before initialization so that
Terraform can determine which modules and providers need to be installed.
╷
│ Error: Invalid version constraint
│
│ on main.tf line 8, in terraform:
│ 8: version=""
│
│ This string does not use correct version constraint syntax.
╵
╷
│ Error: Duplicate resource "local_file" configuration
│
│ on main.tf line 18:
│ 18: resource "local_file" "file" {
│
│ A local_file resource named "file" was already declared at main.tf:13,1-29. Resource names must be unique per type in each module.
We see that already at terraform init
we actually catch a lot of errors! First of all we have forgotten to write a value for the version number for the provider. Next we have two resources with the same symbolic names. For the sake of completing this part of the lesson I will fix these mistakes and run another terraform init
:
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "2.2.3"...
- Installing hashicorp/local v2.2.3...
- Installed hashicorp/local v2.2.3 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
If I now check my directory I have the following content:
$ tree -a .
.
├── .terraform
│ └── providers
│ └── registry.terraform.io
│ └── hashicorp
│ └── local
│ └── 2.2.3
│ └── darwin_arm64
│ └── terraform-provider-local_v2.2.3_x5
├── .terraform.lock.hcl
└── main.tf
7 directories, 3 files
My provider has been downloaded and I have a new .terraform.lock.hcl
file (see previous lesson for details). What I don't have is a state file, this is because I am using my local directory as my state backend and I did not have any state file to begin with.
2. Validating
Some errors that we make in our Terraform configuration can be caught early by running the terraform validate
command. Let me return to the original Terraform code I showed in the introduction, and then I run terraform validate
:
$ terraform validate
╷
│ Error: Invalid version constraint
│
│ on main.tf line 8, in terraform:
│ 8: version=""
│
│ This string does not use correct version constraint syntax.
╵
╷
│ Error: Duplicate resource "local_file" configuration
│
│ on main.tf line 18:
│ 18: resource "local_file" "file" {
│
│ A local_file resource named "file" was already declared at main.tf:13,1-29. Resource names must be unique per type in each module.
We see the same kind of errors that we already caught with terraform init
. However, there are errors that terraform validate
can catch that terraform init
is not able to do. Let me edit my main.tf
to the following:
// main.tf
terraform {
required_version=">0.13"
required_providers {
local = {
source = "hashicorp/local"
version="2.2.3"
}
}
}
resource "local_file" "file1" {
filename = "helloworld.txt"
}
resource "local_file" "file2" {
content = "hello world 2"
}
If I run terraform init
for this configuration I get the following result:
$ terraform init
terraform init
Initializing the backend...
Initializing provider plugins...
- Reusing previous version of hashicorp/local from the dependency lock file
- Using previously-installed hashicorp/local v2.2.3
Terraform has been successfully initialized!
No problems! If I run terraform validate
I get another reality:
$ terraform validate
╷
│ Error: Invalid combination of arguments
│
│ with local_file.file1,
│ on main1.tf line 13, in resource "local_file" "file1":
│ 13: resource "local_file" "file1" {
│
│ "content": one of `content,content_base64,sensitive_content,source` must be specified
╵
╷
│ Error: Invalid combination of arguments
│
│ with local_file.file1,
│ on main1.tf line 13, in resource "local_file" "file1":
│ 13: resource "local_file" "file1" {
│
│ "sensitive_content": one of `content,content_base64,sensitive_content,source` must be specified
╵
╷
│ Error: Invalid combination of arguments
│
│ with local_file.file1,
│ on main1.tf line 13, in resource "local_file" "file1":
│ 13: resource "local_file" "file1" {
│
│ "content_base64": one of `content,content_base64,sensitive_content,source` must be specified
╵
╷
│ Error: Invalid combination of arguments
│
│ with local_file.file1,
│ on main1.tf line 13, in resource "local_file" "file1":
│ 13: resource "local_file" "file1" {
│
│ "source": one of `content,content_base64,sensitive_content,source` must be specified
╵
╷
│ Error: Missing required argument
│
│ on main1.tf line 17, in resource "local_file" "file2":
│ 17: resource "local_file" "file2" {
│
│ The argument "filename" is required, but no definition was found.
╵
A lot of errors! It seems like I am missing a few required arguments in my resources. In general terraform validate
can catch errors related to my providers, and this terraform init
will not be able to do for me.
If you are using terraform validate
in automation you could add the -json
flag to the command to get the output in a JSON format that is easier to parse in scripts. If I run terraform validate -json
with this flag for my configuration I get the following result:
{
"format_version": "1.0",
"valid": false,
"error_count": 5,
"warning_count": 0,
"diagnostics": [
{
"severity": "error",
"summary": "Invalid combination of arguments",
"detail": "\"sensitive_content\": one of `content,content_base64,sensitive_content,source` must be specified",
"address": "local_file.file1",
"range": {
"filename": "main1.tf",
"start": {
"line": 13,
"column": 31,
"byte": 176
},
"end": {
"line": 13,
"column": 32,
"byte": 177
}
},
"snippet": {
"context": "resource \"local_file\" \"file1\"",
"code": "resource \"local_file\" \"file1\" {",
"start_line": 13,
"highlight_start_offset": 30,
"highlight_end_offset": 31,
"values": []
}
},
{
"severity": "error",
"summary": "Invalid combination of arguments",
"detail": "\"content_base64\": one of `content,content_base64,sensitive_content,source` must be specified",
"address": "local_file.file1",
"range": {
"filename": "main1.tf",
"start": {
"line": 13,
"column": 31,
"byte": 176
},
"end": {
"line": 13,
"column": 32,
"byte": 177
}
},
"snippet": {
"context": "resource \"local_file\" \"file1\"",
"code": "resource \"local_file\" \"file1\" {",
"start_line": 13,
"highlight_start_offset": 30,
"highlight_end_offset": 31,
"values": []
}
},
{
"severity": "error",
"summary": "Invalid combination of arguments",
"detail": "\"source\": one of `content,content_base64,sensitive_content,source` must be specified",
"address": "local_file.file1",
"range": {
"filename": "main1.tf",
"start": {
"line": 13,
"column": 31,
"byte": 176
},
"end": {
"line": 13,
"column": 32,
"byte": 177
}
},
"snippet": {
"context": "resource \"local_file\" \"file1\"",
"code": "resource \"local_file\" \"file1\" {",
"start_line": 13,
"highlight_start_offset": 30,
"highlight_end_offset": 31,
"values": []
}
},
{
"severity": "error",
"summary": "Invalid combination of arguments",
"detail": "\"content\": one of `content,content_base64,sensitive_content,source` must be specified",
"address": "local_file.file1",
"range": {
"filename": "main1.tf",
"start": {
"line": 13,
"column": 31,
"byte": 176
},
"end": {
"line": 13,
"column": 32,
"byte": 177
}
},
"snippet": {
"context": "resource \"local_file\" \"file1\"",
"code": "resource \"local_file\" \"file1\" {",
"start_line": 13,
"highlight_start_offset": 30,
"highlight_end_offset": 31,
"values": []
}
},
{
"severity": "error",
"summary": "Missing required argument",
"detail": "The argument \"filename\" is required, but no definition was found.",
"range": {
"filename": "main1.tf",
"start": {
"line": 17,
"column": 31,
"byte": 241
},
"end": {
"line": 17,
"column": 32,
"byte": 242
}
},
"snippet": {
"context": "resource \"local_file\" \"file2\"",
"code": "resource \"local_file\" \"file2\" {",
"start_line": 17,
"highlight_start_offset": 30,
"highlight_end_offset": 31,
"values": []
}
}
]
}
3. Formatting
Terraform has a certain code formatting convention that is easy to apply with the terraform fmt
command. Note that this command is optional, Terraform will not complain if your code does not follow the recommended format. However, it is a good practice to follow the formatting rules and since you have this command available it is easy to do. Just write your Terraform code, and then run terraform fmt
. You could also configure your editor (e.g. VS Code) to automatically format your code when you save your file.
This command has two flags that are of interest:
- Use the
-recursive
flag if you have several subdirectories with Terraform files and you wish to format all files in all subdirectories. - Use the
-diff
flag to list all the changes that are performed when formatting the files.
Let me start with my original HCL as defined in the introduction, then fix any mistakes reported by terraform validate
and finally running terraform fmt -diff
:
$ terraform fmt -diff
--- old/main.tf
+++ new/main.tf
@@ -1,22 +1,22 @@
terraform {
- required_version=">0.13"
+ required_version = ">0.13"
required_providers {
local = {
- source = "hashicorp/local"
- version=""
+ source = "hashicorp/local"
+ version = ""
}
}
}
resource "local_file" "file1" {
- content = "hello world 1"
+ content = "hello world 1"
filename = "helloworld.txt"
}
resource "local_file" "file2" {
- content = "hello world 2"
- filename = "helloworld2.txt"
+ content = "hello world 2"
+ filename = "helloworld2.txt"
}
We see all the changes that were made. In this case most changes involves adding spaces here and there. It might seem insignificant, but if you follow the official style then everyone will feel right at home when reading your HCL!
4. Planning
Now we enter the steps where things actually start to happen.
Before we create infrastructure with Terraform we can run a terraform plan
command. When we run terraform plan
Terraform performs the following steps:
- Read the state file and compare it to the resources in the real world
- Compare the current state to any potential updates in the HCL to note any differences
- Create a plan, showing any additions, changes, or deletions that will be performed if this plan was executed
No changes are actually performed by this command, it will just tell you what changes will happen if the current configuration is applied (in the next step).
One of the most common flags for this command is -out <output filename>
that will produce an output file containing a specification of the changes that would be performed if this plan is executed. This file can then be used in the terraform apply
command.
There are some additional flags for this command that could be relevant, but we will discuss them in a later lesson in combination with variables and state.
If we run terraform plan
with the -out
flag for our running example in this lesson we get the following:
$ terraform plan -out terraform.tfplan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# local_file.file1 will be created
+ resource "local_file" "file1" {
+ content = "hello world 1"
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "helloworld.txt"
+ id = (known after apply)
}
# local_file.file2 will be created
+ resource "local_file" "file2" {
+ content = "hello world 2"
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "helloworld2.txt"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
────────────────────────────────────────────────────────────
Saved the plan to: terraform.tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "terraform.tfplan"
I set the name of the output file to terraform.tfplan
, but this name is arbitrary. Just don't use a file ending such as .tf
because then Terraform will think it is a regular Terraform file. This file is in binary format so we will not actually be able to see the contents of it.
5. Applying
All the preparatory steps are performed, and now it is time to create out resources. This is performed with the terraform apply
command. This command triggers Terraform to create all the resources that are defined in our HCL.
If we followed the guide in the terraform plan
step we might have a file representing our plan of what changes to perform, e.g. terraform.tfplan
. If so, we can run terraform plan "terraform.tfplan"
and Terraform will immediately start working to fulfil the plan. If we do not have a plan we can instead just run terraform apply
, and Terraform will actually perform a plan step for us. When it has completed the plan step it will ask us if we wish to proceed to apply the plan.
For automation scenarios we should either use a plan file or alternatively add the -auto-approve
flag to the terraform apply
command.
There are some additional flags for this command that could be relevant, but we will discuss them in a later lesson in combination with variables and state.
If I run terraform apply -auto-approve
for my HCL the following happens:
$ terraform apply -auto-approve
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# local_file.file1 will be created
+ resource "local_file" "file1" {
+ content = "hello world 1"
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "helloworld.txt"
+ id = (known after apply)
}
# local_file.file2 will be created
+ resource "local_file" "file2" {
+ content = "hello world 2"
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "helloworld2.txt"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
local_file.file1: Creating...
local_file.file2: Creating...
local_file.file1: Creation complete after 0s [id=96e58c52e52b5f3bcb307d3309264d420b60403c]
local_file.file2: Creation complete after 0s [id=42ad4ff8bdd0125e98eeaa23146d7899ee77577e]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
My resources (my two files) were created, nothing was changed, and nothing was destroyed. The output indicates that a plan was performed and the result of the plan was shown: Plan: 2 to add, 0 to change, 0 to destroy
before the apply steps started.
6. Destroying
At some point we no longer need the resources we have created with Terraform and it is time to remove them. For this we have the terraform destroy
command.
terraform destroy
is actually an alias for terraform apply -destroy
.
When running this command Terraform will first of all reach out to the real world to compare its state to what actually exists, then it will remove the resources that it knows about from its state file. All the options available to terraform apply
is available to terraform destroy
(since it was an alias as I mentioned!)
This command will most likely be used less than the other commands, especially for production environments.
If I run terraform destroy
with the -auto-approve
flag for my running example I get the following:
$ terraform destroy -auto-approve
local_file.file2: Refreshing state... [id=42ad4ff8bdd0125e98eeaa23146d7899ee77577e]
local_file.file1: Refreshing state... [id=96e58c52e52b5f3bcb307d3309264d420b60403c]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# local_file.file1 will be destroyed
- resource "local_file" "file1" {
- content = "hello world 1" -> null
- directory_permission = "0777" -> null
- file_permission = "0777" -> null
- filename = "helloworld.txt" -> null
- id = "96e58c52e52b5f3bcb307d3309264d420b60403c" -> null
}
# local_file.file2 will be destroyed
- resource "local_file" "file2" {
- content = "hello world 2" -> null
- directory_permission = "0777" -> null
- file_permission = "0777" -> null
- filename = "helloworld2.txt" -> null
- id = "42ad4ff8bdd0125e98eeaa23146d7899ee77577e" -> null
}
Plan: 0 to add, 0 to change, 2 to destroy.
local_file.file2: Destroying... [id=42ad4ff8bdd0125e98eeaa23146d7899ee77577e]
local_file.file1: Destroying... [id=96e58c52e52b5f3bcb307d3309264d420b60403c]
local_file.file2: Destruction complete after 0s
local_file.file1: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.
The output looks similar to the terraform apply -auto-approve
command, except that now the plan states Plan: 0 to add, 0 to change, 2 to destroy
.
Summary
We went through the core Terraform workflow in this lesson. This workflow contains the most common commands that you will run when using Terraform in your day-to-day work. There will be many questions concerning these commands in the certification exam.
A quick reminder of what the steps are and what they do:
-
terraform init
initializes a project by downloading providers and modules, connecting to the backend and downloading the remote state if it exists. We also saw that it does some basic validation of your HCL. -
terraform validate
validates that you have not made any mistakes in your HCL, specifically related to the providers that you use, for instance it makes sure you have not forgotten any required arguments. -
terraform fmt
formats your HCL according to an official Terraform style guide. This does not change the behavior of the code in any way, it is just formatting. -
terraform plan
lists any changes (creations, updates, deletions) that would be performed to the resources defined in our HCL if we were to apply the updated configuration, potentially producing a binary output file containing the plan -
terraform apply
actually creates resources through the providers according to the plan in the previous step, or if no plan exists this command will produce a plan as a first step -
terraform destroy
deletes the resources that Terraform knows about from its state file, this is a very destructive command so it should be used with caution
Top comments (0)