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: |
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 ( |
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 |
|
Nothing above fits and the operation is small, idempotent and runs on the machine that runs Terraform. |
7 |
|
Genuinely last. You need a network path to the machine, credentials for it, and you accept that a failure taints the resource. |
Source: Terraform Docs — Perform post-apply operations and Terraform Docs — Use provisioners to perform post-apply operations.
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.smalltot3.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-execandfileconnect 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-execruns 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 |
|---|---|
|
Copies a file or directory from the machine running Terraform to the new resource over the connection ( |
|
Runs an executable on the machine running Terraform, after the resource is created. No connection is involved. |
|
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 |
|---|---|
|
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 |
|
Directory in which to run the command. Relative to the current working directory or absolute. It must already exist. |
|
List of interpreter arguments, for example |
|
Map of environment variables for the command. The safe place for values that came from Terraform. |
|
Set to |
|
When |
|
|
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 |
|---|---|
|
List of command strings. The remote default shell runs them unless the first element is a shebang such as
|
|
Path to a local script. Terraform uploads it to the remote machine and runs it. |
|
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 |
|---|---|
|
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
|
|
A string written to the destination. When the destination is a directory, Terraform creates a file named
|
|
A path on the remote system, interpreted by the remote system. Over SSH it is passed unchanged to |
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 |
|---|---|
|
|
|
Required. The address to connect to. |
|
Default user is |
|
|
|
How long to wait for the connection to become available; default |
|
SSH only. The contents of the key or signed certificate, so load them with the |
|
SSH only. Whether to use |
|
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 |
|
Where scripts are copied, and |
|
SSH only. Connect through a bastion host first. Unset bastion values default to the matching direct-connection values. |
|
SSH only. Connect through an |
|
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 |
|---|---|
|
Raise an error and stop the operation. For a creation-time provisioner the resource is also tainted. |
|
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_destroyon 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
resourceblock, 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_replacestores a value and replaces the resource when the value changes. Because replacement runs the creation-time provisioners again,triggers_replaceis how you control when a provisioner re-runs. -
inputstores a value that Terraform exposes as theoutputattribute. It is the way to turn a plain value into somethingreplace_triggered_bycan observe, and the way to pass a value to a destroy-time provisioner throughself.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
}
Source: Terraform Registry — null_resource.
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
}
Source: Terraform Registry — cloudinit_config data source and Terraform Docs — Perform post-apply operations.
Notes on the example:
-
user_data_base64is 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 throughuser_data. -
By default, a change to
user_datamakes the AWS provider stop and start the instance, as its documentation says.user_data_replace_on_change = truemakes 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_filesexample builds the YAML withyamlencode, and showsfileandtemplatefilefor 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.
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 |
|---|---|
|
Required list of strings: the program and its arguments. Terraform does not use a shell, so you do not need to escape metacharacters. |
|
Optional map of strings, sent to the program as a JSON object on standard input. Without it the program receives an empty object. |
|
Optional working directory. |
|
Map of strings produced by the program. |
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 |
|---|---|
|
Writes a file on the Terraform host. Exactly one of |
|
Same, for content that must stay out of plan output. Use it instead of |
|
Runs an executable and returns |
|
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. |
|
An action (see next section) that runs an executable at a lifecycle point or on demand. Arguments: |
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"
}
Source: Terraform Registry — local_command data source and Terraform Registry — local_file resource.
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 |
|---|---|
|
Required. The action type, defined by a provider ( |
|
Required. A name that forms part of the action’s address. |
|
The provider’s arguments and nested blocks. Inside it the special symbol |
|
Invoke the action several times or once per element. They cannot be combined. |
|
Use an alternate provider configuration such as |
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/aws6.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_runandaws_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
Source: Terraform Docs — Invoke an action.
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 |
|---|---|
|
Required list. Valid values: |
|
Optional expression; the actions run only when it evaluates to |
|
Required ordered list of |
|
Optional keyword for what happens if an action fails: |
The on_failure modes are:
-
haltstops the apply and reports an error. The resource is not tainted. -
continuelogs the error as a warning and continues the apply. -
taintstops 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
}
}
}
Source: Terraform Docs — resource block reference, invoke an action and Terraform Registry — ansible_playbook_run action.
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 |
|---|---|---|
|
|
The provider calls the API directly, waits for completion, and needs no CLI on the runner. |
|
|
Same, with payload, invocation type and qualifier as typed arguments. |
|
|
Typed arguments ( |
|
Packer image plus |
No SSH path from the runner, convergent configuration. |
|
|
Data travels through the cloud API. |
|
|
Built in, and any value type. |
|
A real resource, an action, or |
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 exampletriggers_replace = [filesha256("scripts/seed-catalog.sh")], if a change must re-run it. -
Tainted resources get re-created. A failed
remote-execrebuilds the whole machine on the next apply, which can be expensive. Make provisioners idempotent and consideron_failure = continueonly for non-critical steps. -
Secrets in commands. Interpolating a password into a
commandstring places it in process listings, shell history and possibly logs. Pass secrets throughenvironment, 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 = 0procedure above. -
create_before_destroydisables destroy-time provisioners. Do not rely on clean-up when you also enable it. -
CI runners differ from laptops. Provisioners and the
externalandlocal_commanddata 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-invokefrom 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 1.9 |
|
Terraform 1.10 |
Ephemeral values are allowed in |
Terraform 1.14 |
The top-level |
Terraform 1.15 |
SSH-based provisioners ( |
Terraform 1.16 |
|
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-execandremote-exec,connection,when = destroy,on_failureand provisioners without a resource throughterraform_data, with the same last-resort advice.quietforlocal-execis documented as well. Output of a provisioner is suppressed automatically when it uses sensitive values. -
terraform_data. OpenTofu has theterraform_datamanaged resource and themovedblock, so the migration fromnull_resourceapplies there too. -
Actions. Actions are a Terraform 1.14 feature. At the time of writing the OpenTofu documentation has no
actionblock or-invokepage, and the OpenTofu repository tracks an open issue titled "Support action blocks and action_trigger lifecycle properties" (#3309). A configuration that usesaction,action_triggeror-invokewill therefore not run on OpenTofu 1.13; keep a provisioner orterraform_datafallback if you must support both tools. -
Providers. The
external,local,cloudinitandnullproviders are ordinary providers and work in both tools. Provider actions are meaningful only on a tool that supports theactionblock. -
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-execandremote-execas the normal way to bootstrap machines. Current documentation treats them as the last rung of the ladder above. -
They use
null_resourceand itstriggersmap. Useterraform_datawithtriggers_replaceon Terraform 1.4 and later. -
They cannot describe
actionblocks,-invokeoraction_trigger, and so recommend a provisioner for jobs such as cache invalidation or function invocation. -
They show passwords and keys inline in
connectionblocks. Use sensitive or ephemeral variables,TF_VAR_environment variables, SSH agents or certificates, and sethost_key.
Choosing quickly
-
The provider has an argument or resource for it: use that.
-
A machine must configure itself on first boot:
user_datawithcloudinit_config, anduser_data_replace_on_changewhere applicable. -
Many machines share software: Packer image.
-
One provider-supported side effect after a resource event: an
actionwithaction_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_datawithtriggers_replaceandlocal-exec. -
A remote step with no alternative:
remote-execover SSH with ahost_key, and accept the taint-on-failure semantics.
References
-
Terraform Docs — Use provisioners to perform post-apply operations
-
Terraform Docs — resource block reference (provisioner, connection, action_trigger)
-
HashiCorp Developer — Provision Infrastructure with Cloud-Init
-
Terraform Registry — aws_cloudfront_create_invalidation action
-
OpenTofu — Support action blocks and action_trigger lifecycle properties