Skip to content
Cloud & Infra Beginner Tutorial

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.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jul 18, 2026 · 9 min read
Schedule Serverless Cron Jobs with AWS EventBridge and Lambda

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 use us-east-1.
  • jq installed (brew install jq or apt install jq), used once to build a JSON payload.
  • zip on 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 RetryPolicy and DeadLetterConfig on 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-permission call. The --source-arn must match the rule's ARN exactly, region and account included.
  • InvalidParameterValueException on create-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's DeadLetterConfig. Also verify the SQS policy's SourceArn condition 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 Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

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

Join the discussion

Sign in or create an account to comment and vote.

Priya Nair @k8s_whisperer · 3 hours ago

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

Related Reading