Terraform Cert Guide Chapter 4 Part 1: Backends and Provisioners Explained Simply

Chapter 4 is where the book gets serious. We’re past the basics of providers, resources, and variables. Now we’re talking about where Terraform stores its memory (backends) and how you can run scripts as part of your infrastructure deployments (provisioners).

This is Part 1. Part 2 will cover loops, functions, and debugging.

The State File: Terraform’s Memory

Before we talk about backends, you need to understand the state file. Every time you run terraform apply, Terraform creates a JSON file called terraform.tfstate. This file is basically Terraform’s brain. It maps what you wrote in your .tf files to the actual resources that exist in the real world.

Think of it like a receipt. You ordered 3 VMs and a load balancer? The state file says “yes, these exist, here are their IDs and properties.”

The state file does four important things:

  1. Maps config to real resources - it knows that resource "azurerm_resource_group" "example" corresponds to an actual resource group in Azure with a specific ID
  2. Tracks dependencies - Terraform knows which resources depend on which, so it creates and destroys them in the right order
  3. Caches attributes for performance - instead of querying every single resource from the cloud API every time, it can use cached data. This matters a lot when you have hundreds of resources
  4. Enables team collaboration - when stored remotely, it makes sure two people don’t run Terraform at the same time and mess things up

One important rule: don’t edit the state file by hand. Just don’t. If you need to remove a resource from state, use terraform state rm. If you need to import an existing resource, use terraform import. Let Terraform manage its own memory.

Local Backend: The Default

By default, Terraform stores the state file right next to your config files. Run terraform apply and you’ll see terraform.tfstate appear in your working directory:

.
├── .terraform
├── main.tf
└── terraform.tfstate

Simple. No configuration needed. This works fine when you’re the only person working on the project.

You can even change where the local state file goes with a flag:

terraform apply -state=statefile

That creates a file called statefile instead of the default terraform.tfstate.

Workspaces and Local State

Here’s a neat thing. If you use Terraform workspaces, each workspace gets its own state file. So you can have one set of config files but separate state for dev, staging, and production.

terraform workspace new development
terraform apply

After this, your directory looks like:

.
├── main.tf
├── terraform.tfstate
└── terraform.tfstate.d
    └── development
        └── terraform.tfstate

The default workspace keeps its state in the root. Each additional workspace gets a subfolder under terraform.tfstate.d. Same code, different state, different environments.

Remote Backend: For Teams and Security

Local state works for solo projects. But the moment you have a team, you need remote backends. The book gives three solid reasons:

Team collaboration. When you store state remotely, Terraform locks the state file while someone is running an apply. If your coworker John tries to run Terraform at the same time, he gets a “state locked” error. No conflicts, no corruption. Everyone works off the same latest state.

Security. The state file often contains sensitive data like passwords and connection strings. With a remote backend, that data lives in encrypted cloud storage instead of sitting on someone’s laptop.

Remote operations. Some backends (specifically Terraform Cloud) let you run plan and apply on a remote server. You can kick off a big infrastructure change and close your laptop. It keeps running.

Two Classes of Backend

HashiCorp splits backends into two types:

  • Standard - stores state and supports locking. Think S3, Azure Blob, GCS.
  • Enhanced - does everything standard does, plus remote operations. That’s Terraform Cloud and Terraform Enterprise.

Setting Up a Remote Backend

Here’s what an Azure Blob storage backend looks like:

terraform {
  backend "azurerm" {
    storage_account_name = "terraform-stg"
    container_name       = "tfstate"
    key                  = "terraform.tfstate"
    access_key           = "your-access-key-here..."
  }
}

For AWS S3:

terraform {
  backend "s3" {
    bucket = "terraform-state-dev"
    key    = "network/terraform.tfstate"
    region = "us-east-1"
  }
}

For Google Cloud Storage:

terraform {
  backend "gcs" {
    bucket = "tf-state-prod"
    prefix = "terraform/state"
  }
}

Quick note on S3: it handles state storage natively, but for state locking you need to add DynamoDB. Azure Blob and GCS support locking out of the box.

Don’t Hardcode Credentials

The book makes this point clearly and I want to repeat it: don’t put access keys directly in your backend config. That’s a security problem. If you rotate credentials, you have to update your code. And if that code is in Git, congrats, your secrets are in version control.

Instead, use partial configuration. Define the non-sensitive parts in your config:

terraform {
  backend "azurerm" {
    container_name = "tfstate"
    key            = "terraform.tfstate"
  }
}

Then pass the sensitive values at init time:

  • Interactively at the CLI prompt
  • With -backend-config flags: -backend-config="storage_account_name=terraform-stg"
  • With a separate file: -backend-config=backend.hcl
  • Through environment variables like ARM_ACCESS_KEY

After the first terraform init, the backend info gets cached in the .terraform directory. You don’t need to pass it again for plan and apply. Just make sure .terraform is in your .gitignore because it stores those credentials locally.

Reading Remote State

You can also pull data from a remote state file using the terraform_remote_state data source. This is useful when one team manages the network and another team needs the subnet IDs:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "terraform-state-dev"
    key    = "network/terraform.tfstate"
    region = "us-east-1"
  }
}

This gives you access to all the outputs from that remote state. So if the network config exports a public_subnet_id, you can reference it as data.terraform_remote_state.network.outputs.public_subnet_id.

Provisioners: Running Scripts in Terraform

Now we get to provisioners. The book sets up a scenario: you need to deploy 10 Ubuntu servers in Azure and they all need Apache installed before handing them to the app team. Your colleague Mark says “just use provisioners.”

Provisioners let you run scripts, either on your local machine or on a remote resource, as part of a Terraform apply. Sounds great, right?

Well, hold on. The book is very clear about this, and HashiCorp’s docs say the same thing: provisioners are a last resort. Use them only when there’s no other way to accomplish your goal.

Why? Because Terraform can’t track what provisioners do. It doesn’t store their results in the state file. If a provisioner fails halfway through, Terraform has no idea what state your resource is in. Troubleshooting becomes painful.

Better alternatives exist for most use cases:

  • cloud-init on Linux VMs to run scripts at boot
  • user_data in AWS or custom_data in Azure to pass startup scripts
  • Configuration management tools like Ansible, Chef, or Puppet for ongoing config
  • Custom machine images (AMIs, etc.) with software pre-installed

But sometimes you’re stuck and provisioners are the only option. So let’s understand the three types.

local-exec: Run Something on Your Machine

The local-exec provisioner runs a command on the machine where you’re running Terraform. Not on the remote resource. On your laptop or CI server.

resource "aws_instance" "example" {
  # ...

  provisioner "local-exec" {
    command = "echo The EC2 server IP address is ${self.private_ip}"
  }
}

The self object references the parent resource. So self.private_ip gives you the IP of the EC2 instance that was just created.

A more practical example: writing server IPs to a local file:

resource "aws_instance" "example" {
  # ...

  provisioner "local-exec" {
    command = "echo ${aws_instance.example.private_ip} >> private_ips.txt"
  }
}

You can even run PowerShell scripts:

resource "null_resource" "script" {
  triggers = {
    always_run = "${timestamp()}"
  }

  provisioner "local-exec" {
    command     = "${path.module}/script.ps1"
    interpreter = ["powershell", "-File"]
  }
}

Notice the null_resource here. That’s a resource that doesn’t actually create anything in the cloud. It just exists as a container for the provisioner. The triggers block with timestamp() forces it to run every time.

Handling Failures

Every provisioner supports on_failure:

provisioner "local-exec" {
  command    = "echo ${self.private_ip}"
  on_failure = continue
}

Two options:

  • continue - ignore the error and keep going
  • fail - stop everything (this is the default). If the provisioner was running during resource creation, the resource gets marked as “tainted” so Terraform will recreate it next time

Destroy-Time Provisioners

You can also run provisioners when a resource is being destroyed:

provisioner "local-exec" {
  when    = destroy
  command = "echo Cleaning up before destroy"
}

One gotcha: if you remove the entire resource block from your config, the destroy-time provisioner goes with it. It won’t run. The provisioner has to exist in the config at the time the resource is destroyed.

Multiple Provisioners

You can stack multiple provisioners on a single resource. They run in the order you define them:

resource "google_compute_instance" "example" {
  # ...

  provisioner "local-exec" {
    command = "echo Step 1 done"
  }

  provisioner "local-exec" {
    command = "echo Step 2 done"
  }
}

file: Copy Files to Remote Machines

The file provisioner copies files or directories from your local machine to a remote resource. It supports SSH and WinRM connections.

resource "aws_instance" "example" {
  # ...

  # Copy a single file
  provisioner "file" {
    source      = "conf/app.conf"
    destination = "/etc/app.conf"
  }

  # Write string content to a file
  provisioner "file" {
    content     = "ami used: ${self.ami}"
    destination = "/tmp/amifile.log"
  }

  # Copy an entire directory
  provisioner "file" {
    source      = "conf/configs.d"
    destination = "/etc"
  }
}

Key things to know:

  • source is a local path (relative or absolute)
  • content lets you write a string directly to a file on the remote machine
  • destination must be an absolute path on the remote machine
  • You can use source or content, but not both

For the connection, you define it inside the provisioner block:

provisioner "file" {
  source      = "conf/app.conf"
  destination = "/etc/app.conf"

  connection {
    type     = "ssh"
    user     = "root"
    password = var.root_ssh_password
    host     = var.host_name
  }
}

Same idea works for WinRM on Windows machines. Just swap type = "ssh" to type = "winrm".

remote-exec: Run Scripts on Remote Machines

The remote-exec provisioner connects to a remote resource and runs commands there. This is the one you’d use for installing software on a newly created VM.

resource "aws_instance" "example" {
  # ...

  provisioner "remote-exec" {
    inline = [
      "puppet apply",
      "consul join ${aws_instance.example.private_ip}",
    ]
  }
}

Three ways to specify what to run:

  • inline - a list of commands executed in order
  • script - path to a local script that gets copied to the remote machine and executed
  • scripts - a list of local scripts, copied and executed in order

You can only use one of these three. They’re mutually exclusive.

Like the file provisioner, remote-exec needs SSH or WinRM access to the target machine. If the security group or firewall blocks that access, it won’t work.

The Bottom Line on Provisioners

The book sums it up well: provisioners help you run scripts locally or remotely as part of your Terraform workflow. But they’re not tracked in state, they’re hard to debug, and most of what they do can be done better with other tools.

Use them when you’re in a pinch. For everything else, look at cloud-init, user_data, or a proper config management tool.

What’s Next

That covers backends and provisioners. In Part 2, we’ll get into Terraform loops (count, for_each, for), built-in functions, and debugging. That’s where Terraform starts feeling less like a config format and more like an actual programming language.


Previous: Chapter 3 Part 2: Outputs and Data Sources

Next: Chapter 4 Part 2: Loops, Functions, and Debugging


This is part of a series retelling “HashiCorp Infrastructure Automation Certification Guide” by Ravi Mishra (Packt, 2021). For the full text, grab the book - ISBN: 978-1-80056-597-5.

About

About BookGrill.net

BookGrill.net is a technology book review site for developers, engineers, and anyone who builds things with code. We cover books on software engineering, AI and machine learning, cybersecurity, systems design, and the culture of technology.

Know More