This article is sponsored by Kestra.
Say you have a service that runs in three production environments, each in a different AWS account and Region. Before a release, you need to know whether every environment is healthy. For each account you can call a small Lambda function that reads the value of a release-readiness CloudWatch metric, and you'll know whether the environment in that account is ready. The problem we're going to discuss is how to call all three functions across different accounts, and aggregate the responses into a single result that answers whether we can release or not.
Introduction to Kestra
We’re going to use Kestra, a declarative orchestration platform with an Apache 2.0-licensed Open Source Edition that can be self-hosted. For our solution, Kestra is going to act as the orchestration control plane. We're going to assume there are Lambda functions in different AWS accounts, which report whether the environment in that account is ready for release. Kestra is going to invoke them, verify that every required environment reports ready, and pause for a reviewer to approve the handoff. To build this we’ll adapt Kestra’s public AWS Lambda Blueprint, replacing its fixed set of invocations with a multi-account release runbook.
Kestra workflows are defined in YAML. This is what a basic runbook looks like, to invoke an AWS Lambda function in different accounts:
tasks:
- id: run_checks
type: io.kestra.plugin.core.flow.ForEach
values: "{{ vars.targets | keys }}"
tasks:
- id: invoke
type: io.kestra.plugin.aws.lambda.Invoke
region: "{{ vars.targets[taskrun.value].region }}"
stsRoleArn: "{{ vars.targets[taskrun.value].role_arn }}"
functionArn: "{{ vars.targets[taskrun.value].function_arn }}"vars.targets will be a map containing the Region, role ARN, and function ARN for each production environment. ForEach runs the nested Invoke task once for every target, and taskrun.value identifies the target being processed in the current iteration.
That loop is the center of the Kestra runbook we're building. Before we expand it into its complete form, let's define the AWS Lambda function that determines whether an environment is ready for release or not.
Defining the Readiness function
Our Readiness function is a regular Lambda function, which reads a value from the release-readiness CloudWatch metric and returns passed: true or passed: false depending on that metric's value. It's deployed in three different AWS accounts:
Account 111122223333, in region us-east-1
Account 444455556666, in region eu-west-1
Account 777788889999, in region ap-southeast-2
Here's what the code looks like, more or less. I'm not going to show you how to deploy it, let's just assume it's deployed.
from datetime import datetime, timedelta, timezone
import boto3
cloudwatch = boto3.client("cloudwatch")
NAMESPACE = "Company/Release"
METRIC_NAME = "release-readiness"
LOOKBACK_MINUTES = 5
def lambda_handler(event, context):
required = ("changeId", "releaseId", "executionId", "target")
if not isinstance(event, dict):
raise ValueError("The invocation payload must be an object")
missing = [
field
for field in required
if not isinstance(event.get(field), str) or not event[field]
]
if missing:
raise ValueError(f"Missing or invalid fields: {', '.join(missing)}")
# Exclude the current, potentially incomplete one-minute period.
end = datetime.now(timezone.utc).replace(second=0, microsecond=0)
response = cloudwatch.get_metric_data(
MetricDataQueries=[
{
"Id": "readiness",
"MetricStat": {
"Metric": {
"Namespace": NAMESPACE,
"MetricName": METRIC_NAME,
},
"Period": 60,
"Stat": "Minimum",
"Unit": "Count",
},
"ReturnData": True,
}
],
StartTime=end - timedelta(minutes=LOOKBACK_MINUTES),
EndTime=end,
ScanBy="TimestampDescending",
)
series = response["MetricDataResults"][0]
if series.get("StatusCode") != "Complete":
raise RuntimeError(
f"CloudWatch returned {series.get('StatusCode', 'no status')}"
)
if not series.get("Values"):
raise RuntimeError(
"No release-readiness datapoint was found "
"in the last five completed minutes"
)
value = series["Values"][0]
observed_at = series["Timestamps"][0].astimezone(timezone.utc)
return {
"schemaVersion": "1",
"changeId": event["changeId"],
"releaseId": event["releaseId"],
"executionId": event["executionId"],
"target": event["target"],
"functionVersion": context.function_version,
"requestId": context.aws_request_id,
"passed": value == 1,
"message": f"{METRIC_NAME} returned {value:g}",
"observedAt": observed_at.isoformat(),
}Our Readiness function returns an object like this:
{
"schemaVersion": "1",
"changeId": "CHG-2037",
"releaseId": "2026.08.20",
"executionId": "2nP8wQ5kF4rM7cV1",
"target": "prod_us_east_1",
"functionVersion": "42",
"requestId": "328145a1-7b4c-4d72-9010-43d47b9d05a4",
"passed": true,
"message": "release-readiness returned 1",
"observedAt": "2026-08-20T16:42:08+00:00"
}The changeId, releaseId and executionId fields let the flow verify that the response belongs to the current request. functionVersion is the Lambda function version that was executed, requestId can be matched to the Lambda invocation, and observedAt records when the environment was checked. passed is the value the workflow will eventually combine across all three targets.
In this case we're deploying the same code to all three environments, to keep things simple. A more interesting case would be if each environment required checking different information to determine whether it's ready or not for a release. For example, each account could belong to a dedicated deployment for a tenant, where each may have different configurations, or be running different versions of our infrastructure, or custom variations. In that case, we'd need a customized Lambda function for each environment. So long as they're Lambda functions and they return the same response format, we can call them and process them without changing the Kestra flow, it doesn't matter what they do to calculate the value of passed.
Calling the Readiness function in every AWS account
Here's where Kestra comes into play. This is based on Kestra's public AWS Lambda Blueprint, which already gives us the basic Invoke task to call an AWS Lambda function. For the runbook that we need to solve this particular problem, the Region, IAM role, and function ARN are going to take different values per environment. What we're going to do is keep those values together in a target map and let Kestra loop over it. This is what our Kestra runbook looks like:
id: multi_account_lambda_preflight
namespace: company.platform
inputs:
- id: change_id
type: STRING
required: true
validator: '^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$'
- id: release_id
type: STRING
required: true
variables:
targets:
prod_us_east_1:
region: us-east-1
role_arn: arn:aws:iam::111122223333:role/KestraPreflightInvoker
function_arn: arn:aws:lambda:us-east-1:111122223333:function:release-readiness:42
prod_eu_west_1:
region: eu-west-1
role_arn: arn:aws:iam::444455556666:role/KestraPreflightInvoker
function_arn: arn:aws:lambda:eu-west-1:444455556666:function:release-readiness:17
prod_ap_southeast_2:
region: ap-southeast-2
role_arn: arn:aws:iam::777788889999:role/KestraPreflightInvoker
function_arn: arn:aws:lambda:ap-southeast-2:777788889999:function:release-readiness:9
tasks:
- id: run_checks
type: io.kestra.plugin.core.flow.ForEach
values: "{{ vars.targets | keys }}"
tasks:
- id: invoke
type: io.kestra.plugin.aws.lambda.Invoke
region: "{{ vars.targets[taskrun.value].region }}"
stsRoleArn: "{{ vars.targets[taskrun.value].role_arn }}"
stsRoleSessionName: "kestra-{{ execution.id }}"
functionArn: "{{ vars.targets[taskrun.value].function_arn }}"
functionPayload:
changeId: "{{ inputs.change_id }}"
releaseId: "{{ inputs.release_id }}"
executionId: "{{ execution.id }}"
target: "{{ taskrun.value }}"
timeout: PT2MThe value for the key values inside tasks gets expanded into three keys from targets, which is where we defined our invocation targets (the Lambda functions, each in a separate AWS account). Since our task is of type ForEach, Kestra creates one iteration for each key, and taskrun.value inside the payload identifies the environment in the current iteration. The Invoke task uses that key to select the corresponding Region, role, and function ARN. This way, we define the invocation once, define each of our targets, and have the invocation be executed for all targets.
The payload also includes the change id and release id (parameters we would want to set to keep track of what we're trying to release), the Kestra execution id, and the aforementioned target. The Lambda function will return those same values with its result, so the Kestra flow can reject a response that belongs to another target or execution.
Giving Kestra access to each account
Notice that we're not adding AWS credentials anywhere here. We give an identity to the Kestra worker, which needs sts:AssumeRole permission, and then Kestra's Invoke task uses the AWS SDK default credential chain to assume the role that we defined for it in stsRoleArn.
Each target role we define for tasks must have a trust relationship with the identity we give the Kestra worker, and in this case the target roles need to allow lambda:InvokeFunction on the readiness function in their respective accounts. Separate from this we have the Lambda execution role, which gives the function permission to read the local CloudWatch metric without giving Kestra direct access to that data.

Each function ARN above ends in a numbered version. A published Lambda version is an immutable snapshot of the function’s code and most of its configuration. This flow deliberately uses qualified ARNs so the function can return context.function_version and Kestra can compare it with the configured version suffix. An unqualified ARN would invoke $LATEST, and it would work if we change the validation a bit, but it's always a good idea to pin versions.
With this, Kestra can now invoke one readiness function per environment and keep all three responses in the same execution. However, those responses still need a common format before the workflow can combine them into a single result.
Validating each Lambda response
There are many things that can go wrong in the invocation of our Readiness Lambda functions. Here's how we want to treat them.
Outcome | What the runbook should do |
Role assumption or Lambda API request fails | Stops because the environment was not checked |
Lambda returns FunctionError | Stops because the function produced no usable result |
Lambda returns malformed or mismatched JSON | Stops because the response cannot be trusted |
Lambda returns valid JSON with passed: false | Records the response, then rejects the combined preflight |
Lambda returns valid JSON with passed: true | Records the response and waits for the other environments |
A failed STS or Lambda API request causes the Invoke task to fail. Handler and runtime failures are different, a synchronous Lambda invocation can still return HTTP 200, with FunctionError indicating that the function failed. Kestra’s Invoke task checks that field and fails the task. A normal handler return, whether passed is true or false, produces a successful invocation task, and it's up to the flow to inspect the payload.
To do that, we need to add the following tasks after invoke, inside run_checks.tasks:
- id: validate_response
type: io.kestra.plugin.core.execution.Assert
conditions:
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).schemaVersion == '1' }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).changeId == inputs.change_id }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).releaseId == inputs.release_id }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).executionId == execution.id }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).target == taskrun.value }}"
- >-
{{
fromJson(read(outputs.invoke[taskrun.value].uri)).functionVersion ==
(vars.targets[taskrun.value].function_arn | split(':') | last)
}}
- >-
{{
(
fromJson(read(outputs.invoke[taskrun.value].uri))
| jq('((.requestId | type) == "string") and
(.requestId | length > 0) and
((.passed | type) == "boolean") and
((.message | type) == "string") and
(.message | length > 0) and
((.observedAt | type) == "string") and
(.observedAt | length > 0)')
| first
) == true
}}
errorMessage: "Invalid preflight response for {{ taskrun.value }}"
- id: record_result
type: io.kestra.plugin.core.output.OutputValues
values:
target: "{{ taskrun.value }}"
region: "{{ vars.targets[taskrun.value].region }}"
functionArn: "{{ vars.targets[taskrun.value].function_arn }}"
functionVersion: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).functionVersion }}"
requestId: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).requestId }}"
passed: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).passed }}"
message: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).message }}"
observedAt: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).observedAt }}"This new validate_response task that we've just added compares the values returned by each Invoke task with the current Kestra flow inputs and execution. It also checks the function version and the types of the fields that the rest of the workflow will use.
The assertion deliberately accepts passed: false as a valid value. That response means the function ran correctly and found that its environment was not ready. We want Kestra to record the result, then let the aggregator decide whether the release can continue (spoiler: it can't, but we're separating concerns).
After these validations, we have one response from each environment. Kestra can now compare those responses with the target inventory and determine whether we can proceed with the release or not.
Combining the responses into one release decision
We have the individual responses, now we need to aggregate them into a single answer and determine if we can release or not. To do that, we're going to add these tasks to our Kestra flow after run_checks:
- id: collect_results
type: io.kestra.plugin.core.output.OutputValues
values:
results: >-
{{ outputs.record_result | values | jq('map(.values) | sort_by(.target)') | first }}
- id: require_all_to_pass
type: io.kestra.plugin.core.execution.Assert
conditions:
- "{{ (vars.targets | keys | length) > 0 }}"
- >-
{{
(outputs.collect_results.values.results | length) ==
(vars.targets | keys | length)
}}
- >-
{{
(
outputs.collect_results.values.results
| jq('all(.[]; .passed == true)')
| first
) == true
}}
errorMessage: Every expected environment must return passed=true.Because record_result runs inside ForEach, Kestra stores one output for each iteration, keyed by the current taskrun.value, such as prod_us_east_1. Each output contains the values emitted by record_result under .values. The new collect_results task extracts those values into a list and sorts it by target. The assertion then checks that the target inventory isn’t empty, that the number of results matches the number of configured environments, and that every result contains the Boolean value passed: true.
If a role assumption or invocation fails, run_checks fails before we call the collect_results task. If a response doesn't match the expected format, validate_response fails and stops that iteration. If a response contains passed: false, the Kestra flow execution reaches the require_all_to_pass task, which in turn fails.
When require_all_to_pass succeeds, it means every target environment's Readiness Lambda function returned passed: true, meaning all our automated checks pass. Now let's get a human reviewer in here for the final approval.
Asking a reviewer to approve the release
We have confirmation that the environments met the automated readiness rule. A human reviewer still needs to authorize the release process, since we might be using human-controlled release windows, and someone needs to check that an incident isn't happening at the same time. Or we could always use the corporate reason: If something goes wrong, we need someone to blame. It's a joke, but if you work at a place where that's not a joke, I'm sorry.
We're going to introduce a new task called approve_change, right after require_all_to_pass:
- id: approve_change
type: io.kestra.plugin.ee.flow.HumanTask
description: >-
Review the preflight results for release {{ inputs.release_id }}:
{{ outputs.collect_results.values.results | toJson }}
assignment:
groups:
- Production Change Approvers
onResume:
- id: approved
type: BOOL
displayName: Approve this release?
required: true
- id: reason
type: STRING
displayName: Decision reason
required: true
validator: '(?s).*\S.*'
pauseDuration: PT15M
behavior: FAILThis is a HumanTask, assigned to the group Production Change Approvers. It requires both an answer of type BOOL and a reason. This pauses the Kestra flow for up to 15 minutes (defined in pauseDuration) and waits for a human input. If nobody in that group responds within 15 minutes, the task fails with behavior: FAIL.
Note that HumanTask and group assignment are Kestra Enterprise features, meaning you need to pay for them. Other interesting enterprise features are RBAC and Audit Logs, but I won't turn this into a list of Kestra features, you can check the whole list of enterprise features at the Kestra Enterprise docs page.
Sending the approved result to the change system
Once we have human approval, we want to hand off the process to a ticket system (something where you're presumably keeping track of releases). We're going to have Kestra send the responses of all environments, the reviewer's identity (so we can blame them if something goes wrong), approval reason, and the execution ID used by the Kestra flow.
- id: handoff
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.approve_change.onResume.approved == true }}"
then:
- id: update_change_ticket
type: io.kestra.plugin.core.http.Request
uri: "{{ secret('CHANGE_API_URL') }}/changes/{{ inputs.change_id }}"
method: PATCH
contentType: application/json
headers:
Authorization: "Bearer {{ secret('CHANGE_API_TOKEN') }}"
body: >-
{{
{
"status": "preflight-approved",
"changeId": inputs.change_id,
"releaseId": inputs.release_id,
"approvedBy": outputs.approve_change.resumed.by,
"approvedAt": outputs.approve_change.resumed.on,
"reason": outputs.approve_change.onResume.reason,
"executionId": execution.id,
"results": outputs.collect_results.values.results
} | toJson
}}
else:
- id: reject_change
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
Release {{ inputs.release_id }} was rejected by
{{ outputs.approve_change.resumed.by }}.All the Request task does is send an HTTP request to an endpoint. In this case we're sending the complete result and approval metadata to our ticket system at CHANGE_API_URL. Note that URL and Bearer token used for auth come from Kestra secrets, they're not hardcoded into the Kestra flow or inputs. By default, the task fails on HTTP status codes of 400 or higher, so an unsuccessful ticket update also fails the Kestra execution.
Where Kestra fits alongside AWS-native orchestration
This was all super fun, but you might be wondering, is there a reason we're using Kestra here instead of an AWS native option? Let's look at the alternatives AWS gives us.
If you look at that whole YAML, the first solution that comes to mind is probably AWS Step Functions. It's a state machine where you can define workflows, it can invoke Lambda and assume a task role in another account, and can split executions and aggregate results. One limitation is that AWS service integrations do not provide cross-Region resource access. I still love Step Functions though.
Another option is Systems Manager Automation, which can run operational workflows across accounts and Regions with centralized targeting and rate controls. On paper it works well, but you'll find that its aws:approve action is not supported in multi-account and multi-Region automations.
Conclusion
We defined the list of target environments (which all have previously deployed Readiness Lambda functions), Kestra handles invoking those functions, rejecting missing or malformed responses, and aggregating responses into a release decision. Then it escalates to a human approver, who authorizes the release. Kestra then hands off the process to a ticket system that keeps track of everything. Kestra keeps the cross-Region invocations, assigned approval, and external ticket handoff in a single YAML workflow. It's useful to us because our process crosses AWS accounts and Regions, needs a human decision, and calls a system outside AWS.
This article is sponsored by Kestra. I said it upfront, and I'm saying it again. However, I only agreed to write and publish it because I genuinely think it's a good and useful solution. Simple problems should use simple solutions, so if you're dealing with a single environment in a single account and region, probably use Step Functions or just a single Lambda. Complex problems still deserve simple solutions whenever possible, and that's where I think Kestra is genuinely valuable.
Remember that Kestra’s Open Source Edition is available under the Apache License 2.0, with its source code in the Kestra GitHub repository. Kestra also has two paid offerings: the self-hosted Enterprise Edition and the fully managed service Kestra Cloud. The assigned HumanTask approval used in this article requires Enterprise Edition or Cloud. With the Open Source Edition, you can run the automated portion and use the standard Pause task instead, but Kestra will not restrict approval to a specified user or RBAC group. You can find their documentation and Quickstart guide on their website or their GitHub repository.
Here's the complete Kestra runbook for you to try:
id: multi_account_lambda_preflight
namespace: company.platform
inputs:
- id: change_id
type: STRING
required: true
validator: '^[A-Za-z0-9][A-Za-z0-9._-]{0,126}$'
- id: release_id
type: STRING
required: true
variables:
targets:
prod_us_east_1:
region: us-east-1
role_arn: arn:aws:iam::111122223333:role/KestraPreflightInvoker
function_arn: arn:aws:lambda:us-east-1:111122223333:function:release-readiness:42
prod_eu_west_1:
region: eu-west-1
role_arn: arn:aws:iam::444455556666:role/KestraPreflightInvoker
function_arn: arn:aws:lambda:eu-west-1:444455556666:function:release-readiness:17
prod_ap_southeast_2:
region: ap-southeast-2
role_arn: arn:aws:iam::777788889999:role/KestraPreflightInvoker
function_arn: arn:aws:lambda:ap-southeast-2:777788889999:function:release-readiness:9
tasks:
- id: run_checks
type: io.kestra.plugin.core.flow.ForEach
values: "{{ vars.targets | keys }}"
concurrencyLimit: 3
tasks:
- id: invoke
type: io.kestra.plugin.aws.lambda.Invoke
region: "{{ vars.targets[taskrun.value].region }}"
stsRoleArn: "{{ vars.targets[taskrun.value].role_arn }}"
stsRoleSessionName: "kestra-{{ execution.id }}"
functionArn: "{{ vars.targets[taskrun.value].function_arn }}"
functionPayload:
changeId: "{{ inputs.change_id }}"
releaseId: "{{ inputs.release_id }}"
executionId: "{{ execution.id }}"
target: "{{ taskrun.value }}"
timeout: PT2M
- id: validate_response
type: io.kestra.plugin.core.execution.Assert
conditions:
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).schemaVersion == '1' }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).changeId == inputs.change_id }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).releaseId == inputs.release_id }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).executionId == execution.id }}"
- "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).target == taskrun.value }}"
- >-
{{
fromJson(read(outputs.invoke[taskrun.value].uri)).functionVersion ==
(vars.targets[taskrun.value].function_arn | split(':') | last)
}}
- >-
{{
(
fromJson(read(outputs.invoke[taskrun.value].uri))
| jq('((.requestId | type) == "string") and
(.requestId | length > 0) and
((.passed | type) == "boolean") and
((.message | type) == "string") and
(.message | length > 0) and
((.observedAt | type) == "string") and
(.observedAt | length > 0)')
| first
) == true
}}
errorMessage: "Invalid preflight response for {{ taskrun.value }}"
- id: record_result
type: io.kestra.plugin.core.output.OutputValues
values:
target: "{{ taskrun.value }}"
region: "{{ vars.targets[taskrun.value].region }}"
functionArn: "{{ vars.targets[taskrun.value].function_arn }}"
functionVersion: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).functionVersion }}"
requestId: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).requestId }}"
passed: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).passed }}"
message: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).message }}"
observedAt: "{{ fromJson(read(outputs.invoke[taskrun.value].uri)).observedAt }}"
- id: collect_results
type: io.kestra.plugin.core.output.OutputValues
values:
results: >-
{{ outputs.record_result | values | jq('map(.values) | sort_by(.target)') | first }}
- id: require_all_to_pass
type: io.kestra.plugin.core.execution.Assert
conditions:
- "{{ (vars.targets | keys | length) > 0 }}"
- >-
{{
(outputs.collect_results.values.results | length) ==
(vars.targets | keys | length)
}}
- >-
{{
(
outputs.collect_results.values.results
| jq('all(.[]; .passed == true)')
| first
) == true
}}
errorMessage: Every expected environment must return passed=true.
- id: approve_change
type: io.kestra.plugin.ee.flow.HumanTask
description: >-
Review the preflight results for release {{ inputs.release_id }}:
{{ outputs.collect_results.values.results | toJson }}
assignment:
groups:
- Production Change Approvers
onResume:
- id: approved
type: BOOL
displayName: Approve this release?
required: true
- id: reason
type: STRING
displayName: Decision reason
required: true
validator: '(?s).*\S.*'
pauseDuration: PT15M
behavior: FAIL
- id: handoff
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.approve_change.onResume.approved == true }}"
then:
- id: update_change_ticket
type: io.kestra.plugin.core.http.Request
uri: "{{ secret('CHANGE_API_URL') }}/changes/{{ inputs.change_id }}"
method: PATCH
contentType: application/json
headers:
Authorization: "Bearer {{ secret('CHANGE_API_TOKEN') }}"
body: >-
{{
{
"status": "preflight-approved",
"changeId": inputs.change_id,
"releaseId": inputs.release_id,
"approvedBy": outputs.approve_change.resumed.by,
"approvedAt": outputs.approve_change.resumed.on,
"reason": outputs.approve_change.onResume.reason,
"executionId": execution.id,
"results": outputs.collect_results.values.results
} | toJson
}}
else:
- id: reject_change
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
Release {{ inputs.release_id }} was rejected by
{{ outputs.approve_change.resumed.by }}.