If you run Temporal in production, you're probably running a fleet of long-lived Worker processes on Kubernetes or similar — well-proven, and still the right choice for steady, high-throughput workloads where always-on Workers make good use of the capacity you're paying for.
Temporal Cloud's Serverless Workers don't replace that model — they're an alternative for cases where it's not the best fit. Instead of a Worker that polls continuously, you deploy a Lambda function that Temporal invokes on demand, scaling the number of invocations up and down to match the work on your Task Queue. No cluster to provision, no autoscaler to tune — a lighter footprint for workloads that are bursty, unpredictable, or too infrequent to justify dedicated capacity.
This post covers what changes when you migrate a Worker from Kubernetes to Serverless on AWS Lambda, with a step-by-step path to get there.
Scope: This guide focuses on AWS Lambda with Python examples. Serverless Workers on AWS Lambda are supported across the Go, Python, Java, .NET, and TypeScript SDKs, but package names, configuration attributes, and APIs vary between them — consult your SDK's API documentation for the exact equivalents.
How Serverless Workers actually work#
A Serverless Worker uses the same Temporal SDK as a traditional long-lived Worker, and registers Workflows and Activities the same way. The difference is entirely in the lifecycle: instead of starting once and polling continuously, a Serverless Worker is invoked by Temporal on demand, processes whatever Tasks are available, and then shuts down.
A system Workflow called the Worker Controller Instance (WCI) drives invocation. One WCI runs per Worker Deployment Version with a compute provider configured, scaling Workers up and down based on two Task Queue signals:
- Sync match failure — When a Task arrives, Temporal's Matching Service tries to route it to an available Worker. If none is available, it signals the WCI, which invokes the compute provider (for example, Lambda's
InvokeFunctionAPI). This is push-based rather than timer-polled, so scaling stays responsive. - Task Queue backlog — the WCI also watches queue metadata and adds Workers when a backlog builds up faster than existing Workers can clear it.
Each Lambda invocation is independent and establishes a fresh client connection every time. Hence there is no shared state or connection reuse across invocations.
A Serverless Worker's lifecycle has three phases — init, work, and shutdown — all within a single invocation:
For long-running Activities, the shutdown and invocation timings need to be tuned together so the Worker isn't terminated mid-Activity. This is covered in detail in step 3 of the migration steps later in the document.
Failures are handled with Temporal's normal retry semantics, nothing Serverless-specific: if a Worker invocation crashes while an Activity is running, Temporal can retry the Activity according to its configured timeouts and Retry Policy.
That being said, individual Activity durations are bounded by compute provider's invocation limit minus the shutdown deadline buffer, and for AWS Lambda that ceiling is 15 minutes. Workflow duration, by contrast, has no limit. A Workflow runs across as many invocations as it needs, regardless of any single invocation's timeout.
What actually changes in your code#
Your Workflow and Activity code remains unchanged for the most part. The main change is to update the worker code to reflect how the Worker process is invoked and deployed:
- A Serverless Worker exposes a Lambda handler built with
run_worker()fromtemporalio.contrib.aws.lambda_worker. Temporal invokes it when there's work on the Task Queue; each invocation connects, polls, processes Tasks until work drains or the deadline nears, and shuts down cleanly. - Any plugins your Worker depends on (for example, a framework integration like
StrandsPluginfor agentic workflows) need to be attached to the Lambda handler the same way they were attached to the original Worker. - Anything outside the Worker itself — a web UI, an API layer, a starter script — is untouched. It keeps talking to Temporal exactly as it did before.
Prerequisites#
Before you start, make sure you have:
- A Temporal Cloud account with an AWS-hosted Namespace — the Namespace's cloud provider has to match the compute provider (AWS Lambda, in this post).
- An AWS account with permission to create/invoke Lambda functions and create IAM roles, with the
awsCLI installed and authenticated. - A Python (or your SDK language's) environment matching your target Lambda runtime version, plus access to any external services (e.g., AWS Bedrock) your Activities call, in your target AWS region.
Step-by-step migration#
1. Add the Lambda handler alongside your existing Worker#
Create a new lambda_function.py. We recommend leaving your existing worker.py in place — keeping it means you retain a path for local development and a fallback to your dedicated Worker if you need it. The handler configures the same Task Queue, Workflows, and activities your dedicated Worker already registers, and attaches any required client plugins:
from temporalio.contrib.aws.lambda_worker import LambdaWorkerConfig, run_worker
from temporalio.worker import WorkerDeploymentVersion
from workflow import YourWorkflow
from activities import your_activity
def configure(config: LambdaWorkerConfig) -> None:
config.worker_config["task_queue"] = "your-task-queue"
config.worker_config["workflows"] = [YourWorkflow]
config.worker_config["activities"] = [your_activity]
# Attach any framework plugins your workflow depends on here,
# via client_connect_config
# config.client_connect_config["plugins"] = [YourPlugin()]
lambda_handler = run_worker(
WorkerDeploymentVersion(
deployment_name="your-app",
build_id="build-1",
),
configure,
)
If you want OpenTelemetry tracing and metrics, the lambda_worker package has a helper (apply_defaults) preconfigured for the AWS Distro for OpenTelemetry Lambda layer — follow the Add observability with OpenTelemetry section of the Python SDK docs.
2. Set a versioning behavior#
Serverless Workers require Worker Versioning, which means every Workflow needs a versioning behavior. You have two ways to set it, and you can mix them.
Set it per-Workflow when a specific Workflow needs a specific behavior:
from temporalio.common import VersioningBehavior
@workflow.defn(versioning_behavior=VersioningBehavior.PINNED)
class YourWorkflow:
...
Or set a Worker-level default that applies to every Workflow the Worker registers, so you don't have to annotate each one. A per-Workflow behavior still takes precedence where you set it:
config.worker_config["default_versioning_behavior"] = VersioningBehavior.PINNED
Which behaviour you choose depends on the Workflow. PINNED keeps a running Workflow on the Worker Deployment Version and the corresponding Lambda function version it started on until it completes. AUTO_UPGRADE moves a Workflow to the new Current Version at its next Workflow Task, once you roll one out. Which you want depends on how disruptive a mid-flight version change would be for that Workflow.
To understand the two options in depth, including how they interact with deployment rollouts, replay safety, and patching — see Temporal's Worker Versioning documentation.
3. (If you have long-running Activities) Tune the shutdown timings#
Skip this step if your Activities are short. If any Activity runs for more than a few seconds, three timings have to be set together so the Worker isn't terminated mid-Activity. They nest, and two of the three live on the Worker config while the third is the Lambda --timeout you'll set at deploy time step 8:
- Worker stop timeout (Worker config) — longer than your longest Activity, so in-flight Activities can finish after polling stops.
- Shutdown deadline buffer (Worker config) — longer than the stop timeout plus shutdown-hook time, so draining and hooks complete before termination.
- Invocation deadline (Lambda
--timeout) — longer than the two above combined.
Work backwards from your longest Activity. Say it runs up to 5 minutes, and your shutdown hooks take about 3 seconds:
from datetime import timedelta
def configure(config: LambdaWorkerConfig) -> None:
config.worker_config["task_queue"] = "your-task-queue"
config.worker_config["workflows"] = [YourWorkflow]
config.worker_config["activities"] = [your_activity]
# 1. Worker stop timeout > longest Activity runtime.
# Give in-flight Activities time to finish after polling stops.
config.worker_config["graceful_shutdown_timeout"] = timedelta(minutes=5, seconds=30)
# 2. Shutdown deadline buffer > stop timeout + shutdown-hook time.
# Worker starts draining this far ahead of the invocation deadline.
config.shutdown_deadline_buffer = timedelta(minutes=5, seconds=45)
Then set the third value — the invocation deadline — on the Lambda function itself, longer than the Activity runtime plus the shutdown buffer. With the numbers above that's at least 5 min + 5 min 45 s ≈ 11 minutes, so in step 8 you'd deploy with --timeout 660.
If an Activity runs longer than about half the 15-minute Lambda ceiling, consider using regular dedicated Workers. If you choose to run these Activity workloads using Lambda workers, use Activity Heartbeats and checkpoint progress so a retry resumes from the last recorded Heartbeat instead of starting over.
4. Package your dependencies#
Install your dependencies into a local package/ directory from a minimal requirements file.
mkdir package
pip install --target ./package -r requirements-lambda.txt
If your build environment already matches Lambda's runtime — Linux on x86-64, which is what most CI runners give you — that's all you need. You'll zip this directory together with your source files in step 6.
Building on a Mac? The Temporal SDK ships a compiled core, so dependencies installed on macOS or Apple Silicon won't load on Lambda's x86-64 Linux runtime. Either build in a Linux container / CI runner, or cross-install by targeting the Lambda platform explicitly:
pip install --target ./package \
--platform manylinux2014_x86_64 \
--implementation cp --python-version 3.13 \
--only-binary=:all: \
-r requirements-lambda.txt
Then confirm the binary is Linux ELF before continuing: file package/temporalio/bridge/temporal_sdk_bridge.abi3.so should report ELF 64-bit LSB shared object, x86-64.
5. Trim the package to fit Lambda's size limit#
Lambda caps unzipped package size at 250 MB. Check what's taking up space, and remove anything that leaked in as an unused transitive dependency:
du -sh package/* | sort -rh | head -20
# Example removals — adjust based on what's actually unused in your package
find package/ -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
find package/ -type d -name "tests" -exec rm -rf {} + 2>/dev/null
find package/ -name "*.pyc" -delete
If you're still over budget, optionally strip debug symbols from the compiled binary:
strip package/temporalio/bridge/temporal_sdk_bridge.abi3.so
Building on a Mac? Run strip inside a Linux container so it matches the target architecture — stripping a Linux ELF with the macOS strip can corrupt it:
docker run --rm --entrypoint bash \
-v "$(pwd)":/app -w /app \
public.ecr.aws/lambda/python:3.13 \
-c "strip package/temporalio/bridge/temporal_sdk_bridge.abi3.so"
6. Zip the package and your source files#
Dependencies need to sit at the root of the zip, not nested in a subfolder, or Lambda won't find your handler:
cd package && zip -r ../function.zip . && cd ..
zip function.zip lambda_function.py workflow.py activities.py
Double-check the layout before deploying:
unzip -l function.zip | grep -E "lambda_function.py|temporalio/client.py"
7. Create the Lambda execution role#
This role is what Lambda assumes to run your code — distinct from the role Temporal uses to invoke it (step 9). It needs basic execution permissions plus access to whatever external services your Activities call:
aws iam create-role \
--role-name temporal-serverless-worker-exec \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy \
--role-name temporal-serverless-worker-exec \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
If your activities call AWS services directly (Bedrock, S3, DynamoDB, etc.), attach the relevant policy here too. With the right role permissions, your Lambda accesses those services through the standard credential chain — no separate API keys needed inside the function.
8. Deploy the function#
Packages over 70 MB can't be uploaded directly — stage them through S3 instead (S3 supports up to the full 250 MB unzipped limit):
aws s3 mb s3://your-lambda-deploy-bucket --region <REGION>
aws s3 cp function.zip s3://your-lambda-deploy-bucket/function.zip --region <REGION>
aws lambda create-function \
--function-name your-worker \
--runtime python3.13 \
--handler lambda_function.lambda_handler \
--role arn:aws:iam::<ACCOUNT_ID>:role/temporal-serverless-worker-exec \
--code S3Bucket=your-lambda-deploy-bucket,S3Key=function.zip \
--timeout 600 \
--memory-size 512 \
--region <REGION> \
--environment '{"Variables":{
"TEMPORAL_ADDRESS":"<namespace>.<account>.tmprl.cloud:7233",
"TEMPORAL_NAMESPACE":"<namespace>",
"TEMPORAL_API_KEY":"<api-key>",
"AWS_REGION":"<REGION>"
}}'
The --timeout here is the Lambda invocation deadline — the third of the three timings from step 3. If you tuned for long-running Activities there, use the value you computed (in that example, --timeout 660); otherwise a comfortable default like 600 is fine to start. It can't exceed Lambda's 900-second ceiling.
For future code changes, re-zip and run update-function-code rather than recreating the function from scratch.
9. Give Temporal Cloud permission to invoke your Lambda#
This is a second, separate IAM role — the one Temporal Cloud assumes to call lambda:InvokeFunction on your behalf. Its trust policy includes an External ID to guard against confused-deputy attacks. Use the published CloudFormation template for this step:
curl -o temporal-cloud-serverless-worker-role.yaml \
https://docs.temporal.io/assets/files/temporal-cloud-serverless-worker-role-fac5401b050a296d845a9aee4cd1aa5f.yaml
aws cloudformation create-stack \
--stack-name your-app-temporal-role \
--template-body file://temporal-cloud-serverless-worker-role.yaml \
--parameters \
ParameterKey=AssumeRoleExternalId,ParameterValue=<EXTERNAL_ID> \
ParameterKey=LambdaFunctionARNs,ParameterValue='"arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function:your-worker"' \
--capabilities CAPABILITY_NAMED_IAM \
--region <REGION>
Pick any string for the External ID and hold onto it — you'll need it again in the next step. Pull the resulting role ARN from the stack outputs.
10. Register the Worker Deployment Version#
The deployment name and build ID here must match what you set in lambda_function.py:
temporal worker deployment create-version \
--namespace <namespace> \
--deployment-name your-app \
--build-id build-1 \
--aws-lambda-function-arn arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function:your-worker \
--aws-lambda-assume-role-arn <INVOCATION_ROLE_ARN_FROM_STEP_9> \
--aws-lambda-assume-role-external-id <EXTERNAL_ID>
You can do this from the Temporal UI instead, under Workers → Create Worker Deployment — it sets the version as current automatically, which saves you the next step, and gives you a Validate Connection action to confirm the role and invocation actually work before you rely on it.
Use a versioned ARN in production. The ARN above is unqualified, which points at $LATEST — it changes on every redeploy. A Worker Deployment Version is meant to be an immutable build, so for production workloads, publish a numbered Lambda function version (arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function:your-worker:5) and point the compute provider at that qualified ARN instead. Without it, deploying replay-unsafe code can cause non-determinism errors for in-flight Workflows — even ones set to PINNED.
11. Set the version as current#
If you registered via CLI, this step is required — without it, Tasks won't route to your new deployment:
temporal worker deployment set-current-version \
--namespace <namespace> \
--deployment-name your-app \
--build-id build-1
12. Test before you cut over#
Start with a direct invoke to confirm the function boots cleanly:
aws lambda invoke \
--function-name your-worker \
--region <REGION> \
--log-type Tail --query 'LogResult' --output text \
response.json | base64 --decode
Then run an actual Workflow against the Task Queue and watch it progress in the Temporal UI and in CloudWatch logs:
temporal workflow start \
--task-queue your-task-queue \
--type YourWorkflow \
--namespace <namespace> \
--input '{"...": "..."}'
aws logs tail /aws/lambda/your-worker --follow --region <REGION>
Once you're confident the Serverless Worker is handling traffic correctly, you can scale down or retire the dedicated Worker deployment on your original infrastructure.
Choosing the right model for a given Worker#
Dedicated Workers and Serverless Workers are two tools for the same job, not a before-and-after. Dedicated Workers remain the better fit for steady, high-throughput workloads with predictable traffic, where always-on capacity is well-utilized and the per-invocation overhead of Lambda cold starts and packaging constraints would work against you. Serverless Workers shine for workloads that are bursty, unpredictable, or low-volume — where dedicated infrastructure would mean paying to keep capacity running that isn't doing much most of the time.
The good news is that the decision doesn't have to be all-or-nothing, or permanent. Because the Workflow and Activity code don't change — only how the Worker process is invoked and deployed — you can run both models side by side, migrate one Task Queue at a time, and switch back if a given workload doesn't turn out to be a good match. You can even point both at the same Task Queue and let Serverless Workers act as spillover above your dedicated capacity, so there's no need to commit to a full migration up front.
If you're evaluating this for your own environment, your Temporal account team can help you weigh which model fits a given workload and walk through the packaging details specific to your SDK and dependencies.