Skip to main content
Technology & EngineeringTerraform273 lines

Terraform Provisioners

Terraform provisioners, null resources, triggers, and when to use alternatives

Quick Summary18 lines
You are an expert in Terraform provisioners and null resources for infrastructure as code.

## Key Points

- **`local-exec`** runs a command on the machine executing Terraform (the CI runner or your laptop).
- **`remote-exec`** runs a command on the resource being created (requires SSH or WinRM connectivity).
- **`file`** copies files or directories to the remote resource.
- **Prefer user data, cloud-init, or AMI baking** over `remote-exec` for instance bootstrapping. User data runs without SSH access and is idempotent by design.
- **Use `terraform_data` instead of `null_resource`** for Terraform 1.4+. It is built-in and does not require the null provider.
- **Set `on_failure = continue`** on destroy-time provisioners. If the resource is already gone, the provisioner will fail and block `terraform destroy`.
- **Use `triggers` or `triggers_replace` intentionally.** Make triggers depend on values that genuinely indicate when the provisioner should re-run.
- **Keep provisioner scripts idempotent.** Terraform may re-run them during recovery from partial failures.
- **Avoid storing secrets in provisioner commands.** Use the `environment` argument to pass secrets, which keeps them out of the plan output.
- **Use `working_dir`** to control the execution context of `local-exec`.
- **Provisioners are not shown in `terraform plan`.** You will not see what a provisioner will do during the plan phase. This makes them harder to review and audit.
- **`remote-exec` requires network access.** If the instance is in a private subnet with no bastion or VPN, the SSH connection will time out. Use a bastion host or switch to `local-exec` with SSM.
skilldb get terraform-skills/Terraform ProvisionersFull skill: 273 lines
Paste into your CLAUDE.md or agent config

Provisioners — Terraform

You are an expert in Terraform provisioners and null resources for infrastructure as code.

Overview

Provisioners execute scripts or commands on local or remote machines as part of the resource lifecycle. They are a "last resort" mechanism for tasks that Terraform's declarative model cannot express, such as bootstrapping a VM with a script, running database migrations, or triggering an external process. HashiCorp explicitly recommends avoiding provisioners when alternatives exist (user data, configuration management tools, or provider-specific resources).

Core Concepts

Provisioner Types

  • local-exec runs a command on the machine executing Terraform (the CI runner or your laptop).
  • remote-exec runs a command on the resource being created (requires SSH or WinRM connectivity).
  • file copies files or directories to the remote resource.

Null Resource and terraform_data

The null_resource (from the null provider) and the built-in terraform_data resource (Terraform 1.4+) are containers for provisioners when you need to run scripts that are not tied to a specific infrastructure resource.

Implementation Patterns

local-exec Provisioner

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

  provisioner "local-exec" {
    command = "echo 'Instance ${self.id} created at ${self.public_ip}' >> instances.log"
  }
}
# Run a script after creating an EKS cluster
resource "aws_eks_cluster" "main" {
  name     = "my-cluster"
  role_arn = aws_iam_role.eks.arn

  vpc_config {
    subnet_ids = var.subnet_ids
  }

  provisioner "local-exec" {
    command = <<-EOT
      aws eks update-kubeconfig \
        --name ${self.name} \
        --region ${var.region}
      kubectl apply -f ${path.module}/manifests/namespace.yaml
    EOT

    interpreter = ["/bin/bash", "-c"]
  }
}

remote-exec Provisioner

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

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/deploy_key")
    host        = self.public_ip
    timeout     = "5m"
  }

  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update -qq",
      "sudo apt-get install -y -qq docker.io",
      "sudo systemctl enable docker",
      "sudo systemctl start docker",
    ]
  }
}

file Provisioner

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

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/deploy_key")
    host        = self.public_ip
  }

  provisioner "file" {
    source      = "${path.module}/scripts/setup.sh"
    destination = "/tmp/setup.sh"
  }

  provisioner "remote-exec" {
    inline = [
      "chmod +x /tmp/setup.sh",
      "sudo /tmp/setup.sh",
    ]
  }
}

Destroy-Time Provisioner

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

  provisioner "local-exec" {
    when    = destroy
    command = "python3 scripts/deregister.py --instance-id ${self.id}"

    # Continue even if the script fails
    on_failure = continue
  }
}

null_resource with Triggers

resource "null_resource" "db_migration" {
  triggers = {
    # Re-run when the migration script changes
    migration_hash = filemd5("${path.module}/migrations/latest.sql")
    # Or re-run when the database endpoint changes
    db_endpoint = aws_db_instance.main.endpoint
  }

  provisioner "local-exec" {
    command = <<-EOT
      psql "${aws_db_instance.main.endpoint}" \
        -U ${var.db_username} \
        -f ${path.module}/migrations/latest.sql
    EOT

    environment = {
      PGPASSWORD = var.db_password
    }
  }

  depends_on = [aws_db_instance.main]
}

terraform_data (Terraform 1.4+)

terraform_data replaces null_resource without needing an external provider.

resource "terraform_data" "configure_app" {
  triggers_replace = [
    aws_instance.app.id,
    filemd5("${path.module}/scripts/configure.sh"),
  ]

  input = aws_instance.app.public_ip

  provisioner "local-exec" {
    command = "bash ${path.module}/scripts/configure.sh ${self.input}"
  }
}

Ansible Integration Pattern

resource "aws_instance" "app" {
  count         = var.instance_count
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  key_name      = aws_key_pair.deployer.key_name
}

resource "local_file" "ansible_inventory" {
  content = templatefile("${path.module}/templates/inventory.tftpl", {
    instances = aws_instance.app
  })
  filename = "${path.module}/generated/inventory.ini"
}

resource "terraform_data" "ansible_playbook" {
  triggers_replace = [
    local_file.ansible_inventory.content,
    filemd5("${path.module}/ansible/playbook.yml"),
  ]

  provisioner "local-exec" {
    command     = "ansible-playbook -i ${path.module}/generated/inventory.ini ${path.module}/ansible/playbook.yml"
    working_dir = path.module
  }

  depends_on = [local_file.ansible_inventory]
}

Templated User Data (Preferred Over remote-exec)

# Better alternative to remote-exec for initial instance configuration
resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  user_data = templatefile("${path.module}/templates/userdata.tftpl", {
    docker_version = var.docker_version
    app_image      = var.app_image
    app_port       = var.app_port
    environment    = var.environment
  })

  user_data_replace_on_change = true
}

Best Practices

  • Prefer user data, cloud-init, or AMI baking over remote-exec for instance bootstrapping. User data runs without SSH access and is idempotent by design.
  • Use terraform_data instead of null_resource for Terraform 1.4+. It is built-in and does not require the null provider.
  • Set on_failure = continue on destroy-time provisioners. If the resource is already gone, the provisioner will fail and block terraform destroy.
  • Use triggers or triggers_replace intentionally. Make triggers depend on values that genuinely indicate when the provisioner should re-run.
  • Keep provisioner scripts idempotent. Terraform may re-run them during recovery from partial failures.
  • Avoid storing secrets in provisioner commands. Use the environment argument to pass secrets, which keeps them out of the plan output.
  • Use working_dir to control the execution context of local-exec.

Core Philosophy

Provisioners are Terraform's escape hatch, not its primary tool. They exist for the narrow set of tasks that cannot be expressed declaratively: bootstrapping a VM before a configuration management tool takes over, running a one-time database migration, or triggering an external webhook. Every time you reach for a provisioner, the first question should be whether a provider-native resource, a data source, or an external tool can accomplish the same thing more reliably.

The fundamental tension with provisioners is that they introduce imperative, side-effect-driven behavior into a declarative system. Terraform cannot plan what a provisioner will do, cannot detect drift from what it did, and cannot roll back its effects. This makes provisioners invisible to the safety mechanisms that make Terraform valuable. Accepting this trade-off is sometimes necessary, but it should always be a conscious, documented decision rather than a default approach.

Idempotency is the single most important property of any provisioner script. Because Terraform may re-run provisioners during recovery from partial failures, and because null_resource triggers can fire unexpectedly, every script must be safe to execute multiple times without producing duplicate side effects. If a script cannot be made idempotent, it probably should not be a provisioner.

Anti-Patterns

  • Using remote-exec for ongoing configuration management. Provisioners run once at creation time. If you need to keep a VM's software up to date, install patches, or rotate configurations, use Ansible, Chef, Puppet, or baked AMIs. Provisioners are not a replacement for configuration management.

  • Embedding secrets in provisioner commands. Passing database passwords, API keys, or tokens directly in the command string exposes them in Terraform state, CLI output, and process listings. Use the environment argument to pass secrets, which keeps them out of the plan and logs.

  • Chaining many provisioners on a single resource. Stacking five local-exec provisioners on an instance creates a fragile, opaque setup script that is hard to debug and impossible to test independently. Extract complex provisioning logic into standalone scripts or move to a configuration management tool.

  • Using null_resource without triggers. A null_resource with no triggers block runs once and never again, even if the script it executes has changed. Without triggers, there is no mechanism to detect when the provisioner needs to re-run, leaving infrastructure in an unknown state.

  • Relying on provisioners for critical infrastructure setup. If a provisioner fails, the resource is tainted and will be destroyed on the next apply. For critical setup steps (like registering a service with a load balancer), use provider-native resources that Terraform can manage declaratively.

Common Pitfalls

  • Provisioners are not shown in terraform plan. You will not see what a provisioner will do during the plan phase. This makes them harder to review and audit.
  • remote-exec requires network access. If the instance is in a private subnet with no bastion or VPN, the SSH connection will time out. Use a bastion host or switch to local-exec with SSM.
  • Destroy-time provisioners cannot reference other resources. They can only use self and literals. Referencing another resource's attributes will cause an error.
  • Provisioner failure taints the resource. If a provisioner fails during creation, the resource is marked as tainted and will be destroyed and recreated on the next apply, even if the infrastructure itself was created successfully.
  • Null resource triggers are string-only. All trigger values are converted to strings. A list or map used as a trigger value will cause an error; use jsonencode() to serialize complex values.
  • Provisioners run once. They execute only on creation (or destruction). If you need ongoing configuration management, use a dedicated tool like Ansible, Chef, or Puppet.
  • Forgetting depends_on for implicit ordering. If a provisioner needs a resource that is not referenced in the provisioner block, Terraform does not know about the dependency. Add an explicit depends_on.

Install this skill directly: skilldb add terraform-skills

Get CLI access →