I sat the exam for this today and passed with 87%, not as high as I’d been getting on practice exams but obviously good enough. In preparation, I had been writing up the study notes that I’ve provided below. They’re mostly condensed from the Hashicorp study guide so if wording sounds familiar, well, it’s probably copy-pasta.

Note I didn’t actually complete them before attempting the exam so you’ll still need to check the Hashicorp resources, but maybe they’ll still be of use to someone.

Good luck if that’s you and you’re planning to do the exam soon!


I use Terraform on a daily basis so while I’m on the certification bandwagon I may as well go with the obvious choices. This post will be my study guide, as mapped to the exam objectives.

Many thanks go to the comprehensive documentation and tutorials provided by Hashicorp, for Terraform and the rest of their products. ❤️

1. Understand Infrastructure as Code (IaC) concepts

1a. Explain what IaC is

Infrastructure as Code (IaC) is the concept of describing infrastructure (e.g. compute resources, networks, firewalls etc) in human-readable declarative configuration files.

1b. Describe advantages of IaC patterns

  • Idempotent; safe to apply code multiple times without resulting in unwanted/duplicate infrastructure. If part of a deploy fails, you can safely resolve and apply again.
  • Consistent; no opportunity for an engineer to accidentally miss a step while building out infrastructure.
  • Repeatable; the same code can be applied across many environments, or used to build/tear down the same environment (e.g. dev environments).
  • Predictable; only the indicated changes are applied.
  • Commitable (is that even a word?); the IaC can be checked into version control systems (e.g. git)
    • Enables GitOps flows, where changes can automatically be applied provided that they pass tests.
    • Allows for pull requests, easy review of planned changes.
    • Easily view history of changes to infrastructure, simple to roll back if required.

2. Understand Terraform’s purpose (vs other IaC)

2a. Explain multi-cloud and provider-agnostic benefits

  • Multi-cloud helps provide extreme HA/fault-tolerance across services/regions.
  • Provider-agnostisic benefits
    • Single control plane for multi-cloud; action changes in multiple clouds with a single command.
    • Single consistent configuration syntax for operators to learn.

2b. Explain the benefits of state

  • Maps resources to the real world, i.e. state allows Terraform to map resource "aws_instance" "foo" to i-abcd1234.
  • Tracks metadata, e.g. dependency order between resources.
  • Performance; while it’s possible to refresh the entire state per plan operation in smaller infrastructures, this becomes less feasible at scale due to the time required and potential API limits. To address this issue, the state file can be used as a cache via -refresh=false, and/or plan resources can be targeted/limited via -target.
  • Syncing of state between users; use of a fully-featured state backend (e.g. S3 + DynamoDB, Consul) that ‘locks’ the state will prevent apply operations from occuring simultaneously. TL;DR, single point of truth.

3. Understand Terraform basics

3a. Handle Terraform and provider installation and versioning

Terraform installation

# Compiled from source
git clone https://github.com/hashicorp/terraform.git
cd terraform
go install

# Platform specific pre-compilied binaries available on the Terraform website
wget https://releases.hashicorp.com/terraform/1.0.3/terraform_1.0.3_darwin_amd64.zip

# Platform specific package management tools

## Homebrew on OS X
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

## Chocolatey on Windows
choco install terraform

# Apt/yum/dnf on various Linux.

## Ubuntu/Debian
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common curl
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
sudo apt-get update && sudo apt-get install terraform

Terraform versioning

# versions.tf
terraform {
  required_version ">= 0.14"
}
  • Provider definition and version definition
# main.tf
provider "aws" {
  region = "us-west-2"
}

# versions.tf
terraform {
  required_providers {
    aws = {
        source  = "hashicorp/aws"
        version = ">= 2.0.0"
    }
  }
}

Provider installation and versioning via .terraform.lock.hcl.

  • Running init will download/install modules as necessary, and store specific versioning information in the .terraform.lock.hcl file.
    • This file should be version-controlled to ensure consistency across teams and ephemeral Terraform execution environments.
    • This file should not be directly modified.
  • Running init -upgrade will upgrade all providers within given version constraints.
$ terraform init

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Reusing previous version of hashicorp/random from the dependency lock file
- Installing hashicorp/aws v2.50.0...
- Installed hashicorp/aws v2.50.0 (signed by HashiCorp)

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to seeany changes that are required for your infrastructure. All Terraform commands should now work.

$ cat .terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/hashicorp/aws" {
  version     = "2.50.0"
  constraints = ">= 2.0.0"
  hashes = [
    "h1:aKw4NLrMEAflsl1OXCCz6Ewo4ay9dpgSpkNHujRXXO8=",
    ## ...
    "zh:fdeaf059f86d0ab59cf68ece2e8cec522b506c47e2cfca7ba6125b1cd06b8680",
  ]
}

3b. Describe plug-in based architecture

  • Terraform Core reads configuration files and builds up a resource dependency graph.
  • Terraform plugins (i.e. providers and provisioners) bridge the Core to respective target APIs, .e.g. the AWS provider plugin implements S3 resources via calls to the S3 API.
  • plan and update actions cause Core to ask the provider to perform an action via an RPC interface. The provider then attempts a CRUD action against the target API’s client library.
  • Terraform is mainly used against cloud infrastructure but providers can be used to interface with any API to manage any resource.

3c. Demonstrate using multiple providers

# main.tf
provider "aws" {
  region = "us-west-2"
}

resource "random_pet" "petname" {
  length    = 5
  separator = "-"
}

resource "aws_s3_bucket" "sample" {
  bucket = random_pet.petname.id
  acl    = "public-read"
  region = "us-west-2"
}

# versions.tf
terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "3.0.0"
    }

    aws = {
        source  = "hashicorp/aws"
        version = ">= 2.0.0"
    }
  }
}

3d. Describe how Terraform finds and fetches providers

When init is run, Terraform will attempt to download providers from the Terraform registry.

  • If a provider is not on the registry or the registry is known to be unreachable (e.g. due to corporate firewalls), you can override the default registry by providing an ‘Explicit Installation Method Configuration’ in the Terraform CLI config (.terraformrc or terraform.rc).
  • If the default registry fails and an override hasn’t been provided, Terraform checks creates an implied override that checks for the provider in some default locations in the user path.

When the provider has been found, init will download the provider into a subdirectory of the working directory so that each working directory is self-contained. This can lead to unneccessary duplication, so a provider cache can be specified via plugin_cache_dir in the Terraform CLI config.

  • Terraform will not create the cache directory for you.
  • Terraform checks against the registry, then against the cache to see if it has a copy. If it does not, Terraform will download the plugin into the cache directory before copying it into the working directory. It will use symlinks where possible.
  • The plugin cache directory cannot be referenced by an explicit or implied install configuration.
  • The plugin cache directory does not self clean and will need to be manually cleaned over time.

3e. Explain when to use and not use provisioners and when to use local-exec or remote-exec

Provisioners can be used to model specific actions on the local machine or on a remote machine in order to prepare servers or other infrastructure objects for service.

Provisioners should be used a last resort.

  • Terraform cannot model provisoners as part of a plan as they technically could be doing anything.
  • Many more moving parts than are normally involved, e.g. direct network access to remote servers, credentials to login, potentially require pre-existing software to be installed on the remote.

Potential use cases for local-exec or remote-exec.

  • When new functionality has been introduced to a service that is not yet implemented in their provider plugin.
  • Passing data into virtual machines and other compute resources; however, often cloud services will provide their own mechanism for this e.g. user_data for AWS EC2.
  • Running configuration management software; however, Hashicorp recommend a custom image build process instead e.g. through the use of Packer.

4. Use the Terraform CLI (outside of core workflow)

4a. Given a scenario: choose when to use terraform fmt to format code

The terraform fmt command is used to rewrite Terraform configuration files to a canonical format and style. This command applies a subset of the Terraform language style conventions, along with other minor adjustments for readability.

The format command scans the current directory for configuration files and rewrites your Terraform configuration files to the recommended format.

  • fmt and the format/style applied should always be used, basically. It is intentionally opinionated to ensure consistency across teams and projects.
  • The canonical format may have minor changes between versions so Hashicorp recommend running fmt after upgrading.
  • fmt will detect errors in your code and is useful for validation
$ terraform fmt
Error: Invalid character

  on main.tf line 46, in resource "aws_instance" "web_app":
  46:     Name = $var.name-learn

This character is not used within the language.

4b. Given a scenario: choose when to use terraform taint to taint Terraform resources

The terraform taint command informs Terraform that a particular object has become degraded or damaged. Terraform represents this by marking the object as “tainted” in the Terraform state, in which case Terraform will propose to replace it in the next plan you create.

Replacing a resource is also useful in cases where a user manually changes a setting on a resource or when you need to update a provisioning script. This allows you to rebuild specific resources and avoid a full terraform destroy operation on your configuration.

taint is deprecated, instead use -replace="resource" with plan or apply commands. The latter will allow you to see the full effect of the change, i.e. tainting a single resource may force the recreation of one or more other resources, which will be visible in the plan.

4c. Given a scenario: choose when to use terraform import to import existing infrastructure into your Terraform state

The terraform import command is used to import existing resources into Terraform.

import helps Terraform assume control over existing infrastructure or resources otherwise created outside of Terraform (and thus are not in the state file). This is as opposed to manually editing the state file which is not recommended.

Through import, Terraform can generate the appropriate code to represent your existing resources.

# Create an empty resource in terraform
$ cat main.tf
resource "docker_container" "web" {}

# Import the existing resource into terraform
terraform import docker_container.web $(docker inspect --format="{{.ID}}" hashicorp-learn)

# Check the state contains the resource as expected
$ terraform show
# docker_container.web:
resource "docker_container" "web" {
  command = [
    "nginx",
    "-g",
    "daemon off;",
  ]
# ...

# Generate configuration from the state
terraform show -no-color > main.tf
$ cat main.tf
# docker_container.web:
resource "docker_container" "web" {
  command = [
    "nginx",
    "-g",
    "daemon off;",
  ]
# ...

Note that dumping the state this way may contain attributes that cannot actually be assigned via the configuration. Be sure to run a plan to detect any issues and modify the resource as required.

4d. Given a scenario: choose when to use terraform workspace to create workspaces

Each Terraform configuration has an associated backend that defines how operations are executed and where persistent data such as the Terraform state are stored.

The persistent data stored in the backend belongs to a workspace. Initially the backend has only one workspace, called “default”, and thus there is only one Terraform state associated with that configuration.

Certain backends support multiple named workspaces, allowing multiple states to be associated with a single configuration. The configuration still has only one backend, but multiple distinct instances of that configuration to be deployed without configuring a new backend or changing authentication credentials.

  • Workspaces can be used to create multiple instances of infrastructure from the same Terraform configuration.
  • Workspaces are less suitable for separating environments, e.g. development from production, as each environment should have its own configuration, backend and workspaces.
    • They may be appropriate in this case, in the interests of avoiding duplicating code, if there is very little drift between the environments.
    • When different credentials and backends are required between environments, splitting environments by using directories is preferable, at the risk of duplicating code and allowing drift to creep in. Modules should be used to address both those issues, however.
  • Workspaces are more suitable for creating short-lived infrastructures used during development, e.g. a developer might branch the main trunk, create a new workspace for testing/developing that branch and then delete the workspace after the branch is merged into main.
  • Workspaces can be referenced as variables, .e.g. count = "${terraform.workspace == "default" ? 5 : 1}"

4e. Given a scenario: choose when to use terraform state to view Terraform state

Terraform stores information about your infrastructure in a state file. This state file keeps track of resources created by your configuration and maps them to real-world resources.

The terraform state command is used for advanced state management. As your Terraform usage becomes more advanced, there are some cases where you may need to modify the Terraform state. Rather than modify the state directly, the terraform state commands can be used in many cases instead.

  • state commands work the same with local and remote state backends.
  • All modifying state commands create a backup file, and this behaviour cannot be disabled.
  • state has been designed to be friendly with other CLI tools.

Really, state should be used for all state file interactions.

4f. Given a scenario: choose when to enable verbose logging and what the outcome/value is

Terraform has detailed logs which can be enabled by setting the TF_LOG environment variable to any value. This will cause detailed logs to appear on stderr.

  • You can set TF_LOG to one of the log levels TRACE, DEBUG, INFO, WARN or ERROR to change the verbosity of the logs.
  • Setting TF_LOG to JSON outputs logs at the TRACE level or higher, and uses a parseable JSON encoding as the formatting.
  • Logging can be enabled separately for terraform itself and the provider plugins using the TF_LOG_CORE or TF_LOG_PROVIDER environment variables. These take the same level arguments as TF_LOG, but only activate a subset of the logs.
  • To persist logged output you can set TF_LOG_PATH in order to force the log to always be appended to a specific file when logging is enabled. Note that even when TF_LOG_PATH is set, TF_LOG must be set in order for any logging to be enabled.

Verbose logging is useful when you’re encountering issues with either Terraform Core or a plugin. In particular, it’s expected you’ll use these logs to determine where a crash or issue is caused, before opening an issue on the appropriate github repository. The TRACE logging level should be used for these logs.

5. Interact with Terraform modules

5a. Contrast module source options

The Terraform Registry is integrated directly into Terraform, so a Terraform configuration can refer to any module published in the registry.

Each module in the registry is versioned. These versions syntactically must follow semantic versioning. In addition to pure syntax, we encourage all modules to follow the full guidelines of semantic versioning.

  • Module sources.
    • Terraform Registry modules.
      • The default registry.
      • Only shows verified modules by default.
      • Source strings of the form <NAMESPACE>/<NAME>/<PROVIDER>. For example: hashicorp/consul/aws.
    • Private Registry modules.
      • Terraform Cloud provides a private registry, as an example.
      • Ideal for sharing modules within or across an organisation.
      • Public modules from the Terraform Registry can be added to a Private Registry to provide a curated list of approved modules.
      • May need to provide credentials for access.
      • Source strings of the form <HOSTNAME>/<NAMESPACE>/<NAME>/<PROVIDER>. This is the same format as the public registry, but with an added hostname prefix.
    • Local modules.
      • Terraform treats any local directory referenced in the source argument of a module block as a module.

5b. Interact with module inputs and outputs

  • Input variables are like function arguments.
  • Output values are like function return values.
  • Local values are like a function’s temporary local variables.
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "2.21.0"

  # everything below this point is a module input
  name = var.vpc_name
  cidr = var.vpc_cidr

  azs             = var.vpc_azs
  private_subnets = var.vpc_private_subnets
  public_subnets  = var.vpc_public_subnets

  enable_nat_gateway = var.vpc_enable_nat_gateway

  tags = var.vpc_tags
}

For the given module inputs above, there are corresponding input variables inside the module.

variable "name" {
  description = "Name to be used on all the resources as identifier"
  type        = string
  default     = ""
}

variable "cidr" {
  description = "The CIDR block for the VPC. Default value is a valid CIDR, but not acceptable by AWS and should be overridden"
  type        = string
  default     = "0.0.0.0/0"
}

The resources defined in a module are encapsulated, so the calling module cannot access their attributes directly. However, the child module can declare output values to selectively export certain values to be accessed by the calling module.

output "vpc_id" {
  description = "The ID of the VPC"
  value       = concat(aws_vpc.this.*.id, [""])[0]
}

This output could then be access via module.vpc.vpc_id.

5c. Describe variable scope within modules/child modules

Within the module that declared a variable, its value can be accessed from within expressions as var.<NAME>, where <NAME> matches the label given in the declaration block

The value assigned to a variable can only be accessed in expressions within the module where it was declared.

TL;DR, variables are scoped to the module they are declared in and need to redeclared in child modules if they need to be passed down.

Locals can be useful for merging values or other variables. Alternatively they can be used to create short references to otherwise long variables or values.

locals {
  # Ids for multiple sets of EC2 instances, merged together
  instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id)

  # merging variables
  name-prefix = "${var.project_name}-${var.environment}"

  # shortening a long value
  vpc_id = data.terraform_remote_state.test_project.outputs.vpc_id
}

resource "aws_s3_bucket" "default" {
  bucket = "${local.name-prefix}-bucket"
  acl    = "private"

  tags = {
    Name = "${local.name-prefix}-bucket"
  }
}

5d. Discover modules from the public Terraform Module Registry

I don’t really know what to put for this one. Browse and get comfortable with https://registry.terraform.io/browse/modules.

5e. Defining module version

  • Hashicorp recommend explicitly constraining the acceptable version numbers to avoid unexpected or unwanted changes.
    • The following operators are valid:
    • = (or no operator): Allows only one exact version number. Cannot be combined with other conditions.
    • !=: Excludes an exact version number.
    • >, >=, <, <=: Comparisons against a specified version, allowing versions for which the comparison is true. “Greater-than” requests newer versions, and “less-than” requests older versions.
    • ~>: Allows only the rightmost version component to increment. For example, to allow new patch releases within a specific minor release, use the full version number: ~> 1.0.4 will allow installation of 1.0.5 and 1.0.10 but not 1.1.0. This is usually called the pessimistic constraint operator.
  • Terraform will use the newest installed version of the module that meets the constraint; if no acceptable versions are installed, it will download the newest version that meets the constraint.
  • Version constraints are supported only for modules installed from a module registry, such as the public Terraform Registry or Terraform Cloud’s private module registry. Other module sources can provide their own versioning mechanisms within the source string itself, or might not support versions at all. In particular, modules sourced from local file paths do not support version; since they’re loaded from the same source repository, they always share the same version as their caller.
  • Less than and equal to operators can be changed to create an acceptable range, e.g. >= 1.2.0, < 2.0.0
module "consul" {
  source  = "hashicorp/consul/aws"
  version = "0.0.5"

  servers = 3
}

6. Navigate Terraform workflow

6a. Describe Terraform workflow ( Write -> Plan -> Create )

The core Terraform workflow has three steps:

  • Write - Author infrastructure as code.
  • Plan - Preview changes before applying.
  • (version control commit)
  • Apply - Provision reproducible infrastructure.
  • (version control push)

When working with Terraform as an individual, these can all happen locally and have changes pushed to version control at the end of the cycle. As teams grow and managing secrets/credentials becomes more difficult, it’s normal to introduce a CI system. You can then lower individuals access to sensitive details while still allowing them to work with them. It also allows for branching/merge-request workflows where team members can review and approve changes before they are applied. An example flow might look like the below.

  • (version control branch)
  • Write code.
  • (version control commit)
  • Write code.
  • (version control commit)
  • (version control push)
  • CI Plan
  • (team-member reviews plan output and approves)
  • (version control merge)
  • CI Plan
  • (team-member reviews merged plan output and approves)
  • CI Apply

This can add time to the development workflow so be pragmatic and build what suits your company and situation.

6b. Initialize a Terraform working directory (terraform init)

When you create a new configuration — or check out an existing configuration from version control — you need to initialize the directory with terraform init. Initializing a configuration directory downloads and installs the providers defined in the configuration, which in this case is the aws provider.

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Reusing previous version of hashicorp/random from the dependency lock file
- Installing hashicorp/aws v2.50.0...
- Installed hashicorp/aws v2.50.0 (signed by HashiCorp)

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to seeany changes that are required for your infrastructure. All Terraform commands should now work.

6c. Validate a Terraform configuration (terraform validate)

The terraform validate command validates the configuration files in a directory, referring only to the configuration and not accessing any remote services such as remote state, provider APIs, etc.

Validate runs checks that verify whether a configuration is syntactically valid and internally consistent, regardless of any provided variables or existing state. It is thus primarily useful for general verification of reusable modules, including correctness of attribute names and value types.

It is safe to run this command automatically, for example as a post-save check in a text editor or as a test step for a re-usable module in a CI system.

Validation requires an initialized working directory with any referenced plugins and modules installed.

  • plan includes an implied validation check.

6d. Generate and review an execution plan for Terraform (terraform plan)

The terraform plan command creates an execution plan. By default, creating a plan consists of:

  • Reading the current state of any already-existing remote objects to make sure that the Terraform state is up-to-date.
  • Comparing the current configuration to the prior state and noting any differences.
  • Proposing a set of change actions that should, if applied, make the remote objects match the configuration.

You can use the optional -out=FILE option to save the generated plan to a file on disk, which you can later execute by passing the file to terraform apply as an extra argument. This two-step workflow is primarily intended for when running Terraform in automation. If you run terraform plan without the -out=FILE option then it will create a speculative plan, which is a description of a the effect of the plan but without any intent to actually apply it. In teams that use a version control and code review workflow for making changes to real infrastructure, developers can use speculative plans to verify the effect of their changes before submitting them for code review. However, it’s important to consider that other changes made to the target system in the meantime might cause the final effect of a configuration change to be different than what an earlier speculative plan indicated, so you should always re-check the final non-speculative plan before applying to make sure that it still matches your intent.

6e. Execute changes to infrastructure with Terraform (terraform apply)

If you are using Terraform directly in an interactive terminal and you expect to apply the changes Terraform proposes, you can alternatively run terraform apply directly. By default, the “apply” command automatically generates a new plan and prompts for you to approve it.

6f. Destroy Terraform managed infrastructure (terraform destroy)