Terraform Actions Block
The Complete Guide to Day 2 Operations in IaC
Terraform 1.14 introduced the `action` block — and it quietly solved the problem every cloud engineer has worked around for years. Invalidate a CDN. Run a database migration. Send an alert. All from within the plan/apply lifecycle, no bash scripts required. Here's the complete picture: syntax, catalog, pitfalls, and the OpenTofu fork you need to understand before you ship this to shared modules.
The IaC Gap Nobody Talks About (Until Oncall Fires)
Here is a scenario every cloud engineer has lived through. You write pristine Terraform code. Infrastructure deploys cleanly. Day 1 is a success.
Then Day 2 arrives — and it arrives with a list of things Terraform simply doesn't do.
The traditional answer: write a bash script, wire it to a null_resource, or bolt it onto a CI pipeline as a separate step. Those scripts accumulate. They drift. They break silently. And when they do, nobody can find them because they live in a different directory — or a different repository — from the infrastructure they're supposed to operate on.
Terraform 1.14 introduced a direct answer: the action block. This is not a minor feature enhancement. It is a fundamental shift in what IaC is allowed to do.
Actions Are Not Resources
Before going deep: a critical distinction that trips people up.
A resource block
Tells Terraform to ensure something exists with specific attributes — a VPC, an S3 bucket, a Kubernetes namespace. Terraform reconciles resources against reality on every plan and apply. The state file tracks them. Drift is detectable and correctable.
An action block
Tells Terraform to do something at a specific point in time. No state is recorded. No drift detection. The operation runs when triggered and Terraform moves on. Visible in run history (HCP Terraform) and provider-side logs, but the state file has no record that the action happened.
This is the right design. Trying to model “I stopped an EC2 instance” as Terraform state would create a mess. Treating it as an event is correct. The separation keeps declarative infrastructure declarative and makes imperative operations explicit.
The Syntax: How It Actually Works
The action Block
Top-Level HCL — Same Level as resource, data, variable
The action block is a new top-level HCL construct. Its type is defined by the provider following the convention <provider>_<operation>. Declaring an action block does nothing on its own — it must be triggered by a resource lifecycle event or invoked directly from the CLI.
action "aws_lambda_invoke" "notify_deploy" {
config {
function_name = aws_lambda_function.notifier.function_name
payload = jsonencode({
environment = var.environment
version = var.app_version
timestamp = timestamp()
})
}
}The action_type (aws_lambda_invoke) is provider-defined. The symbolic_name (notify_deploy) is how you reference this action in triggers and CLI invocations.
The action_trigger Lifecycle Block
Lives Inside a Resource's lifecycle Block
The action_trigger block connects lifecycle events on a resource to one or more declared actions. It accepts an events list, an actions list (executed in sequence), and an optional condition expression.
resource "aws_cloudfront_distribution" "cdn" {
# ... distribution config ...
lifecycle {
action_trigger {
events = [after_update]
actions = [action.aws_cloudfront_create_invalidation.bust_cache]
}
}
}Supported Events
before_createFires before the resource is first createdafter_createFires after first creation completesbefore_updateFires before any update to the resourceafter_updateFires after any update completesAdding a Condition
lifecycle {
action_trigger {
events = [after_create, after_update]
condition = var.environment == "production"
actions = [action.aws_sns_publish.pagerduty_alert]
}
}for_each and the CLI -invoke Flag
Beyond Single-Resource Binding
Actions support for_each and count meta-arguments, just like resources. This makes multi-region and multi-target operational patterns first-class. One constraint: for_each values must be known at plan time — deriving them from a freshly-created resource's ID will error.
variable "notification_regions" {
default = ["us-east-1", "eu-west-1", "ap-southeast-1"]
}
action "aws_cloudfront_create_invalidation" "bust_all_cdns" {
for_each = toset(var.notification_regions)
config {
distribution_id = aws_cloudfront_distribution.cdn[each.value].id
paths = ["/*"]
}
}You can also invoke any declared action directly from the CLI without binding it to a resource lifecycle. Useful for one-off operational tasks you want in code but executed on demand: database seeding, cache warm-up, smoke test invocations.
# Invoke a declared action directly — no resource update required
terraform apply -invoke=action.aws_lambda_invoke.notify_deploy
# Dry-run: see what the action would do without executing
terraform plan -invoke=action.aws_lambda_invoke.notify_deployExecution Model: How Terraform Handles Actions
Actions participate in Terraform's dependency graph, but their position relative to the resource events they're bound to is fixed. When Terraform processes an after_update trigger:
Applies the resource update
Waits for the provider to confirm the resource is in its target state
Invokes the declared action(s) in sequence
Waits for each action to complete before proceeding to the next change
Step 4 is the one that catches CI/CD pipelines off guard. Terraform's apply process blocks on each action. It does not proceed to other changes until the action completes. If your pipeline has a step timeout and your CloudFront invalidation takes longer than that, you will hit the timeout in a partial-apply state. See Pitfall #1.
The Current Catalog (May 2026)
The catalog is early. Here is the honest picture. AWS got the launch focus; Azure has one entry; GCP has nothing yet. The provider SDK's actions API is documented and the pattern is established — the gaps are a contribution opportunity, not a permanent state.
AWS — 8 Actions
aws_lambda_invokeInvoke a Lambda with a payloadaws_ec2_stop_instanceStop an instance, polls until stopped stateaws_cloudfront_create_invalidationInvalidate CDN paths, waits for completionaws_codebuild_start_buildTrigger a CodeBuild project, waits for completionaws_events_put_eventsPublish custom events to EventBridgeaws_sns_publishPublish a message to an SNS topicaws_ses_send_emailSend an email via SESaws_sfn_start_executionStart a Step Functions state machine executionAzure — 1 Action
azurerm_virtual_machine_powerControl VM power state (start / stop / restart)GCP — 0 Actions
Not yet implemented. Provider SDK is ready — GCP actions are a contribution opportunity.
The catalog gap is an opportunity. The Terraform provider SDK's actions API is documented and the pattern is established. If you need aws_rds_create_snapshot, aws_eks_rolling_restart, or any other operation that isn't listed yet, contributing to the AWS provider is a real path forward. The framework is there; the coverage needs expanding.
Implementation Guide: Step by Step
Prerequisites
Terraform 1.14+
Actions are GA and stable; unchanged in 1.15
AWS Provider 5.x+
Required for all aws_* action types
Provider changelog
Check your specific provider version implements the action you need
Step 1: Declare Your Action
# actions.tf
action "aws_lambda_invoke" "db_migrate" {
config {
function_name = aws_lambda_function.migrator.function_name
payload = jsonencode({
action = "migrate"
direction = "up"
})
}
}Step 2: Wire It to a Resource Lifecycle
# main.tf
resource "aws_ecs_service" "app" {
name = "my-app"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
lifecycle {
action_trigger {
events = [after_update]
condition = var.run_migrations
actions = [action.aws_lambda_invoke.db_migrate]
}
}
}Steps 3–5: Plan, Apply, and Test CLI Invocation
The plan output shows planned action invocations alongside resource changes explicitly — review them before applying. After the ECS service updates, Terraform invokes the migration Lambda and waits for completion before marking the apply done.
terraform plan
terraform apply
# Test the action independently, outside a full apply:
terraform plan -invoke=action.aws_lambda_invoke.db_migrate
terraform apply -invoke=action.aws_lambda_invoke.db_migrateCommon Pitfalls
Long-Running Actions That Stall Your Pipeline
The single biggest operational gotcha in CI/CD
aws_ec2_stop_instance polls until the instance reaches stopped state. aws_cloudfront_create_invalidation waits for CloudFront propagation across all edge locations — this can take 20 minutes or more. Terraform's apply blocks on each action. It does not proceed to other changes until the action completes. If your pipeline job has a step-level timeout set to 30 minutes and your CloudFront invalidation takes 25, you will hit the timeout in a partial-apply state.
Fix: set your pipeline job timeout to at least 2× the longest expected action duration.
jobs:
terraform:
timeout-minutes: 120
steps:
- name: Terraform Apply
timeout-minutes: 90
run: terraform apply -auto-approveSensitive Values in for_each
Terraform uses for_each keys as identifiers in plan output — and plan output is not redacted. If you attempt to pass a sensitive variable as a for_each value on an action, Terraform will error. This is by design but catches people off guard. Use non-sensitive keys (region names, environment labels) and pass sensitive data through the config block instead.
for_each Keys Must Be Known at Plan Time
If you're deriving for_each values from resource attributes that aren't known until apply (like a freshly created resource's ID), Terraform cannot plan the actions and will error. Stage your applies: first apply to create the resources, then apply again for the actions in a second run.
Action Results Can't Feed Downstream Resources
Actions don't produce outputs that other resources can consume via depends_on or attribute references. If you need an operation's response to inform a subsequent resource attribute — for example, a Lambda invocation that returns a value you want to store — use a data source or the external provider instead. Actions are fire-and-forget from Terraform's state perspective.
The OpenTofu Situation
If your organization runs OpenTofu instead of HashiCorp Terraform, actions are not available to you.GitHub issue #3309in the OpenTofu repository — “Support action blocks and action_trigger lifecycle properties” — is labeled pending-decision. The team has neither committed to implementing it nor ruled it out. There is no timeline.
If you share modules across both runtimes
Do not use actions in shared modules. Fall back to null_resource + local-exec or separate pipeline steps. The moment you add an action block to a shared module, OpenTofu users get a parse error.
If you run only HashiCorp Terraform
Actions are GA in 1.14 and 1.15. Adopt freely. The feature is stable, the pattern is right, and the catalog will grow.
This is the most concrete feature fork between HashiCorp Terraform and OpenTofu since the license change. Actions are a feature-level divergence, not just a governance-level one. Shared module authors need to decide: support both runtimes at the lowest common denominator, or specialize for one. That decision is now non-trivial.
Real-World Pattern: Zero-Downtime ECS Deploy
Migrations + Cache Bust, No Pipeline Scripts
On every production ECS task definition update, Terraform: deploys the new task definition, runs the database migration Lambda, waits for completion, invalidates the CDN cache, and waits for propagation. No external pipeline steps. No separate scripts. The complete deploy sequence is in code, version-controlled alongside the infrastructure it operates on.
# 1. Deploy Lambda migration runner
resource "aws_lambda_function" "db_migrator" {
function_name = "${var.app_name}-migrator"
# ... Lambda config ...
}
# 2. Declare the migration action
action "aws_lambda_invoke" "run_migrations" {
config {
function_name = aws_lambda_function.db_migrator.function_name
payload = jsonencode({
direction = "up"
env = var.environment
})
}
}
# 3. Declare the cache invalidation action
action "aws_cloudfront_create_invalidation" "bust_cache" {
config {
distribution_id = aws_cloudfront_distribution.cdn.id
paths = ["/api/*", "/static/*"]
}
}
# 4. Wire both to the ECS service lifecycle
resource "aws_ecs_service" "app" {
name = var.app_name
task_definition = aws_ecs_task_definition.app.arn
lifecycle {
action_trigger {
events = [after_update]
condition = var.environment == "production"
actions = [
action.aws_lambda_invoke.run_migrations,
action.aws_cloudfront_create_invalidation.bust_cache,
]
}
}
}What You Need to Know
The action block is Terraform's acknowledgment that infrastructure management and operational execution are not separate concerns — they just need separate primitives.
Actions are not resources. No state recorded. No drift detection. They execute at a specific point in time and Terraform moves on. This is the correct design.
action_trigger lives inside a resource's lifecycle {} block and connects events (before_create, after_create, before_update, after_update) to ordered lists of actions with an optional condition expression.
Actions support for_each and count, just like resources. for_each values must be known at plan time — you cannot derive them from freshly-created resource attributes.
The -invoke CLI flag lets you run any declared action on demand without a full apply. Useful for one-off operational tasks that belong in code but execute manually.
The current catalog: 8 AWS actions, 1 Azure action, 0 GCP actions. The provider SDK is open and documented — the gaps are contribution opportunities.
Long-running actions (CloudFront invalidation, EC2 stop polling) block the apply process. Set pipeline job timeouts to at least 2× the longest expected action duration.
OpenTofu does not implement actions (issue #3309, pending-decision). Shared module authors must choose: lowest common denominator or runtime specialization. This is now a non-trivial decision.
The feature is GA and stable as of Terraform 1.14, carried forward unchanged in 1.15. If you're running only HashiCorp Terraform, you can adopt this today.
Sources
HashiCorp: action block language reference
https://developer.hashicorp.com/terraform/language/block/action
HashiCorp: Invoke an action (CLI and lifecycle)
https://developer.hashicorp.com/terraform/language/invoke-actions
Terraform Actions Deep Dive — mattias.engineer
https://mattias.engineer/blog/2025/terraform-actions-deep-dive/
OpenTofu Issue #3309 — Support action blocks and action_trigger lifecycle properties
https://github.com/opentofu/opentofu/issues/3309
Terraform 1.14 Complete Guide — DEV Community
https://dev.to/x4nent/terraform-114-the-complete-guide-to-list-resources-tfqueryhcl-actions-block-and-terraform-350j
Day 2 Is Where Infrastructure Teams Live.
Declarative IaC was always going to need to handle more than CRUD. The action block is a cleaner answer than anything that came before it. The catalog is early. The pattern is right. Start with one action, wire it to the resource it belongs with, and delete the bash script it replaces.
Related Posts
Terraform 1.14 Actions: When Declarative IaC Goes Imperative
Terraform 1.14 introduces Actions — first-class imperative blocks that let you invoke provider-defined operations directly within the plan/apply lifecycle. No more 500-line Bash wrappers. Here's what Actions are, how they work, where the boundaries are, and how to adopt them without turning your Terraform into Ansible.
Terraform State Management at Scale: The Environment Isolation Problem
Remote backends are necessary, but they do not solve state topology. Once you scale to multiple environments and dozens of services, the real problem is environment isolation, blast radius, and operational guardrails. This guide breaks down workspaces vs directories vs Terragrunt, the failure modes at scale, and a decision framework that actually works.
MCP Is the USB-C of DevOps: The Governance Playbook Teams Need Before the First "Deploy Staging" Prompt
MCP has crossed from demo protocol to real platform plumbing for DevOps workflows, but the blocker is not model quality. It is governance: transport choices, identity, approval gates, server trust, auditability, and rollout discipline. This guide separates hype from what is actually production-relevant in Q1 2026.