Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 089 / 100

Step Functions with Terraform: best practices explained

Workflow orchestration as code.

Expona Admin9 min read

Step Functions with Terraform: best practices explained

AWS Step Functions let you orchestrate complex workflows using state machines, but deploying them safely and observably requires discipline. When you define state machines as code in Terraform, you gain the ability to version control your workflows, test them in multiple environments, and catch configuration errors before they reach production. In this article, we'll walk through the patterns our team uses to build Step Functions that are robust, secure, and maintainable.

Why orchestration as code matters

Step Functions convert your workflows into declarative JSON state machines that AWS executes for you. Without Terraform, you'd either hand-craft JSON in the console or build brittle CloudFormation templates. Terraform's templating, validation, and modular design make it the natural choice for teams managing infrastructure at scale.

The real power emerges when you treat your state machine definition the same way you treat your infrastructure: version it, review it in pull requests, and test it in staging before pushing to production. A misconfigured retry or catch block can cost hours of troubleshooting in production; catching these issues early saves your team time and your customers from broken workflows.

Define the state machine with templatefile

The core of a Step Function is its state machine definition, written in Amazon States Language (ASL). While you can inline small definitions in Terraform strings, production workflows quickly become unreadable. We use templatefile() to separate the state machine into its own file, making it easier to maintain and test independently.

Here's a real-world example: an e-commerce order processing workflow that validates the order, charges the customer, and notifies them of success or failure.

hcl
resource "aws_sfn_state_machine" "order_processor" {
  name       = "order-processor-${var.environment}"
  role_arn   = aws_iam_role.sfn_role.arn
  definition = templatefile("${path.module}/state_machines/order.json", {
    validate_function_arn = aws_lambda_function.validate_order.arn
    charge_function_arn   = aws_lambda_function.charge_payment.arn
    notify_function_arn   = aws_lambda_function.notify_customer.arn
    dlq_arn               = aws_sqs_queue.dead_letter.arn
  })

  tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

The corresponding state_machines/order.json file uses Terraform variable interpolation:

hcl
{
  "Comment": "Order processing workflow",
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "${validate_function_arn}",
      "TimeoutSeconds": 30,
      "Next": "ChargePayment",
      "Catch": [
        {
          "ErrorEquals": ["ValidationError"],
          "Next": "RejectOrder"
        }
      ]
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "${charge_function_arn}",
      "TimeoutSeconds": 60,
      "Next": "NotifyCustomer",
      "Catch": [
        {
          "ErrorEquals": ["PaymentFailed"],
          "Next": "HandlePaymentError"
        },
        {
          "ErrorEquals": ["States.ALL"],
          "Next": "CriticalFailure"
        }
      ]
    },
    "NotifyCustomer": {
      "Type": "Task",
      "Resource": "${notify_function_arn}",
      "TimeoutSeconds": 45,
      "End": true
    },
    "RejectOrder": {
      "Type": "Pass",
      "Result": "Order rejected due to validation failure",
      "End": true
    },
    "HandlePaymentError": {
      "Type": "Pass",
      "Result": "Payment processing failed, customer notified",
      "End": true
    },
    "CriticalFailure": {
      "Type": "Pass",
      "Result": "Unexpected error encountered",
      "End": true
    }
  }
}

By separating the state machine definition into its own JSON file, your team can review workflows alongside infrastructure code. The templatefile() function safely injects Lambda ARNs and other dynamic values, eliminating hardcoding while keeping the definition readable.

Retries and catches: declare error handling per step

Most production workflows fail at some point. A downstream API might be temporarily unavailable, or a Lambda invocation might hit a transient error. Rather than letting the entire state machine fail, you declare retry and catch policies per step.

Retries automatically re-execute a task with exponential backoff. Use them for transient failures like network timeouts or rate-limiting. Catches let you respond to specific error classes, either by moving to a fallback state or by failing gracefully.

Here's a realistic payment processing state with both retry and catch logic:

hcl
{
  "ChargeCard": {
    "Type": "Task",
    "Resource": "arn:aws:states:::aws-sdk:dynamodb:updateItem",
    "Parameters": {
      "TableName": "payments",
      "Key": {
        "order_id": {
          "S.$": "$.order_id"
        }
      },
      "UpdateExpression": "SET #status = :status",
      "ExpressionAttributeNames": {
        "#status": "status"
      },
      "ExpressionAttributeValues": {
        ":status": {
          "S": "charged"
        }
      }
    },
    "Retry": [
      {
        "ErrorEquals": ["States.TaskFailed"],
        "IntervalSeconds": 2,
        "MaxAttempts": 3,
        "BackoffRate": 2.0
      },
      {
        "ErrorEquals": ["DynamoDB.ServiceUnavailableException"],
        "IntervalSeconds": 1,
        "MaxAttempts": 5,
        "BackoffRate": 1.5
      }
    ],
    "Catch": [
      {
        "ErrorEquals": ["DynamoDB.ValidationException"],
        "Next": "InvalidPaymentData",
        "ResultPath": "$.error"
      },
      {
        "ErrorEquals": ["States.ALL"],
        "Next": "PaymentFailed",
        "ResultPath": "$.error"
      }
    ],
    "Next": "ChargeSucceeded",
    "TimeoutSeconds": 30
  }
}

The strategy here is clear: if the task fails with a transient error, we retry with exponential backoff (2 seconds, then 4, then 8). If it fails with a validation error, we move to InvalidPaymentData. Any other error goes to PaymentFailed, and we preserve the error details in $.error for logging and investigation.

In Terraform, you can even parameterize these values:

hcl
locals {
  payment_retry_config = {
    max_attempts    = 3
    interval_seconds = 2
    backoff_rate    = 2.0
  }
}

# Then reference in your templatefile:
resource "aws_sfn_state_machine" "payment_machine" {
  definition = templatefile("${path.module}/state_machines/payment.json", {
    max_retry_attempts  = local.payment_retry_config.max_attempts
    retry_interval      = local.payment_retry_config.interval_seconds
    retry_backoff_rate  = local.payment_retry_config.backoff_rate
  })
}

This approach lets you tune retry behavior without modifying the state machine definition directly—useful when you want different retry strategies across environments.

IAM: allow the state machine to invoke only its tasks

Step Functions must assume an IAM role to invoke Lambda functions, call DynamoDB, or publish to SNS. The principle of least privilege demands that this role can only invoke the specific resources it needs, nothing more.

Here's how we structure IAM for a state machine:

hcl
resource "aws_iam_role" "sfn_role" {
  name = "sfn-order-processor-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "states.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy" "sfn_invoke_lambdas" {
  name = "invoke-lambdas"
  role = aws_iam_role.sfn_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "lambda:InvokeFunction"
        ]
        Resource = [
          aws_lambda_function.validate_order.arn,
          aws_lambda_function.charge_payment.arn,
          aws_lambda_function.notify_customer.arn
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy" "sfn_write_logs" {
  name = "write-logs"
  role = aws_iam_role.sfn_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogDelivery",
          "logs:GetLogDelivery",
          "logs:UpdateLogDelivery",
          "logs:DeleteLogDelivery",
          "logs:ListLogDeliveries",
          "logs:PutResourcePolicy",
          "logs:DescribeResourcePolicies",
          "logs:DescribeLogGroups"
        ]
        Resource = "*"
      }
    ]
  })
}

Notice that we grant lambda:InvokeFunction only to the three specific Lambda ARNs used in the workflow. If a developer accidentally references a fourth Lambda in the state machine definition, the execution will fail—which is exactly what we want. This catches configuration errors before they cause silent failures in production.

For workflows that interact with DynamoDB, SQS, or SNS, add separate policy statements that scope access to specific resources:

hcl
resource "aws_iam_role_policy" "sfn_dynamodb_access" {
  name = "dynamodb-access"
  role = aws_iam_role.sfn_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:UpdateItem",
          "dynamodb:PutItem",
          "dynamodb:GetItem"
        ]
        Resource = aws_dynamodb_table.orders.arn
      }
    ]
  })
}

Observe: enable logging and X-Ray tracing

A state machine with no visibility is a black box. When a workflow executes for two hours and fails near the end, you need logs and traces to diagnose what went wrong. AWS Step Functions integrates with CloudWatch and X-Ray for full observability.

Set up comprehensive logging in Terraform:

hcl
resource "aws_cloudwatch_log_group" "sfn_logs" {
  name              = "/aws/stepfunctions/order-processor-${var.environment}"
  retention_in_days = 30

  tags = {
    Environment = var.environment
  }
}

resource "aws_sfn_state_machine" "order_processor" {
  name       = "order-processor-${var.environment}"
  role_arn   = aws_iam_role.sfn_role.arn
  definition = templatefile("${path.module}/state_machines/order.json", { /* ... */ })

  logging_configuration {
    log_destination        = "${aws_cloudwatch_log_group.sfn_logs.arn}:*"
    include_execution_data = true
    level                  = "ALL"
  }

  tracing_configuration {
    enabled = true
  }

  tags = {
    Environment = var.environment
  }
}

With include_execution_data = true, CloudWatch logs include the full input and output of each step, making troubleshooting much faster. The level = "ALL" setting logs every transition, retry, and catch—invaluable when debugging complex workflows.

X-Ray tracing creates a service map showing how long each step took and where bottlenecks occur. Enable it alongside CloudWatch logging so your on-call team has both logs and traces to work with.

To query logs efficiently, consider creating a simple CloudWatch Insights query:

hcl
resource "aws_cloudwatch_log_group" "sfn_logs" {
  name              = "/aws/stepfunctions/order-processor-${var.environment}"
  retention_in_days = 30
}

# In your documentation or Terraform outputs:
output "cloudwatch_insights_query" {
  value = "fields @timestamp, type, id, status | filter type = 'ExecutionFailed' | stats count() by status"
}

This query helps your team quickly find failed executions and group them by error type.

Common mistakes

  • Hardcoding resource ARNs in state machine definitions: Always use templatefile() and inject ARNs from Terraform variables. Hardcoding makes it impossible to promote workflows across environments.
  • Overly permissive IAM policies: Never grant a state machine role lambda:* or dynamodb:*. List the exact resources and actions it needs.
  • Ignoring timeouts: Every task needs a reasonable timeout. Without one, a hanging Lambda can stall your entire workflow. Set conservative timeouts and test them in staging.
  • No error handling: Assuming tasks always succeed leads to silent failures. Define catch blocks for every task that can fail, and log errors clearly.
  • Disabling logging in production: The moment you disable logs to save costs, you'll have a production incident you can't debug. Keep logging enabled; the cost is worth the operational visibility.
  • Testing only happy paths: Use Step Functions' console to manually trigger executions with invalid input or simulate task failures. Test error handling as thoroughly as happy paths.

Key takeaways

  • Use templatefile() to keep state machine definitions readable and separate from Terraform code, while still injecting dynamic values safely.
  • Declare retry policies for transient failures and catch blocks for specific error classes; treat error handling as a first-class concern, not an afterthought.
  • Apply the principle of least privilege to state machine IAM roles: grant only the Lambda functions and AWS service actions the workflow actually needs.
  • Enable CloudWatch logging with include_execution_data = true and X-Ray tracing on every production state machine; the observability cost pays for itself in faster incident resolution.
  • Test error paths as rigorously as happy paths, parameterize retry and timeout values, and version your workflows in Git alongside infrastructure code.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles