Terraform Certification (Part 5): Core Terraform Workflow
Tags: hashicorp, terraform
Published: 2023-03-26 10:00:00

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:

1. `terraform init`
1. `terraform validate`
1. `terraform fmt`
1. `terraform plan`
1. `terraform apply`
1. `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:

```hcl
// 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](../providers/) 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:

```console
$ tree -a .

.
└── main.tf

0 directories, 1 file
```

Next I run `terraform init`:

```console
$ 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`:

```console
$ 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:

```console
$ 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`:

```console
$ 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:

```hcl
// 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:

```console
$ 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:

```console
$ 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:

```json
{
  "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`:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

1. `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.
1. `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.
1. `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.
1. `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
1. `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
1. `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