Provisioners, Actions and Post-Apply Operations

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Terraform is very good at one job: making sure that a virtual machine, a bucket, a database or a DNS record exists with the arguments you wrote. It is much less good at the jobs that come right after: installing a package on the machine, seeding a table, warming a cache, calling a webhook or running a playbook. Those jobs are post-apply operations. They are imperative ("run this, then that"), they usually are not safe to repeat blindly, and Terraform cannot show them in a plan because it cannot predict what a script will do. This page describes every tool Terraform and OpenTofu give you for them, ordered from the one you should try first to the one you should avoid.

The page is written against the documentation of Terraform 1.17 (1.17.0 was released on 2026-10-21) and OpenTofu 1.13. Provider examples use the AWS provider 6.x, hashicorp/local 2.9, hashicorp/external 2.4, hashicorp/cloudinit 2.4 and the ansible/ansible provider 1.5, which were the latest published versions when the page was checked. It follows Resources and Data Sources and Lifecycle, Validation and Checks in the reading path, which already introduced terraform_data and the action_trigger block, and it relates to Sensitive Data, Ephemeral Values and Write-only Arguments for the secrets that provisioners tend to need, and to Refactoring: moved, removed and Import for the migration away from null_resource.

The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). Hosts, addresses, account numbers and keys are placeholders. Nothing here contains a real credential: secrets arrive through TF_VAR_ environment variables or are read by the machine itself from a secret store.

The ladder: what to try before a provisioner

The official documentation is unusually blunt about this topic. The page "Perform post-apply operations" says Terraform is "primarily designed for immutable infrastructure operations" and that you should use a purpose-built solution. A provisioner is described as the thing to reach for only "when you are unable to use a purpose-built solution". In practice the choices form a ladder; each rung is more imperative and harder to reason about than the one above it.

Rung Mechanism Use it when

1

Provider arguments and first-class resources

The provider already models the thing: a database, a parameter group, a bucket policy, a Kubernetes manifest. Nothing to run after apply; the next plan shows any drift. If the provider lacks a mechanism, the documentation recommends opening a feature request instead of scripting around it.

2

Boot-time data: user_data, custom_data, metadata and cloud-init

A virtual machine must configure itself on first boot. The data travels through the cloud API, so Terraform needs no network path to the machine and no remote credentials, and auto-scaling groups work because the machine boots unattended.

3

Machine images built by Packer (or a similar tool)

The same software is installed on many machines. Installation happens at image build time, and Terraform only selects the image.

4

Configuration management (Ansible, Puppet, Chef) started by an action or by a pipeline step

The machine needs ongoing, convergent configuration that is richer than a first-boot script.

5

Provider actions (action blocks, -invoke, action_trigger)

A one-off provider-supported side effect belongs next to a resource: invoke a Lambda function, invalidate a CDN cache, stop an instance, send a notification. Terraform 1.14 and later.

6

terraform_data plus a provisioner, or the external and local providers

Nothing above fits and the operation is small, idempotent and runs on the machine that runs Terraform.

7

remote-exec and file provisioners over SSH or WinRM

Genuinely last. You need a network path to the machine, credentials for it, and you accept that a failure taints the resource.

Rung 5 is a modern addition. Before Terraform 1.14 a typical configuration reached rung 6 or 7 for jobs that an action now expresses directly, which is why older tutorials and books lean on null_resource and local-exec so heavily.

Why provisioners are a last resort

The provisioners documentation gives two reasons, and both are worth understanding because they explain the odd failure behavior described later.

  • Terraform cannot model what a provisioner does. A resource has a schema, so a plan can say that an argument changes from t3.small to t3.large. A script is a black box. The plan can only say that a provisioner exists and will run; it cannot say what the script will change, so a provisioner can never be reviewed in a plan.

  • Most provisioners need direct network access and credentials. remote-exec and file connect to the new machine over SSH or WinRM, so the machine running Terraform (a laptop, a CI runner) needs a route to a private network and a key or password. local-exec runs on the Terraform host and often needs cloud CLI credentials as well. Both widen the attack surface of the pipeline.

A third reason follows from how the lifecycle works. By default a provisioner runs once, immediately after the resource is created. Terraform does not re-run it when you edit the script, and it does not detect that the machine drifted away from the state the script created. Configuration that must stay true over time belongs in a tool that converges.

A fourth reason is security: Terraform "makes no attempts to sanitize the code being executed" in local-exec or remote-exec, nor interpolated inputs, so you are responsible for command injection from untrusted input.

OpenTofu’s documentation uses the same wording ("Provisioners are a Last Resort") and lists the same alternatives.

The provisioner block

A provisioner "<TYPE>" block lives inside a resource block (or a terraform_data resource, see below). Three types are built in. Third-party provisioner plugins can be installed into the plugin directories, but the documentation recommends using only the built-in ones.

Type What it does

file

Copies a file or directory from the machine running Terraform to the new resource over the connection (ssh or winrm).

local-exec

Runs an executable on the machine running Terraform, after the resource is created. No connection is involved.

remote-exec

Runs commands or scripts on the new resource, over the connection.

Every provisioner supports the when and on_failure meta-arguments, and an optional label. All types can carry their own connection block. Provisioners in one resource run in the order they are written.

local-exec

local-exec takes a command and runs it in a local shell on the Terraform host. It is the provisioner you will meet most, because it needs no connection.

Argument Meaning

command

Required. The command to run. It can be an absolute path or relative to the current working directory. Terraform evaluates it in a local shell. The documentation warns against substituting Terraform variables into the command text because that invites shell injection; pass them through environment instead.

working_dir

Directory in which to run the command. Relative to the current working directory or absolute. It must already exist.

interpreter

List of interpreter arguments, for example ["/bin/bash", "-c"]. The first item is the interpreter’s path. Without it Terraform uses operating-system defaults.

environment

Map of environment variables for the command. The safe place for values that came from Terraform.

when

Set to destroy to run the command when the resource is destroyed instead of when it is created.

quiet

When true, Terraform prints Suppressed by quiet=true instead of the command line; the command’s own output is still printed.

on_failure

fail (default) or continue. See below.

remote-exec

remote-exec runs commands on the remote resource and therefore needs a connection. Exactly one of three arguments selects what to run; they cannot be combined in the same provisioner.

Argument Meaning

inline

List of command strings. The remote default shell runs them unless the first element is a shebang such as #!/bin/bash.

script

Path to a local script. Terraform uploads it to the remote machine and runs it.

scripts

List of local script paths, uploaded and run in order.

Terraform uploads scripts to a temporary path and then runs them with the remote shell, so a script keeps its environment between statements. The default path is /tmp/terraform_%RAND%.sh on Unix and C:/windows/temp/terraform_%RAND%.cmd on Windows, where %RAND% is replaced by random digits. The connection block’s script_path argument overrides it for systems with another file-system layout, and the documentation recommends forward slashes even for Windows paths because the Terraform language uses the backslash as an escape character. Provisioners cannot read remote environment variables such as TMPDIR, because they run where Terraform runs.

file

The file provisioner copies content to the new machine. Exactly one of source or content is given, plus a destination.

Argument Meaning

source

A local file or directory. A trailing slash on a directory copies its contents into the destination; without it the directory itself is created under the destination. Over SSH the destination directory must already exist (a remote-exec can create it); over WinRM Terraform creates it.

content

A string written to the destination. When the destination is a directory, Terraform creates a file named tf-file-content in it, so point destination at a file.

destination

A path on the remote system, interpreted by the remote system. Over SSH it is passed unchanged to scp, which starts in the remote user’s home directory, so a relative path lands there and the remote user’s permissions limit what can be written. Over WinRM it is interpreted by PowerShell, so never put untrusted input in it.

SSH uploads require the scp program on the remote host. The documentation strongly recommends SSH over WinRM, because modern Windows can run an OpenSSH server.

The connection block

A connection block tells file and remote-exec how to reach the machine. Put it in the resource to set defaults for every provisioner of that resource, or in one provisioner block to apply to it alone. The documentation suggests using two connections so that a first provisioner connects as root to create accounts and later ones connect as an unprivileged user.

Argument Meaning and default

type

ssh (default) or winrm.

host

Required. The address to connect to.

user, password

Default user is root for SSH and Administrator for WinRM.

port

22 for SSH, 5985 for WinRM.

timeout

How long to wait for the connection to become available; default 5m.

private_key, certificate

SSH only. The contents of the key or signed certificate, so load them with the file function. private_key wins over password when both are set.

agent, agent_identity

SSH only. Whether to use ssh-agent (default false) and which identity to prefer.

host_key

SSH only. The remote host’s public key or signing CA. Host key validation is off by default because the connection usually targets a machine that was created seconds ago; set host_key if that is unacceptable.

script_path, target_platform

Where scripts are copied, and unix (default) or windows, which changes the default script path.

bastion_host, bastion_host_key, bastion_port, bastion_user, bastion_password, bastion_private_key, bastion_certificate

SSH only. Connect through a bastion host first. Unset bastion values default to the matching direct-connection values.

proxy_scheme, proxy_host, proxy_port, proxy_user_name, proxy_user_password

SSH only. Connect through an http, https or socks5 proxy; the proxy is contacted before the bastion host.

https, insecure, use_ntlm, cacert

WinRM only: use HTTPS, skip certificate-chain validation, use NTLM instead of basic authentication, and a CA certificate to validate against.

Expressions in provisioner and connection blocks cannot refer to their own resource by name, because that reference would create a dependency cycle. Use the self object instead, for example self.public_ip.

A complete (and discouraged) example

The following configuration is the pattern to recognize rather than to copy. It creates an EC2 instance for a Bookshelf worker, uploads a configuration file, and runs a script over SSH. The password is an input variable supplied through TF_VAR_worker_ssh_password, and the AMI, subnet and security group are placeholders.

terraform {
  required_version = ">= 1.15"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

variable "worker_ssh_password" {
  type      = string
  sensitive = true
}

resource "aws_instance" "worker" {
  ami                    = "ami-0123456789abcdef0" # placeholder
  instance_type          = "t3.small"
  subnet_id              = "subnet-0123456789abcdef0" # placeholder
  vpc_security_group_ids = ["sg-0123456789abcdef0"]   # placeholder

  connection {
    type     = "ssh"
    user     = "root"
    password = var.worker_ssh_password
    host     = self.public_ip
  }

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

  provisioner "remote-exec" {
    inline = [
      "install -m 0640 /tmp/worker.conf /etc/bookshelf-worker.conf",
      "systemctl restart bookshelf-worker",
    ]
  }
}

If the remote-exec fails, the instance is tainted and the next apply destroys and re-creates it. That is the intended behavior and the reason a failure is expensive: see the next section.

When provisioners run and what happens on failure

Creation-time provisioners

By default Terraform runs a provisioner immediately after it creates the resource. If it fails, Terraform marks the resource as tainted. The reasoning in the documentation is that a failed provisioner can leave the resource semi-configured, and since Terraform cannot model the provisioner, the only way to guarantee a correct resource is to create it again. The next terraform apply therefore replaces it.

The on_failure argument changes this:

Value Behavior

fail (default)

Raise an error and stop the operation. For a creation-time provisioner the resource is also tainted.

continue

Ignore the error and continue creating or destroying.

resource "terraform_data" "notify_deploy" {
  triggers_replace = [var.release_version]

  provisioner "local-exec" {
    command    = "./scripts/announce-release.sh"
    on_failure = continue
  }
}

on_failure = continue is appropriate only for operations whose failure does not matter, such as a courtesy notification. It hides real errors, so a failed seed script that must succeed should keep fail.

Destroy-time provisioners with when = destroy

Setting when = destroy makes the provisioner run before the resource is destroyed, which is the place for clean-up such as de-registering a host from a service or draining a node. The rules are easy to forget:

  • If a destroy-time provisioner fails, Terraform returns an error and runs the provisioners again on the next apply. They must therefore be safe to run more than once.

  • Enabling create_before_destroy on the resource prevents destroy-time provisioners from running.

  • A tainted resource skips its destroy-time provisioners. That includes resources tainted by a failed creation-time provisioner and resources tainted manually.

  • A destroy-time provisioner can only run if it is still in the configuration when the resource is destroyed. If you delete the whole resource block, its provisioners disappear with it and nothing runs.

The documented procedure to remove a resource that has a destroy-time provisioner safely is therefore multi-step: add count = 0 to the resource, apply (which destroys the instances and runs the provisioner), remove the resource block and its provisioners from the configuration, and apply again, when nothing is left to do. Because of this limitation the documentation says to use destroy-time provisioners "sparingly and with care".

resource "terraform_data" "worker_registration" {
  input = "bookshelf-worker-01"

  provisioner "local-exec" {
    command = "./scripts/register-worker.sh ${self.input}"
  }

  provisioner "local-exec" {
    when    = destroy
    command = "./scripts/deregister-worker.sh ${self.input}"
  }
}

Creation and destroy provisioners can share a resource: Terraform runs only the ones valid for the current operation, each group in the order it is written.

Sensitive and ephemeral values

Provisioner and connection blocks can use sensitive values; Terraform suppresses them in log output. From Terraform 1.10 they can also use ephemeral values (an ephemeral resource, variable or output), which Terraform does not persist in the plan or the state or print in logs. That makes it possible to feed a short-lived token to a provisioner without it reaching state, which is the reason this page links to Sensitive Data, Ephemeral Values and Write-only Arguments. OpenTofu’s documentation adds a detail: ephemeral values alone do not suppress provisioner output, so mark them sensitive as well if the command prints them.

terraform_data and triggers_replace

A provisioner needs a resource to attach to. When the work is not tied to one real resource, for example "seed the catalog after the database and the API are up", the documentation’s answer is the built-in terraform_data resource. It belongs to the provider terraform.io/builtin/terraform, needs no required_providers entry, follows the standard resource lifecycle and manages no real infrastructure. The resource itself is described in Resources and Data Sources; this section covers its role as a provisioner anchor.

Two arguments matter here:

  • triggers_replace stores a value and replaces the resource when the value changes. Because replacement runs the creation-time provisioners again, triggers_replace is how you control when a provisioner re-runs.

  • input stores a value that Terraform exposes as the output attribute. It is the way to turn a plain value into something replace_triggered_by can observe, and the way to pass a value to a destroy-time provisioner through self.input.

Meta-arguments such as depends_on and for_each apply as usual and decide where in the dependency graph the provisioner runs.

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

resource "docker_image" "catalog_api" {
  name = "bookshelf/catalog-api:1.4.0"
}

resource "docker_container" "catalog_api" {
  name  = "catalog-api"
  image = docker_image.catalog_api.image_id
}

# Seed the catalog once per new container, and again whenever the image changes.
resource "terraform_data" "seed_catalog" {
  triggers_replace = [
    docker_container.catalog_api.id,
    docker_image.catalog_api.image_id,
  ]

  provisioner "local-exec" {
    command     = "./scripts/seed-catalog.sh"
    working_dir = path.module

    environment = {
      CATALOG_CONTAINER = docker_container.catalog_api.name
    }
  }
}

The environment map carries the container name instead of splicing it into the command text, as the local-exec documentation recommends. The script itself should be idempotent, because replacement of the anchor, a failed apply followed by a retry, or a taint will run it again.

The documentation’s own example attaches a remote-exec bootstrap to a cluster of instances through triggers_replace, listing all instance IDs and using the first instance for the connection:

resource "aws_instance" "cluster" {
  count = 3
  # ...
}

resource "terraform_data" "cluster" {
  triggers_replace = aws_instance.cluster[*].id

  connection {
    host = aws_instance.cluster[0].public_ip
  }

  provisioner "remote-exec" {
    inline = [
      "bootstrap-cluster.sh ${join(" ", aws_instance.cluster[*].private_ip)}",
    ]
  }
}

Migrating from null_resource

null_resource from the hashicorp/null provider did the same job with a triggers map of strings. The provider’s documentation now says that on Terraform 1.4 and later you should use terraform_data instead, and that Terraform 1.9 and later support a moved block from null_resource to terraform_data. Two differences matter when you migrate: triggers_replace accepts any value, not only a map of strings, and terraform_data needs no provider download. The moved mechanism is covered in Refactoring: moved, removed and Import.

resource "terraform_data" "seed_catalog" {
  triggers_replace = [docker_container.catalog_api.id]

  provisioner "local-exec" {
    command = "./scripts/seed-catalog.sh"
  }
}

moved {
  from = null_resource.seed_catalog
  to   = terraform_data.seed_catalog
}

The page for terraform_data also describes the store block (Terraform 1.16) for keeping ephemeral and sensitive values across plan and apply; that is covered in the resources page and is not repeated here.

Passing data at boot: user_data and cloud-init

The first purpose-built alternative needs no provisioner at all. Most clouds can hand a blob of data to a machine when it is created, and most Linux images include cloud-init, which reads that blob on first boot to write files, install packages and run commands. The documentation lists the argument per cloud: user_data (and user_data_base64) for Amazon EC2, custom_data for Azure, metadata for Google Cloud, user_data for Alibaba Cloud and a CD-ROM cdrom block for vSphere.

This route has three advantages that the documentation calls out. It makes the data available immediately at boot. It is required for machines that an auto-scaling group launches unattended, when Terraform is not around to provision them. And it removes the need for network access from Terraform to the new machine and for remote credentials.

The cloudinit_config data source

The hashicorp/cloudinit provider’s cloudinit_config data source renders a multi-part MIME document from several part blocks, so a shell script and a #cloud-config file can travel together. It has gzip and base64_encode arguments (both default to true; base64 cannot be disabled when gzip is on), a boundary, and returns rendered. Each part has content (required), content_type (default text/plain), filename and merge_type. Parts are included in the order declared.

terraform {
  required_version = ">= 1.15"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    cloudinit = {
      source  = "hashicorp/cloudinit"
      version = "~> 2.4"
    }
  }
}

variable "catalog_api_image" {
  type    = string
  default = "bookshelf/catalog-api:1.4.0"
}

data "cloudinit_config" "catalog_api" {
  gzip          = true
  base64_encode = true

  part {
    content_type = "text/cloud-config"
    filename     = "cloud.conf"

    content = yamlencode({
      write_files = [
        {
          path        = "/etc/bookshelf/catalog-api.env"
          permissions = "0640"
          content     = "CATALOG_IMAGE=${var.catalog_api_image}\n"
        },
      ]
    })
  }

  part {
    content_type = "text/x-shellscript"
    filename     = "install.sh"
    content      = file("${path.module}/scripts/install-catalog-api.sh")
  }
}

resource "aws_instance" "catalog_api" {
  ami                         = "ami-0123456789abcdef0" # placeholder
  instance_type               = "t3.small"
  user_data_base64            = data.cloudinit_config.catalog_api.rendered
  user_data_replace_on_change = true
}

Notes on the example:

  • user_data_base64 is the right argument for gzip-compressed data. The AWS documentation says to use it whenever the value is not valid UTF-8, and to avoid passing gzip data through user_data.

  • By default, a change to user_data makes the AWS provider stop and start the instance, as its documentation says. user_data_replace_on_change = true makes the provider destroy and re-create the instance instead, so the new data is applied on a fresh first boot, which is the immutable behavior you usually want.

  • Do not put secrets into user data. Anyone who can read the instance metadata or the console output may see them. Have the machine fetch its secrets at boot from a secret store using its instance profile.

  • The documentation’s own write_files example builds the YAML with yamlencode, and shows file and templatefile for the file contents, so the same function library described in Functions keeps scripts out of heredocs.

Images and configuration management: the Packer and Ansible hand-off

For software that every machine needs, install it once, when you build the image, rather than on every machine at boot. The post-apply documentation recommends HashiCorp Packer or similar tools: Packer runs installation steps (it has its own provisioners for configuration management) and produces a reusable machine image. Terraform’s part is only to select the image, usually through an AMI ID passed as a variable or looked up with a data source, which keeps the Terraform plan small and the instance start-up fast. The official "Provision Infrastructure with Packer" tutorial walks through the flow.

flowchart LR subgraph Build["Image build (CI)"] P["Packer template"] --> A["Ansible playbook"] A --> I["Machine image"] end subgraph Deploy["Infrastructure (Terraform)"] T["terraform apply"] --> V["Instances from the image"] T -.->|"user_data: environment, registration"| V end I --> T V -.->|"day-two tasks via action"| X["Ansible playbook or other action"]

A subtle point from the documentation: configuration-management software that registers the machine with a central server should delay that registration until the machine boots from the final image, and the registration details should reach it through user_data or metadata. That way the machine registers itself and Terraform never needs SSH access.

When ongoing configuration is needed, for instance patching an existing fleet, the modern hand-off is an action (next section). The ansible/ansible provider defines an ansible_playbook_run action that runs one or more playbooks, with arguments such as playbooks (required), inventories or inventory_files, extra_vars, limit, tags, become, check_mode, private_key_file and vault_password_file. The Ansible provider also has data sources and resources that build an inventory from Terraform objects (ansible_inventory, ansible_host, ansible_group).

The external and local providers

Two small official providers give Terraform a controlled way to talk to programs on the Terraform host. Both are documented by their authors as escape hatches.

The external data source

data "external" runs a program and exposes its JSON output. It reads, not writes: Terraform expects a data source to have no observable side effects and re-runs the program every time the state is refreshed.

Item Meaning

program

Required list of strings: the program and its arguments. Terraform does not use a shell, so you do not need to escape metacharacters.

query

Optional map of strings, sent to the program as a JSON object on standard input. Without it the program receives an empty object.

working_dir

Optional working directory.

result (attribute)

Map of strings produced by the program. id is always -.

The protocol is strict. The program reads a JSON object from stdin, and must write a JSON object to stdout whose values are all strings and exit with status zero. On error it writes a readable message to stderr and exits non-zero; anything on stdout is then ignored. All environment variables of the Terraform process are visible to the program. The provider’s documentation recommends jq for robust JSON handling in shell scripts and warns that the mechanism hurts portability and is not recommended in Terraform Enterprise, which does not guarantee any runtimes beyond standard shell utilities.

terraform {
  required_version = ">= 1.15"

  required_providers {
    external = {
      source  = "hashicorp/external"
      version = "~> 2.4"
    }
  }
}

# Ask an internal tool for the next free release tag; it prints {"tag":"..."}.
data "external" "next_release" {
  program = ["bash", "${path.module}/scripts/next-release.sh"]

  query = {
    service = "catalog-api"
  }
}

output "catalog_api_release" {
  value = data.external.next_release.result.tag
}

A typical next-release.sh reads the service key from standard input with jq, computes the tag and prints a single JSON object such as {"tag": "1.4.1"}. The point to remember is that an external data source is a read; do not use it to create things. Prefer a native data source, a provider function, or the local_command data source below.

The local provider

The hashicorp/local provider manages files and, since its 2.6.0 release (2025-11-17), runs commands. The pieces relevant to post-apply work are:

Object Purpose

local_file resource

Writes a file on the Terraform host. Exactly one of content, content_base64, source or the deprecated sensitive_content must be set; filename is required. The default file_permission and directory_permission are 0777 before umask. The provider warns that applying on a different machine, where the file is absent, shows a diff to re-create it, which is noise in CI.

local_sensitive_file resource

Same, for content that must stay out of plan output. Use it instead of sensitive_content.

local_command data source

Runs an executable and returns exit_code, stdout and stderr as strings. A non-zero exit code is an error unless allow_non_zero_exit_code = true. Arguments: command (required), arguments, stdin, working_directory. Its documentation carries the same "escape hatch" warning as external.

local_command ephemeral resource (2.7.0)

The same, but the result is ephemeral and never reaches state or plan. Useful for reading a password from a local secret manager and feeding it to a provider configuration.

local_command action (2.6.0)

An action (see next section) that runs an executable at a lifecycle point or on demand. Arguments: command (required), arguments, environment (2.8.0), stdin, working_directory.

terraform {
  required_version = ">= 1.15"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
  }
}

# Read a version string produced by a local tool; no side effects.
data "local_command" "git_revision" {
  command   = "git"
  arguments = ["rev-parse", "--short", "HEAD"]
}

locals {
  revision = trimspace(data.local_command.git_revision.stdout)
}

# Render a configuration file next to the module for another tool to pick up.
resource "local_file" "catalog_api_env" {
  filename = "${path.module}/generated/catalog-api.env"
  content  = "CATALOG_REVISION=${local.revision}\n"
}

Because a data source runs at plan time and on every refresh, the git call above runs on every plan. That is acceptable for a read-only command that is cheap and deterministic, and wrong for anything slow or with side effects.

Actions

An action is a provider-defined operation for "day-two" work outside the create, read, update and delete model: invoke a function, invalidate a cache, run a playbook. Actions were introduced as the top-level action block in Terraform 1.14 (released 2025-11-19). The documentation states that they do not affect resource state, which is the main difference from a provisioner: a provisioner is part of a resource’s own lifecycle, an action is a separate operation. A provider developer must implement an action before it is available, so what you can do depends on the provider documentation; the Terraform documentation recommends opening a feature request with the provider if the operation you need is missing.

The action block

action "<TYPE>" "<LABEL>" {
  config {
    # provider-defined arguments and nested blocks
  }

  count    = 3             # or for_each = { ... }; mutually exclusive
  provider = aws.alias
}
Item Meaning

TYPE

Required. The action type, defined by a provider (aws_lambda_invoke, local_command, ansible_playbook_run).

LABEL

Required. A name that forms part of the action’s address.

config

The provider’s arguments and nested blocks. Inside it the special symbol caller is the resource instance that invokes the action from an action_trigger, so one action block can serve many resources without hard-coded references.

count, for_each

Invoke the action several times or once per element. They cannot be combined.

provider

Use an alternate provider configuration such as aws.alias.

An action is referenced as action.<TYPE>.<LABEL>. The block reference lists config, count, for_each and provider; it does not document depends_on for actions.

Some provider actions that exist at the time of writing, as listed in the provider repositories:

  • AWS (hashicorp/aws 6.x): aws_lambda_invoke, aws_cloudfront_create_invalidation, aws_ec2_stop_instance, aws_codebuild_start_build, aws_sfn_start_execution, aws_sns_publish, aws_ses_send_email, aws_events_put_events, aws_dynamodb_create_backup, aws_elasticache_apply_service_update, aws_dms_start_replication_task_assessment_run and aws_transcribe_start_transcription_job.

  • Local (hashicorp/local): local_command.

  • Ansible (ansible/ansible): ansible_playbook_run.

The AWS provider’s documentation for aws_lambda_invoke notes that a synchronous invocation waits for the function to finish, whereas an asynchronous one (invocation_type = "Event") returns as soon as the request is accepted. aws_cloudfront_create_invalidation waits for the invalidation to finish and has a timeout (default 900 seconds, between 60 and 3600). Check the provider page of the action you use for such behavior, because it is part of the provider and not of Terraform.

Running an action from the command line: -invoke

Add -invoke=action.<TYPE>.<LABEL> to terraform plan or terraform apply. Terraform then runs only that action and excludes the rest of the configuration, so it is the day-two "run this now" button.

$ terraform plan -invoke=action.aws_lambda_invoke.reindex
$ terraform apply -invoke=action.aws_lambda_invoke.reindex

Limits from the documentation: targeting an action with -invoke works only when the configuration is in a workspace. Stacks run only in HCP Terraform and do not support local CLI invocation, so a Stack must use action_trigger. To monitor action runs in HCP Terraform you need a configured connection to HCP Terraform. Terraform 1.16 added support for combining -invoke with -target, to name the calling resource instance when several resources trigger the same action.

Triggering actions from the lifecycle: action_trigger

An action_trigger block in the lifecycle of a resource runs actions around that resource’s own events. It is supported on resource blocks only.

Argument Meaning

events

Required list. Valid values: before_create, after_create, before_update, after_update, before_destroy, after_destroy.

condition

Optional expression; the actions run only when it evaluates to true.

actions

Required ordered list of action.<TYPE>.<LABEL> references. They run in the order listed.

on_failure

Optional keyword for what happens if an action fails: halt (default), continue or taint.

The on_failure modes are:

  • halt stops the apply and reports an error. The resource is not tainted.

  • continue logs the error as a warning and continues the apply.

  • taint stops the apply, reports an error and marks the resource as tainted, so that the next apply replaces it.

Note the contrast with provisioners, where the default failure mode taints the resource. For an action the default leaves the resource alone, and tainting is something you opt into. Use taint when the action is a required part of making the resource usable, and continue when it is best-effort. The before_* events run before the create, update or destroy operation and the after_* events after it.

The documentation adds a detail worth knowing: with after_update in the list, Terraform runs the action during an apply when it detects drift in the resource even if the configuration did not change.

terraform {
  required_version = ">= 1.16"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

variable "reindex_after_update" {
  type    = bool
  default = true
}

resource "aws_lambda_function" "search_indexer" {
  function_name = "bookshelf-search-indexer"
  role          = "arn:aws:iam::111122223333:role/bookshelf-indexer" # placeholder
  handler       = "index.handler"
  runtime       = "python3.13"
  filename      = "build/indexer.zip"

  lifecycle {
    action_trigger {
      events     = [after_create, after_update]
      condition  = var.reindex_after_update
      actions    = [action.aws_lambda_invoke.reindex]
      on_failure = continue
    }
  }
}

action "aws_lambda_invoke" "reindex" {
  config {
    function_name = aws_lambda_function.search_indexer.function_name
    payload       = jsonencode({ mode = "full" })
  }
}

This is the same example as in the lifecycle page, seen from the action side. The action is declared once; the action_trigger decides when it runs. on_failure = continue turns a failed re-index into a warning because the deployment itself succeeded.

Using caller to share one action

The caller symbol lets a single action serve several resources. The documentation’s Ansible example passes the identifier of the calling instance as an extra variable, and uses the instance’s attributes through caller.

terraform {
  required_version = ">= 1.16"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    ansible = {
      source  = "ansible/ansible"
      version = "~> 1.5"
    }
  }
}

resource "aws_instance" "web" {
  count         = 2
  ami           = "ami-0123456789abcdef0" # placeholder
  instance_type = "t3.small"

  lifecycle {
    action_trigger {
      events  = [after_create]
      actions = [action.ansible_playbook_run.configure_web]
    }
  }
}

action "ansible_playbook_run" "configure_web" {
  config {
    playbooks   = ["${path.module}/ansible/web.yml"]
    inventories = [data.ansible_inventory.web.json]

    extra_vars = {
      machine_id = caller.id
    }
  }
}

# Static inventory, as in the provider's own example; a real one is generated from the instances.
data "ansible_inventory" "web" {
  group {
    name = "webservers"

    host {
      name = "web1.example.com" # placeholder
    }
  }
}

The ansible_inventory data source follows the provider’s documented example, which builds the inventory in JSON. How to feed it with the addresses of the instances you just created is left to the provider’s documentation, because this page did not verify that schema in detail.

A local action in practice

The local_command action is the supported replacement for the most common provisioner, local-exec. The provider documentation shows an action that runs after a resource is created, and prints its standard output. Its example output (quoted from the provider page, with the long part of the plan trimmed there) looks like this:

Plan: 1 to add, 0 to change, 0 to destroy. Actions: 1 to invoke.
terraform_data.test: Creating...
terraform_data.test: Creation complete after 0s [id=4b41b541-5550-590a-9949-657e91baa346]
Action started: action.local_command.bash_example (triggered by terraform_data.test)
Action action.local_command.bash_example (triggered by terraform_data.test):

stdin: {"key1":"value1","key2":"value2"}, args: arg1 arg2

Action complete: action.local_command.bash_example (triggered by terraform_data.test)

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Two things stand out compared with a provisioner. The plan counts actions ("Actions: 1 to invoke"), so a reviewer sees that something will run, although not what it does. And the action output is labeled with the resource that triggered it.

A Bookshelf variant that runs a smoke test after the covers bucket is created, passing values by environment:

terraform {
  required_version = ">= 1.16"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
  }
}

resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-example" # placeholder

  lifecycle {
    action_trigger {
      events     = [after_create]
      actions    = [action.local_command.smoke_test]
      on_failure = continue
    }
  }
}

action "local_command" "smoke_test" {
  config {
    command   = "bash"
    arguments = ["scripts/smoke-test-covers.sh"]

    environment = {
      COVERS_BUCKET = caller.bucket
    }
  }
}

Use of caller.bucket follows the documented behavior that caller evaluates to the resource instance that invokes the action.

From provisioner to action

The table maps the usual provisioner uses to their modern replacements. The mapping is a design guide, not a mechanical rewrite: an action has no equivalent of the taint-by-default behavior and does not record anything in state.

You wrote Prefer Why

local-exec that calls a cloud CLI to invalidate a CDN cache

aws_cloudfront_create_invalidation action with action_trigger

The provider calls the API directly, waits for completion, and needs no CLI on the runner.

local-exec that invokes a Lambda function

aws_lambda_invoke action

Same, with payload, invocation type and qualifier as typed arguments.

local-exec that runs a script

local_command action, or terraform_data with a provisioner if you must support Terraform older than 1.14

Typed arguments (arguments, environment, stdin) reduce shell-injection risk.

remote-exec that installs and configures software

Packer image plus user_data; or ansible_playbook_run

No SSH path from the runner, convergent configuration.

file provisioner that uploads a configuration

cloudinit_config write_files, or a secret and parameter store the machine reads

Data travels through the cloud API.

null_resource with triggers

terraform_data with triggers_replace

Built in, and any value type.

external data source that does something with side effects

A real resource, an action, or local_command data source for reads

Data sources must be free of side effects.

Pitfalls

  • A provisioner is not a state-aware resource. Editing the script does nothing on the next plan. Add the script hash to triggers_replace, for example triggers_replace = [filesha256("scripts/seed-catalog.sh")], if a change must re-run it.

  • Tainted resources get re-created. A failed remote-exec rebuilds the whole machine on the next apply, which can be expensive. Make provisioners idempotent and consider on_failure = continue only for non-critical steps.

  • Secrets in commands. Interpolating a password into a command string places it in process listings, shell history and possibly logs. Pass secrets through environment, mark them sensitive, and prefer machines that fetch secrets themselves.

  • The cycle with self. A provisioner that names its own resource creates a dependency cycle. Use self.

  • Destroy-time provisioners and removal. Deleting the resource block deletes its destroy-time provisioner. Use the count = 0 procedure above.

  • create_before_destroy disables destroy-time provisioners. Do not rely on clean-up when you also enable it.

  • CI runners differ from laptops. Provisioners and the external and local_command data sources depend on binaries (jq, bash, git) present on the machine that runs Terraform. Pin them in the runner image. HCP Terraform and Terraform Enterprise do not guarantee runtimes beyond standard shell utilities.

  • Actions are not a state-recorded convergence. An action that runs after create does not run again when you re-apply an unchanged configuration. If the operation must run per release, trigger it by an update (after_update) or run it explicitly with -invoke from the pipeline.

  • Drift also triggers after_update. When detected drift causes an update, the action runs although your configuration did not change; make actions safe to repeat.

  • Do not wrap business operations in actions. Use them for platform side effects. Application deployment steps such as database migrations belong to a release pipeline.

What changed recently

Version Change

Terraform 1.4

terraform_data becomes the replacement for null_resource, according to the null provider documentation.

Terraform 1.9

moved block from null_resource to terraform_data is supported.

Terraform 1.10

Ephemeral values are allowed in provisioner and connection blocks.

Terraform 1.14

The top-level action block, the -invoke flag and action_trigger in lifecycle. The CLI summarizes the number of actions invoked during terraform apply, matching the plan output.

Terraform 1.15

SSH-based provisioners (file and remote-exec) support PowerShell again.

Terraform 1.16

on_failure modes halt, taint and continue for action triggers; the before_destroy and after_destroy events; -invoke combined with -target; actions are invoked respecting all resource dependencies; caller available in Stacks action configurations; terraform_data store block; and bastion_host_key is now correctly applied by provisioners, so review configured keys before upgrading.

Terraform 1.17

Current at the time of writing. The documentation pages for actions and provisioners describe this version.

Source: Terraform — CHANGELOG and the per-release changelogs on the v1.14, v1.15 and v1.16 branches.

The 1.16 change to bastion_host_key deserves attention: configurations whose key was previously ignored may start to fail host verification after upgrade, which is the correct outcome but a surprise on the upgrade day.

Terraform and OpenTofu

  • Provisioners. OpenTofu supports file, local-exec and remote-exec, connection, when = destroy, on_failure and provisioners without a resource through terraform_data, with the same last-resort advice. quiet for local-exec is documented as well. Output of a provisioner is suppressed automatically when it uses sensitive values.

  • terraform_data. OpenTofu has the terraform_data managed resource and the moved block, so the migration from null_resource applies there too.

  • Actions. Actions are a Terraform 1.14 feature. At the time of writing the OpenTofu documentation has no action block or -invoke page, and the OpenTofu repository tracks an open issue titled "Support action blocks and action_trigger lifecycle properties" (#3309). A configuration that uses action, action_trigger or -invoke will therefore not run on OpenTofu 1.13; keep a provisioner or terraform_data fallback if you must support both tools.

  • Providers. The external, local, cloudinit and null providers are ordinary providers and work in both tools. Provider actions are meaningful only on a tool that supports the action block.

  • Naming. The command is tofu; the language blocks are otherwise the same for provisioners.

Where books and older tutorials are out of date

Most Terraform books were written before Terraform 1.14, and they teach post-apply operations with the tools that existed then. When you read them, adjust in four places.

  • They often present local-exec and remote-exec as the normal way to bootstrap machines. Current documentation treats them as the last rung of the ladder above.

  • They use null_resource and its triggers map. Use terraform_data with triggers_replace on Terraform 1.4 and later.

  • They cannot describe action blocks, -invoke or action_trigger, and so recommend a provisioner for jobs such as cache invalidation or function invocation.

  • They show passwords and keys inline in connection blocks. Use sensitive or ephemeral variables, TF_VAR_ environment variables, SSH agents or certificates, and set host_key.

Choosing quickly

  • The provider has an argument or resource for it: use that.

  • A machine must configure itself on first boot: user_data with cloudinit_config, and user_data_replace_on_change where applicable.

  • Many machines share software: Packer image.

  • One provider-supported side effect after a resource event: an action with action_trigger.

  • The same side effect on demand: terraform apply -invoke=action.<TYPE>.<LABEL>.

  • Ongoing configuration of a fleet: Ansible, started by an action or by a pipeline.

  • A local, idempotent step with no action available, on a version older than 1.14: terraform_data with triggers_replace and local-exec.

  • A remote step with no alternative: remote-exec over SSH with a host_key, and accept the taint-on-failure semantics.