terraform
iac
hashicorp
devops
platform-engineering
infrastructure-automation
day-2-ops

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.

Invalidate a CloudFront cache after a deploy
Invoke a Lambda to run a database migration
Stop an EC2 instance before a maintenance window
Send a Slack notification when a new environment spins up
Trigger a CodeBuild smoke test after an ECS task update
Publish an event to EventBridge when infrastructure changes

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

1

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 block — Lambda invocation examplehcl
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.

2

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.

action_trigger — CloudFront invalidation on updatehcl
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 created
after_createFires after first creation completes
before_updateFires before any update to the resource
after_updateFires after any update completes

Adding a Condition

condition — gate actions on environmenthcl
lifecycle {
  action_trigger {
    events    = [after_create, after_update]
    condition = var.environment == "production"
    actions   = [action.aws_sns_publish.pagerduty_alert]
  }
}
3

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.

for_each — invalidate CDN across three regionshcl
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 CLI flag — on-demand action executionbash
# 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_deploy

Execution 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:

1

Applies the resource update

2

Waits for the provider to confirm the resource is in its target state

3

Invokes the declared action(s) in sequence

4

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 payload
aws_ec2_stop_instanceStop an instance, polls until stopped state
aws_cloudfront_create_invalidationInvalidate CDN paths, waits for completion
aws_codebuild_start_buildTrigger a CodeBuild project, waits for completion
aws_events_put_eventsPublish custom events to EventBridge
aws_sns_publishPublish a message to an SNS topic
aws_ses_send_emailSend an email via SES
aws_sfn_start_executionStart a Step Functions state machine execution

Azure — 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.tfhcl
# 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.tfhcl
# 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.

plan → apply → test CLI invocationbash
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_migrate

Common Pitfalls

1

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.

GitHub Actions — timeout settings for long-running applyyaml
jobs:
  terraform:
    timeout-minutes: 120
    steps:
      - name: Terraform Apply
        timeout-minutes: 90
        run: terraform apply -auto-approve
2

Sensitive 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.

3

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.

4

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.

Production ECS deploy — migrations + CloudFront invalidationhcl
# 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

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.