Schedule Serverless Cron Jobs with AWS EventBridge and Lambda
Trigger a Lambda function on a recurring schedule, then make it production-grade with EventBridge retries, an SQS dead-letter queue, and CloudWatch alarms that actually tell you when something broke.
What you'll build
A Lambda function that runs on a fixed schedule via an EventBridge rule, with retries on delivery failures, an SQS dead-letter queue for failed invocations, and a CloudWatch alarm that emails you when something goes wrong.
Prerequisites
- An AWS account and IAM permissions to create Lambda functions, IAM roles, EventBridge rules, SQS queues, SNS topics, and CloudWatch alarms.
- AWS CLI v2 installed and configured (
aws configure). Examples below useus-east-1. jqinstalled (brew install jqorapt install jq), used once to build a JSON payload.zipon your PATH (default on macOS/Linux; use WSL or Git Bash on Windows).
Set two variables you'll reuse throughout:
export AWS_REGION=us-east-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
1. Package and deploy the Lambda function
mkdir cron-lambda && cd cron-lambda
cat > lambda_function.py <<'EOF'
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
logger.info("Scheduled run: %s", json.dumps(event))
return {"status": "ok"}
EOF
zip function.zip lambda_function.py
Create an execution role. Lambda needs lambda.amazonaws.com in its trust policy plus permission to write logs:
cat > trust-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name cron-lambda-role \
--assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy \
--role-name cron-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Roles take a few seconds to propagate. Wait ~15 seconds, then create the function:
aws lambda create-function \
--function-name scheduled-cleanup \
--runtime python3.12 \
--role arn:aws:iam::$ACCOUNT_ID:role/cron-lambda-role \
--handler lambda_function.handler \
--zip-file fileb://function.zip \
--timeout 30
2. Add a dead-letter queue and configure retries
Two separate failure layers exist here, and mixing them up is the most common mistake:
- EventBridge delivery failures (throttling, permission errors): handled by the
RetryPolicyandDeadLetterConfigon the rule's target. - Function execution failures (your code throws): Lambda retries asynchronously on its own, then can route to an on-failure destination configured separately on the function.
You'll wire both to the same queue.
aws sqs create-queue --queue-name scheduled-cleanup-dlq
QUEUE_URL=$(aws sqs get-queue-url --queue-name scheduled-cleanup-dlq --query QueueUrl --output text)
QUEUE_ARN=$(aws sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names QueueArn --query Attributes.QueueArn --output text)
Let the function's execution role publish to it (required for the on-failure destination):
cat > dlq-send-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sqs:SendMessage",
"Resource": "$QUEUE_ARN"
}]
}
EOF
aws iam put-role-policy \
--role-name cron-lambda-role \
--policy-name allow-dlq-send \
--policy-document file://dlq-send-policy.json
Configure Lambda's own async retry and failure destination:
aws lambda put-function-event-invoke-config \
--function-name scheduled-cleanup \
--maximum-retry-attempts 2 \
--maximum-event-age-in-seconds 3600 \
--destination-config '{"OnFailure":{"Destination":"'"$QUEUE_ARN"'"}}'
Allow EventBridge to send to the queue too, scoped to this rule only:
export RULE_ARN=arn:aws:events:$AWS_REGION:$ACCOUNT_ID:rule/scheduled-cleanup-rule
cat > dlq-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowEventBridgeSendMessage",
"Effect": "Allow",
"Principal": {"Service": "events.amazonaws.com"},
"Action": "sqs:SendMessage",
"Resource": "$QUEUE_ARN",
"Condition": {"ArnEquals": {"aws:SourceArn": "$RULE_ARN"}}
}]
}
EOF
jq -n --arg policy "$(cat dlq-policy.json)" '{Policy: $policy}' > sqs-attributes.json
aws sqs set-queue-attributes --queue-url "$QUEUE_URL" --attributes file://sqs-attributes.json
3. Schedule it with EventBridge
Use rate() for simple intervals or cron() for specific times (6-field format, always UTC):
aws events put-rule \
--name scheduled-cleanup-rule \
--schedule-expression "rate(5 minutes)" \
--state ENABLED
For a daily 3 AM UTC run instead: --schedule-expression "cron(0 3 * * ? *)".
Grant EventBridge permission to invoke the function:
aws lambda add-permission \
--function-name scheduled-cleanup \
--statement-id AllowEventBridgeInvoke \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn "$RULE_ARN"
Attach the function as a target with the retry policy and DLQ from step 2:
LAMBDA_ARN=$(aws lambda get-function --function-name scheduled-cleanup --query Configuration.FunctionArn --output text)
cat > targets.json <<EOF
[
{
"Id": "cleanup-lambda-target",
"Arn": "$LAMBDA_ARN",
"RetryPolicy": {"MaximumRetryAttempts": 2, "MaximumEventAgeInSeconds": 3600},
"DeadLetterConfig": {"Arn": "$QUEUE_ARN"}
}
]
EOF
aws events put-targets --rule scheduled-cleanup-rule --targets file://targets.json
4. Add CloudWatch alerting
TOPIC_ARN=$(aws sns create-topic --name scheduled-cleanup-alerts --query TopicArn --output text)
aws sns subscribe --topic-arn "$TOPIC_ARN" --protocol email --notification-endpoint you@example.com
Confirm the subscription from the email AWS sends, or alarms will fire silently into nothing.
aws cloudwatch put-metric-alarm \
--alarm-name scheduled-cleanup-errors \
--namespace AWS/Lambda --metric-name Errors \
--dimensions Name=FunctionName,Value=scheduled-cleanup \
--statistic Sum --period 300 --evaluation-periods 1 \
--threshold 1 --comparison-operator GreaterThanOrEqualTo \
--treat-missing-data notBreaching --alarm-actions "$TOPIC_ARN"
aws cloudwatch put-metric-alarm \
--alarm-name scheduled-cleanup-dlq-messages \
--namespace AWS/SQS --metric-name ApproximateNumberOfMessagesVisible \
--dimensions Name=QueueName,Value=scheduled-cleanup-dlq \
--statistic Maximum --period 300 --evaluation-periods 1 \
--threshold 1 --comparison-operator GreaterThanOrEqualTo \
--treat-missing-data notBreaching --alarm-actions "$TOPIC_ARN"
The DLQ alarm is your real signal, errors metric catches transient blips too but messages sitting in the queue mean something actually needs a look.
Verify it works
Invoke manually first:
aws lambda invoke --function-name scheduled-cleanup out.json && cat out.json
Expect {"status": "ok"}. Confirm the target is attached:
aws events list-targets-by-rule --rule scheduled-cleanup-rule
Wait for a real scheduled run, then tail the logs:
aws logs tail /aws/lambda/scheduled-cleanup --since 10m --follow
You should see a Scheduled run: line with the event payload. To test failure handling, temporarily raise an exception in the handler and redeploy the zip. Messages should land in scheduled-cleanup-dlq, and you should get an SNS email once the alarm's evaluation period passes.
Troubleshooting
- Nothing happens, no logs, no errors. Almost always a missing or mismatched
add-permissioncall. The--source-arnmust match the rule's ARN exactly, region and account included. InvalidParameterValueExceptiononcreate-function. The IAM role hasn't finished propagating. Wait 10-15 seconds and retry.- Messages never show up in the DLQ despite real failures. Check which layer failed: function exceptions need
put-function-event-invoke-config's destination, only EventBridge delivery failures use the target'sDeadLetterConfig. Also verify the SQS policy'sSourceArncondition matches your rule. - Alarm never fires even with DLQ messages. Confirm the SNS subscription (unconfirmed subscriptions drop notifications silently), and double-check the alarm's dimension values match your actual function and queue names.
Next steps
If you need one-off future runs, time zones, or per-invocation flexibility instead of a recurring rule, look at EventBridge Scheduler, a newer, separate service built for that. For anything beyond a proof of concept, move this into CloudFormation, CDK, or Terraform rather than raw CLI calls, and consider adding an OnSuccess destination too so you get structured completion events instead of relying on logs alone.
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 1
i love how this article emphasizes setting up retries and a dead-letter queue - it's so easy to overlook those details when you're just trying to get a cron job running, but they're crucial for production-grade reliability