DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Terraform Tutorial

Introduction to Terraform
Installing Terraform
The Terraform Core Workflow
Terraform Variables and Outputs
Terraform State and Backends
Terraform Modules
Terraform Data Sources
Terraform Count and for_each
Terraform Expressions and Functions
Terraform Dynamic Blocks
Terraform Workspaces
Terraform with AWS
Terraform Provisioners
Terraform CI/CD Pipeline
Terraform Security

Terraform Provisioners

PreviousPrev
Next

Learn what Terraform provisioners are, how local-exec, remote-exec, file, connection blocks, and null_resource work, and why provisioners should be a last resort.

What Are Terraform Provisioners?

Provisioners are Terraform features that let you run scripts or copy files as part of resource creation or destruction. They sit at the edge between infrastructure provisioning and machine configuration.

In plain English, provisioners answer requests like:

  • “After the EC2 instance is created, run these commands on it.”
  • “Copy this file to the new VM over SSH.”
  • “Run a local shell command after Terraform finishes creating a resource.”

Terraform introduced provisioners in its early 0.x releases, long before Terraform 1.0. They have been part of the tool for years, which is why you still see them in older blog posts, legacy codebases, and interview questions.

But there is a crucial catch: HashiCorp recommends against relying on provisioners for normal infrastructure design. They still exist, and they are sometimes useful, but they are intentionally treated as a last resort.

That tension is what makes this topic important. You need to understand both how provisioners work and why experienced Terraform users try to avoid them.

Why Provisioners Feel Attractive at First

Provisioners are appealing because they look like a shortcut.

Imagine you create an EC2 instance and then want to:

  • install Nginx
  • write a config file
  • restart a service
  • call a registration API

A provisioner can do all of that. That sounds convenient, especially if you come from a Bash or configuration-management background.

Here is the beginner mental model:

  1. create resource
  2. run commands
  3. machine is ready

That model is not wrong, but it hides some operational problems. Provisioners can fail halfway through. They can depend on SSH availability. They can make repeated applies unpredictable. They can blur the separation between “Terraform manages infrastructure state” and “something else manages software configuration.”

So before you treat provisioners as the default answer, learn their mechanics and their tradeoffs.

The Three Common Provisioners

Terraform's most common provisioners are:

  • local-exec
  • remote-exec
  • file

Each one does something slightly different.

local-exec: Run Commands on the Machine Running Terraform

local-exec executes a command on the local machine where Terraform is running. That might be:

  • your laptop
  • a CI runner
  • a build container
  • an automation host

Example:

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  provisioner "local-exec" {
    command = "echo ${self.public_ip} >> inventory.txt"
  }
}

What happens here?

After Terraform creates the EC2 instance, it runs the local shell command and appends the instance public IP to inventory.txt on the machine running Terraform.

Common uses of local-exec

  • writing an inventory file for another tool
  • calling an external API
  • triggering a follow-up script
  • sending a notification
  • running a one-off local registration command

Important limitation

local-exec does not run on the created resource. It runs locally. That distinction matters a lot.

If Terraform runs inside GitLab CI, the command runs in the CI job container or runner environment, not on your EC2 instance.

remote-exec: Run Commands on the Provisioned Resource

remote-exec runs commands on the target machine itself, usually over SSH for Linux hosts or WinRM for Windows.

Example:

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  key_name      = aws_key_pair.deployer.key_name

  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update -y",
      "sudo apt-get install -y nginx",
      "sudo systemctl enable nginx",
      "sudo systemctl restart nginx"
    ]
  }

  connection {
    type        = "ssh"
    host        = self.public_ip
    user        = "ubuntu"
    private_key = file(var.private_key_path)
  }
}

This is one of the most common provisioner examples you will see.

Why it feels useful

It lets Terraform create the VM and then configure software on it immediately. That can make a tutorial seem smooth because one terraform apply produces a working server.

Why it is fragile

This setup assumes all of the following are true at the exact right time:

  • the instance is booted and reachable
  • the security group allows SSH
  • the route table is correct
  • the private key matches
  • the correct SSH username is used
  • cloud-init has finished enough for login to work

If any one of those assumptions breaks, the apply fails even though the infrastructure resource itself may already exist.

file: Copy Files to a Remote Resource

The file provisioner copies a file or directory from the Terraform runner to the remote resource.

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  key_name      = aws_key_pair.deployer.key_name

  provisioner "file" {
    source      = "app.conf"
    destination = "/tmp/app.conf"
  }

  connection {
    type        = "ssh"
    host        = self.public_ip
    user        = "ubuntu"
    private_key = file(var.private_key_path)
  }
}

You often see file combined with remote-exec:

  1. copy a file
  2. move it into place
  3. restart the service

Example:

provisioner "remote-exec" {
  inline = [
    "sudo mv /tmp/app.conf /etc/myapp/app.conf",
    "sudo systemctl restart myapp"
  ]
}

Again, it works, but it turns Terraform into a machine bootstrap-and-configure tool, which is not its strongest role.

The connection Block

Both remote-exec and file usually need a connection block. This tells Terraform how to reach the remote host.

connection {
  type        = "ssh"
  host        = self.public_ip
  user        = "ubuntu"
  private_key = file(var.private_key_path)
}

Key fields

  • type, the connection method, commonly ssh
  • host, the destination address
  • user, the login username
  • private_key, the SSH private key contents

Other useful fields can include:

  • port
  • timeout
  • agent
  • password
  • bastion_host

Why self appears here

Inside a provisioner or connection block, self refers to the resource being provisioned. So self.public_ip means “use the public IP of this resource.”

That is convenient, but it also shows how tightly provisioners depend on resource timing and runtime connectivity.

Provisioners Run on Create or Destroy

Provisioners normally run when a resource is created. Some can also run at destroy time.

Example destroy provisioner:

resource "null_resource" "cleanup" {
  provisioner "local-exec" {
    when    = destroy
    command = "echo Cleaning up external registration"
  }
}

Destroy-time provisioners are even trickier than create-time ones because they fire during teardown, when resources may already be disappearing.

If the destroy-time cleanup is truly critical, you should think carefully about whether Terraform is the right place to model it.

Why HashiCorp Recommends Against Provisioners

This is the most important section of the lesson.

HashiCorp recommends avoiding provisioners where possible because they are often:

  • fragile
  • hard to reason about
  • not fully idempotent
  • hard to test reliably
  • outside Terraform's core plan/apply state model

Let us unpack that.

1. They are fragile

Provisioners depend on live connectivity, timing, credentials, and runtime behavior. Infrastructure might be created successfully while the provisioner still fails. That leaves you with partial success and messy recovery.

2. They are not truly declarative

Terraform is strongest when you describe the desired end state. Provisioners push you back toward procedural steps: run this command, then this file copy, then restart that service.

3. They weaken idempotence

Idempotence means you can run the same operation repeatedly and converge on the same result. Provisioners often make that harder, especially if scripts append, mutate, or depend on current machine state.

4. They blur responsibilities

Terraform is an infrastructure provisioning tool. Configuration management tools such as Ansible are built specifically for software installation, service management, and repeated host configuration.

5. They break the clean plan/apply model

Terraform can show you the plan for resource changes. It cannot give you a rich preview of the side effects inside your shell commands. That reduces predictability.

Better Alternatives to Provisioners

HashiCorp does not say “never automate post-provision tasks.” It says “use better-native patterns first.”

1. user_data or cloud-init

For VM bootstrapping, user_data and cloud-init are usually cleaner than remote-exec.

Why?

  • they are designed for first boot
  • they do not require Terraform to SSH into the machine
  • the instance can bootstrap itself
  • they fit cloud-native workflows better

2. Packer

If the same packages and files should exist on every VM, bake them into a machine image with Packer instead of installing them during every Terraform apply.

That gives you:

  • faster instance launches
  • more predictable boot behavior
  • versioned machine images

3. Ansible

If you need rich configuration management, Ansible is usually a better tool than Terraform provisioners. It is built for:

  • package installation
  • service configuration
  • file templates
  • idempotent repeatable host changes
  • fleet-wide configuration updates

4. External application deployment pipelines

If the post-provision step is “deploy the app,” that step often belongs in CI/CD, not inside Terraform.

When Provisioners Are Acceptable

Provisioners are not forbidden. They are a last resort.

Good last-resort examples include:

  • registering a resource in an external system that has no Terraform provider
  • performing a one-time bootstrap step that cannot be modeled another way
  • running a local notification or metadata capture after apply
  • temporary bridging work during migration away from legacy scripts

The key question is:

Can this be modeled in a more declarative, provider-native, or pipeline-friendly way?

If yes, choose that instead.

If no, a small carefully scoped provisioner may be acceptable.

null_resource and Triggers

Provisioners often appear together with null_resource.

A null_resource does not create real infrastructure. It is just a Terraform-managed placeholder that can run provisioners.

Example:

resource "null_resource" "build_assets" {
  triggers = {
    app_version = var.app_version
  }

  provisioner "local-exec" {
    command = "echo Building assets for ${var.app_version}"
  }
}

What are triggers?

triggers is a map of values Terraform watches. If one of those values changes, Terraform replaces the null_resource, which causes the provisioner to run again.

That makes null_resource useful for:

  • simple orchestration glue
  • one-off local tasks tied to input changes
  • legacy integrations during migration

But it also makes it easy to abuse Terraform as a generic task runner.

Example: rerun when a file hash changes

resource "null_resource" "upload_config" {
  triggers = {
    config_sha = filesha256("app.conf")
  }

  provisioner "local-exec" {
    command = "echo Config file changed"
  }
}

This can be handy, but again, ask whether the task belongs in Terraform at all.

A Realistic Decision Framework

When you feel tempted to use a provisioner, pause and ask:

  1. Is this infrastructure creation or machine configuration?
  2. Can a provider resource model this directly?
  3. Can user_data, cloud-init, or a startup script handle it more reliably?
  4. Would Packer produce a cleaner image-based solution?
  5. Would Ansible or CI/CD own this concern better?
  6. Is this a one-off edge case with no good provider support?

If the answer reaches question 6, a provisioner may be justified.

Example: A Minimal Acceptable local-exec

Here is a small example that is often easier to defend than remote host configuration:

resource "aws_eip" "web" {
  domain = "vpc"

  provisioner "local-exec" {
    command = "echo ${self.public_ip}"
  }
}

This just prints or captures information locally after a resource is created. It does not turn Terraform into a configuration engine.

Example: A Provisioner Pattern to Avoid

This style is much harder to justify:

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  provisioner "file" {
    source      = "./entire-app-directory"
    destination = "/tmp/app"
  }

  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update -y",
      "sudo apt-get install -y docker.io",
      "sudo mv /tmp/app /opt/app",
      "sudo docker compose up -d"
    ]
  }
}

Why is this weak?

  • it depends on SSH timing
  • it mixes app deployment into infra apply
  • it is hard to re-run safely
  • it becomes painful in CI/CD
  • it is difficult to preview in a plan

That workload is usually better handled by image baking, startup automation, or a deployment pipeline.

Final Guidance

Provisioners are part of Terraform, so you should know them. But mature Terraform practice is not about knowing every way to run shell commands. It is about knowing when not to do that.

The best summary is this:

  • local-exec runs on the Terraform runner
  • remote-exec runs commands on the created machine
  • file copies files to a remote machine
  • connection tells Terraform how to reach that machine
  • null_resource can act as a triggerable placeholder for provisioners
  • all of them are best treated as exceptions, not your default design pattern

If you keep that mental model, you will be able to read legacy Terraform code confidently without turning new Terraform projects into shell-script orchestration systems.

Practice Questions

Exercise

Question 1: local-exec Scope

Where does a `local-exec` provisioner run?

Exercise

Question 2: Why Avoid Provisioners

What is the main reason HashiCorp recommends avoiding provisioners when possible?

Exercise

Question 3: null_resource Triggers

What do `triggers` do on a `null_resource`?

PreviousPrev
Next

Continue Learning

Terraform AWS VPC Setup

Build a custom AWS VPC with Terraform, including public and private subnets, an internet gateway, route tables, and a NAT gateway.

14 min read·Medium

Terraform AWS EC2 Web Server

Continue the AWS Terraform project by launching an EC2 web server with a security group, key pair, user data, and Elastic IP.

12 min read·Medium

Terraform AWS S3 Static Assets

Continue the AWS Terraform project by creating an S3 bucket for static assets with versioning, lifecycle rules, public-read policy, and object uploads.

10 min read·Medium

Explore Related Topics

AW

AWS Tutorials

Provision AWS infrastructure with Terraform

CI

CI/CD Tutorials

Run terraform plan/apply in CI pipelines

Try the Tool

YAML Validator

Validate Terraform and Kubernetes YAML configs before applying.

On This Page

What Are Terraform Provisioners?Why Provisioners Feel Attractive at FirstThe Three Common Provisioners`local-exec`: Run Commands on the Machine Running TerraformWhat happens here?Common uses of `local-exec`Important limitation`remote-exec`: Run Commands on the Provisioned ResourceWhy it feels usefulWhy it is fragile`file`: Copy Files to a Remote ResourceThe `connection` BlockKey fieldsWhy `self` appears hereProvisioners Run on Create or DestroyWhy HashiCorp Recommends Against Provisioners1. They are fragile2. They are not truly declarative3. They weaken idempotence4. They blur responsibilities5. They break the clean plan/apply modelBetter Alternatives to Provisioners1. `user_data` or cloud-init2. Packer3. Ansible4. External application deployment pipelinesWhen Provisioners Are Acceptable`null_resource` and TriggersWhat are `triggers`?Example: rerun when a file hash changesA Realistic Decision FrameworkExample: A Minimal Acceptable `local-exec`Example: A Provisioner Pattern to AvoidFinal GuidancePractice Questions