Per-User Cron Jobs on AWS Lambda
Deploy a Flask app on Lambda with per-user daily schedules via EventBridge.
Introduction
30 Second Summary
Think about apps that send you a daily digest, a morning briefing, or a personalised report. Every user wants theirs at a different time. Running thousands of individual scheduled tasks without a server humming 24/7 sounds impossible, but serverless scheduling makes it straightforward.
In this project, you will deploy a Flask application to AWS Lambda and build an API that creates per-user daily cron jobs using EventBridge Scheduler. Everything is defined in a single infrastructure-as-code template and deployed with one command.
What You'll Build
A live API endpoint where you POST a user ID and their preferred schedule time, and EventBridge Scheduler instantly creates a dedicated daily job that fires that user's personal pipeline on schedule.
By the end of this project, you'll have:
- A Flask app responding to HTTP requests at a public API Gateway URL, deployed serverlessly with a single SAM command.
- A schedule registration endpoint that creates per-user daily schedules with timezone support, so each user's cron job fires independently at their configured time.
- A cron Lambda that logs each per-user execution, verifiable in CloudWatch so you can trace exactly when each user's job ran.
- Secret Mission: Add a dead-letter queue and CloudWatch alarm to catch failed cron executions in production.
Are there any prerequisites?
You need an AWS account with the AWS CLI already configured, Python 3.12 installed, and Docker installed (SAM uses it to package Lambda dependencies). You will install the AWS SAM CLI in Step 1.
Before We Start
To deploy a Flask app on AWS Lambda, you need a way to define your infrastructure (Lambda functions, API Gateway, IAM roles) as code and deploy it all at once.
AWS SAM CLI is the tool that handles this. It reads a single template file describing your serverless architecture, packages your code, and deploys everything to AWS. In this step, you will install SAM CLI, create the project structure, and confirm SAM can build your project.
In this step, get ready to:
- Install the AWS SAM CLI and verify it works.
- Create the project directory structure with two Lambda function folders and a SAM template.
- Add dependency files and confirm SAM can build the project.
Install the AWS SAM CLI
The AWS SAM CLI lets you define, build, and deploy serverless applications from your terminal. You will use it throughout this project to package Lambda functions and push them to AWS.
- Check whether SAM CLI is already installed by running this command:
sam --version
✔️ I see a version number
You are good to go. You should see output like:
SAM CLI, version 1.163.0
Any recent version works for this project. Skip ahead to the next substep.
ⓧ Command not found
SAM CLI is not installed yet. Follow the instructions for your operating system below.
macOS
- Install the AWS SAM CLI using Homebrew by running this command:
brew install aws-sam-cli
- After installation completes, verify it worked by running:
sam --version
You should see output like SAM CLI, version 1.163.0 (your version number may differ).
Windows
- Download the AWS SAM CLI 64-bit MSI installer from the official install page:
- Run the downloaded MSI file and follow the on-screen prompts to complete installation.
- Open a new PowerShell or Command Prompt window (this is required for the PATH to update).
- Verify the installation by running:
sam --version
You should see output like SAM CLI, version 1.163.0 (your version number may differ).
Still seeing command not found?
On macOS, make sure Homebrew is installed first. If brew itself is not found, visit https://brew.sh to install it.
On Windows, you must open a new terminal window after installation for the PATH change to take effect. If it still does not work, try restarting your computer.
help me install the AWS SAM CLI
Create the project directory structure
Your project needs two separate Lambda function folders (one for the Flask web app, one for the cron job) plus a template.yaml at the root that tells SAM how to deploy them.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir flask-cron-scheduler
cd flask-cron-scheduler
- Create the two Lambda function folders and a test events folder by running:
mkdir flask_app cron_job events
- Confirm the folders exist by running:
ls
You should see cron_job, events, and flask_app listed in the output.
Create the requirements files and SAM template
Each Lambda function needs its own requirements.txt listing its Python dependencies. The SAM template defines how AWS deploys your functions.
- Create the Flask app's dependency file by running this command:
cat > flask_app/requirements.txt << 'EOF'
flask==3.1.1
serverless-wsgi==3.1.0
boto3==1.38.0
EOF
What are these dependencies?
- flask==3.1.1 is the web framework for handling HTTP requests.
- serverless-wsgi==3.1.0 adapts Flask's WSGI interface to work inside a Lambda function.
- boto3==1.38.0 is the AWS SDK for Python, used to create EventBridge schedules.
- Create the cron job's dependency file by running:
cat > cron_job/requirements.txt << 'EOF'
boto3==1.38.0
requests==2.32.3
EOF
- Create the SAM template file by running:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Flask app with per-user cron jobs via EventBridge Scheduler
Globals:
Function:
Runtime: python3.12
Architectures:
- x86_64
Parameters:
SupabaseUrl:
Type: String
Default: "your-supabase-url-here"
SupabaseKey:
Type: String
Default: "your-supabase-key-here"
NoEcho: true
LlmApiKey:
Type: String
Default: "your-llm-api-key-here"
NoEcho: true
Resources: {}
- Save this as template.yaml in the root of your project folder. If you used the cat command above it is already saved. If you are using your code editor, create a new file called template.yaml in the flask-cron-scheduler folder and paste the YAML above.
What does this template do?
The Transform: AWS::Serverless-2016-10-31 line tells AWS CloudFormation to interpret this as a SAM template with its shorthand syntax. The Globals section sets defaults for all Lambda functions (Python 3.12 runtime). The Parameters section defines secrets that get passed as environment variables later.
The Resources: {} section is intentionally empty for now. You will add your Lambda function definitions here in the next step.
- Verify your project structure looks correct by running:
ls
ls flask_app
ls cron_job
You should see template.yaml in the root, requirements.txt inside flask_app/, and requirements.txt inside cron_job/.
Before you run the build, what do you think will happen? The template has no Lambda functions defined yet.
- Test that SAM can process your template by running:
sam build
SAM will validate your template and confirm the build succeeded. Since Resources is empty, there is nothing to package yet, but the build confirms your template is valid and SAM CLI is working.
Build failed or template error?
Check that your template.yaml is in the root of your current directory (the flask-cron-scheduler folder). SAM looks for the template in the directory where you run the command.
If you see a YAML parsing error, double-check the indentation. YAML requires spaces (not tabs) and consistent indentation at each level.
help me fix my SAM build error
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: template.yaml, flask_app/requirements.txt, and cron_job/requirements.txt.
ⓧ I'd like to double check the full code
Here are the three files you should have. Scroll up and confirm each one matches.
template.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Flask app with per-user cron jobs via EventBridge Scheduler
Globals:
Function:
Runtime: python3.12
Architectures:
- x86_64
Parameters:
SupabaseUrl:
Type: String
Default: "your-supabase-url-here"
SupabaseKey:
Type: String
Default: "your-supabase-key-here"
NoEcho: true
LlmApiKey:
Type: String
Default: "your-llm-api-key-here"
NoEcho: true
Resources: {}
flask_app/requirements.txt:
flask==3.1.1
serverless-wsgi==3.1.0
boto3==1.38.0
cron_job/requirements.txt:
boto3==1.38.0
requests==2.32.3
Your project skeleton is ready and SAM confirms it can process the template. Next up, you will add the Flask app code and deploy it to AWS Lambda behind API Gateway.
Deploy the Flask App to Lambda
Your project structure is ready with dependencies defined, but your Flask app has no code yet and nothing is deployed to AWS.
To make your app reachable over the internet, you need to deploy it to Lambda behind API Gateway. This gives you a public URL without managing servers.
In this step, get ready to:
- Write the Flask app with a health check endpoint and a serverless-wsgi handler.
- Define the Lambda function and API Gateway events in your SAM template.
- Deploy to AWS and confirm your endpoint responds over the internet.
Create the Flask app with a health route
Your Flask app needs two things to run on Lambda: a route that responds to HTTP requests, and a handler function that bridges Lambda events into Flask's request format.
- Create a new file called app.py inside your flask_app/ folder by running:
touch flask_app/app.py
- Confirm the file exists by running:
ls flask_app/
You should see both app.py and requirements.txt listed.
- Open flask_app/app.py in your editor and paste the following code:
import json
import os
import logging
import boto3
import serverless_wsgi
from flask import Flask, jsonify, request
app = Flask(__name__)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@app.route("/health")
def health():
return jsonify({"status": "ok"})
def handler(event, context):
return serverless_wsgi.handle_request(app, event, context)
What does this code do?
- The /health route returns a simple JSON response so you can confirm the app is live.
- The handler function is the Lambda entry point. It uses serverless_wsgi.handle_request to translate Lambda's event format into a standard Flask request.
- Flask is a WSGI framework, so it needs a WSGI adapter to run on Lambda. That single handle_request call handles the entire translation.
- Save flask_app/app.py.
Don't see app.py listed?
- Make sure you created the file inside the flask_app/ folder, not in the project root.
- Check that your terminal is in the project root directory (the folder that contains flask_app/ and template.yaml).
Still stuck? Why isn't my app.py showing up in the flask_app directory?
Define the Lambda function in your SAM template
Your template currently has an empty Resources section. You need to add a function definition that tells SAM where your code lives, which handler to call, and how to route HTTP traffic to it.
- Open template.yaml in your editor.
- Find the line that says Resources: {} and replace it with the following:
Resources:
FlaskAppFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: flask_app/
Handler: app.handler
Timeout: 30
MemorySize: 256
Events:
RootPath:
Type: Api
Properties:
Path: /
Method: ANY
ProxyPath:
Type: Api
Properties:
Path: /{proxy+}
Method: ANY
Outputs:
ApiEndpoint:
Description: API Gateway endpoint URL
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
What does this template do?
- FlaskAppFunction defines a Lambda function that points to flask_app/ for its code and calls app.handler when invoked.
- The two Events entries create an API Gateway that routes all HTTP methods on any path to your function. The /{proxy+} pattern is a catch-all that forwards every request path to Flask.
- The Outputs section prints the public URL after deployment so you can test your app immediately.
- Save template.yaml.
- Run a build to verify SAM can package your function:
sam build
You should see a Build Succeeded message. This confirms SAM found your code and installed the dependencies from requirements.txt.
Build failed?
- Check that your template.yaml indentation uses spaces, not tabs. YAML is strict about this.
- Verify that CodeUri: flask_app/ matches the actual folder name exactly.
- Make sure flask_app/requirements.txt exists and lists flask, serverless-wsgi, and boto3.
Still stuck? Help me fix my sam build failure.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on to deploy.
ⓧ I'd like to double check the full code
Here is the full flask_app/app.py:
import json
import os
import logging
import boto3
import serverless_wsgi
from flask import Flask, jsonify, request
app = Flask(__name__)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@app.route("/health")
def health():
return jsonify({"status": "ok"})
def handler(event, context):
return serverless_wsgi.handle_request(app, event, context)
For template.yaml, the file exceeds 30 lines so confirm each section is present by scrolling up:
- The Globals and Parameters sections from the previous step are still at the top.
- The Resources section contains FlaskAppFunction with CodeUri: flask_app/, Handler: app.handler, and both RootPath and ProxyPath events.
- The Outputs section contains ApiEndpoint with the !Sub reference to your API Gateway URL.
Deploy to AWS and test your endpoint
Now that the build succeeds, it is time to push your app to the cloud. The sam deploy --guided command walks you through configuring the deployment interactively.
What happens during guided deploy?
SAM asks you a series of questions to configure your CloudFormation stack. It packages your code, uploads it to S3, and creates all the resources defined in your template.
Your answers get saved to a samconfig.toml file so future deploys skip the prompts entirely.
- Deploy your application by running:
sam deploy --guided
SAM will present several prompts. Answer them as follows:
- Enter flask-cron-app for the **Stack Name** prompt.
- Press **Enter** for the **AWS Region** to accept your default.
- Press **Enter** for **Confirm changes before deploy** to accept the default.
- Press **Enter** for **Allow SAM CLI IAM role creation** to accept the default.
- Press **Enter** for **Disable rollback** to accept the default.
- Enter y when asked FlaskAppFunction may not have authorization defined, Is this okay? This is expected because your API is publicly accessible.
- Press **Enter** for **Save arguments to configuration file** to save your choices.
- Press **Enter** for both **SAM configuration file** and **SAM configuration environment** to accept defaults.
SAM will create and deploy the CloudFormation changeset. This takes 1-2 minutes.
Why answer y to the authorization question?
SAM detects that your API Gateway has no authentication configured and asks if that is intentional. For this project, a public endpoint is what you want. Answering N would abort the deployment.
When deployment finishes, look for the **Outputs** section in the terminal. Before you test it, what do you think will happen when you curl the /health path?
- Copy the ApiEndpoint URL from the Outputs and test it by running:
curl [[API_ENDPOINT="your API Gateway endpoint URL from the Outputs"]]health
You should see this response:
{"status":"ok"}
Your Flask app is live on the internet, running serverlessly on Lambda behind API Gateway.
Seeing a 403 or 500 error?
- Make sure the URL ends with Prod/health (the Prod/ stage prefix is required).
- If you see a 403, check that you answered y to the authorization prompt during deploy. If you answered no, delete the stack and redeploy.
- If you see a 500, check that Handler: app.handler in template.yaml matches the handler function name in app.py.
Still stuck? Help me debug my Flask Lambda deployment.
Your Flask app is deployed and responding to requests from anywhere in the world. Next up, you will build the cron job Lambda that runs daily per user.
Create the Cron Job Lambda
Your Flask app handles incoming HTTP requests, but the daily per-user pipeline needs its own dedicated function. Each user's cron job pulls config, calls APIs, runs processing, hits an LLM, and saves results.
Separating this logic into its own Lambda function gives it independent timeout settings, memory allocation, and IAM permissions. In this step, you will build the cron handler, register it in your SAM template, and test it locally.
In this step, get ready to:
- Create the cron job Lambda handler with stubbed pipeline steps.
- Add the function definition to the SAM template with environment variables.
- Test the function locally using SAM CLI and Docker.
Create the cron job handler
The cron handler is a standard Lambda function that receives an event with a user_id and runs a five-step pipeline. For now, you will stub each step with logging so you can trace execution end-to-end before wiring up real services.
- Create a new file called handler.py inside the cron_job/ folder.
- Add the imports, logger setup, and the first section of the handler by pasting this code:
import json
import logging
import os
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
user_id = event.get("user_id", "unknown")
logger.info(f"Processing cron job for user: {user_id}")
# Step 1: Retrieve configuration from Supabase
logger.info(f"[{user_id}] Fetching configuration from Supabase...")
supabase_url = os.environ.get("SUPABASE_URL", "")
supabase_key = os.environ.get("SUPABASE_KEY", "")
config = {"api_endpoint": "https://api.example.com", "prompt_template": "Summarize: {data}"}
logger.info(f"[{user_id}] Configuration retrieved: {json.dumps(config)}")
What does this code do?
The function extracts user_id from the incoming event so every log line is tagged with which user triggered the run.
Step 1 reads environment variables for Supabase credentials. Right now it returns a hardcoded config dictionary as a placeholder for the real Supabase query you will add later.
- Directly below Step 1, add the remaining pipeline steps and the return statement:
# Step 2: Call external APIs
logger.info(f"[{user_id}] Calling external APIs...")
external_data = {"sample": "data from external API"}
logger.info(f"[{user_id}] External API data received")
# Step 3: Run Python processing scripts
logger.info(f"[{user_id}] Running processing scripts...")
processed_data = {
"user_id": user_id,
"input_data": external_data,
"processed": True,
"summary": f"Processed {len(external_data)} items",
}
logger.info(f"[{user_id}] Processing complete")
# Step 4: Call LLM API
logger.info(f"[{user_id}] Calling LLM API...")
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_result = {"response": f"LLM analysis for user {user_id}: All metrics normal."}
logger.info(f"[{user_id}] LLM response received")
# Step 5: Save results to Supabase
logger.info(f"[{user_id}] Saving results to Supabase...")
logger.info(f"[{user_id}] Results saved successfully")
return {
"statusCode": 200,
"body": json.dumps({
"user_id": user_id,
"status": "completed",
"result": llm_result,
}),
}
What does this code do?
Steps 2 through 5 each log their action and return stub data. This makes execution visible in CloudWatch later without needing real API keys during development.
The function returns a standard Lambda response with statusCode and a JSON body so you can inspect results when invoking it.
- Save cron_job/handler.py.
- Confirm the file exists by running:
ls cron_job/
You should see handler.py and requirements.txt listed in the output.
Don't see handler.py?
Make sure you created the file inside the cron_job/ directory, not at the project root. The folder should already exist from the previous step when you created cron_job/requirements.txt.
Help me figure out where my file was saved
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your cron_job/handler.py should contain both chunks in order:
- Chunk 1: Imports (json, logging, os), logger setup, lambda_handler definition with user_id extraction, and Step 1 (Supabase config fetch).
- Chunk 2: Steps 2-5 (external API, processing, LLM, save) followed by the return statement with statusCode and JSON body.
Scroll up and confirm each chunk is present and in order.
Create the test event and register the function
SAM CLI can invoke your Lambda locally using a JSON event file. You also need to register the new function in template.yaml so SAM knows it exists.
- Create a folder called events/ at your project root, then create test_event.json inside it with the following content:
{
"user_id": "test-user-123"
}
What does this event represent?
This simulates what EventBridge Scheduler will send to your cron Lambda when it fires for a specific user. The only required field is user_id because the handler uses it to tag all log output.
- Save events/test_event.json.
- Now switch to template.yaml and find the closing lines of the FlaskAppFunction resource.
- Add the CronJobFunction directly below it (still inside the Resources section):
CronJobFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: cron_job/
Handler: handler.lambda_handler
Timeout: 300
MemorySize: 512
Environment:
Variables:
SUPABASE_URL: !Ref SupabaseUrl
SUPABASE_KEY: !Ref SupabaseKey
LLM_API_KEY: !Ref LlmApiKey
Why a separate timeout and memory?
The Flask app times out at 30 seconds because HTTP responses should be fast. The cron job gets 300 seconds (5 minutes) because it calls external APIs and an LLM, which can have variable latency.
The Environment block references the Parameters you defined at the top of the template. SAM injects these as real environment variables at deploy time.
- In the same file, find the Outputs section and add an output for the cron function ARN below the existing ApiEndpoint output:
CronJobFunctionArn:
Description: ARN of the cron job Lambda function
Value: !GetAtt CronJobFunction.Arn
- Save template.yaml.
YAML indentation errors?
SAM templates are sensitive to indentation. Make sure CronJobFunction is indented at the same level as FlaskAppFunction (two spaces under Resources).
Help me fix my template.yaml formatting
Test the cron Lambda locally
SAM CLI uses Docker to spin up a local container that mimics the Lambda runtime. Make sure Docker Desktop is running before you invoke.
- Build the project so SAM picks up the new function by running:
sam build
Before you run the next command, predict what you expect the cron Lambda to print when it receives the test event.
- Invoke the cron function locally by running:
sam local invoke CronJobFunction --event events/test_event.json
You should see log lines for each pipeline step tagged with test-user-123, starting with:
Processing cron job for user: test-user-123
The final output includes a JSON response with "status": "completed" and the LLM stub result.
Seeing a Docker error?
The sam local invoke command requires Docker to be installed and running. Open Docker Desktop and wait for the engine to start before retrying.
If you see a timeout during the first run, that is Docker pulling the Lambda base image. It only happens once and subsequent invocations are faster.
Help me troubleshoot my Docker setup
Your cron Lambda is working locally. Next up, you will wire it to EventBridge Scheduler so each user gets their own daily trigger that fires this function automatically.
Wire Up EventBridge Scheduler
Your cron Lambda is deployed and working, but nothing triggers it yet. Without a scheduling system, you would need a single cron job that loops over all users. That approach is fragile and makes it impossible to run at different times per user.
EventBridge Scheduler solves this by creating a dedicated schedule per user with timezone support and built-in retry. In this step, you will wire up the IAM permissions that connect EventBridge Scheduler to your cron Lambda, giving your Flask app the power to create per-user schedules programmatically.
In this step, get ready to:
- Create an IAM execution role for EventBridge Scheduler.
- Grant your Flask Lambda permission to create and delete schedules.
- Deploy and confirm the wiring in the AWS Console.
Add the EventBridge Scheduler execution role
EventBridge Scheduler needs its own IAM role to invoke your cron Lambda. This role is what EventBridge Scheduler "assumes" each time a schedule fires, giving it short-lived permission to call the target function.
- In template.yaml, find the Resources: section.
- Add the following SchedulerExecutionRole resource below the CronJobFunction resource:
SchedulerExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AWS::StackName}-scheduler-role"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: scheduler.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InvokeCronLambda
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- lambda:InvokeFunction
Resource: !GetAtt CronJobFunction.Arn
What does this resource do?
- AssumeRolePolicyDocument tells AWS that only the scheduler.amazonaws.com service can assume this role. No other service or user can use it.
- The InvokeCronLambda policy grants exactly one permission: calling lambda:InvokeFunction on your CronJobFunction. This is least-privilege access.
- RoleName uses !Sub to prefix the role with your stack name, avoiding naming conflicts across deployments.
- Also add this new output at the bottom of the Outputs: section:
SchedulerRoleArn:
Description: ARN of the EventBridge Scheduler execution role
Value: !GetAtt SchedulerExecutionRole.Arn
- Save template.yaml and validate the template by running:
sam validate
You should see template.yaml is a valid SAM Template confirming your YAML is well-formed.
Seeing a YAML parsing error?
- YAML is whitespace-sensitive. Make sure the SchedulerExecutionRole block is indented at the same level as FlaskAppFunction and CronJobFunction (two spaces from the left).
- Check that there are no tab characters. YAML requires spaces only.
- help me fix a YAML indentation error in my SAM template
Grant the Flask Lambda scheduler permissions
Your Flask app will create EventBridge schedules on behalf of users. For that to work, its Lambda execution role needs explicit permission to call the Scheduler API and to pass the execution role you just created.
- In template.yaml, find the FlaskAppFunction resource.
- Add the following Policies block inside the Properties section, below MemorySize: 256:
Policies:
- Statement:
- Effect: Allow
Action:
- scheduler:CreateSchedule
- scheduler:DeleteSchedule
- scheduler:GetSchedule
Resource: "arn:aws:scheduler:*:*:schedule/default/*"
- Effect: Allow
Action:
- iam:PassRole
Resource: !GetAtt SchedulerExecutionRole.Arn
Condition:
StringEquals:
iam:PassedToService: scheduler.amazonaws.com
What do these permissions allow?
- scheduler:CreateSchedule, scheduler:DeleteSchedule, and scheduler:GetSchedule let the Flask app manage schedules in the default schedule group.
- iam:PassRole allows the Flask app to hand the SchedulerExecutionRole to EventBridge Scheduler when creating a schedule. The condition ensures this role can only be passed to the scheduler service.
Why is iam:PassRole needed?
When your Flask app creates a schedule, it tells EventBridge Scheduler which IAM role to use for triggering the Lambda. AWS treats this as a sensitive action because it delegates permissions. The iam:PassRole permission with the iam:PassedToService condition ensures only the scheduler service can receive this role.
- Save template.yaml.
Pass function ARNs as environment variables and deploy
Your Flask app needs to know the ARN of the cron function and the scheduler role at runtime. Environment variables are how Lambda functions receive configuration values.
- In the FlaskAppFunction Properties section, add the following Environment block below the Policies section you just added:
Environment:
Variables:
CRON_FUNCTION_ARN: !GetAtt CronJobFunction.Arn
SCHEDULER_ROLE_ARN: !GetAtt SchedulerExecutionRole.Arn
What does this do?
SAM resolves !GetAtt CronJobFunction.Arn and !GetAtt SchedulerExecutionRole.Arn at deploy time and injects the real ARN strings into the Lambda's environment. Your Flask code can then read them with os.environ.get("CRON_FUNCTION_ARN") without hardcoding account-specific values.
- Save template.yaml and deploy the updated stack by running:
sam build && sam deploy
Before you check the output, do you think the deploy will succeed on the first try? What new resources should CloudFormation report as created?
You should see CloudFormation report the SchedulerExecutionRole as a newly created resource. The deploy completes with a new output: SchedulerRoleArn.
- Open the AWS Console in your browser.
- Search for EventBridge Scheduler in the service search bar and open it.
- Click Schedules in the left sidebar.
You should see no schedules listed yet. That is correct. Users have not registered, so no per-user schedules exist.
- Click Schedule groups in the left sidebar to confirm the default group exists. This is where your Flask app will create schedules.
Deploy failing with a role-related error?
- Check that SchedulerExecutionRole is defined at the same indentation level as the other resources under Resources:.
- Verify the !GetAtt SchedulerExecutionRole.Arn references match exactly. A typo in the resource name causes a "resource not found" error during stack creation.
- If you see a ROLLBACK_COMPLETE status, the previous deploy failed and left the stack in a bad state. Delete the stack and redeploy.
- help me debug my SAM deploy failure for the EventBridge Scheduler role
✔️ Awesome, I've got everything!
Great. Double check you have saved template.yaml and that your deploy succeeded without errors.
ⓧ I'd like to double check the full code
Your template.yaml has grown beyond a quick glance, so scroll up and confirm the following sections are present in this order:
- Globals block with Runtime: python3.12.
- Parameters block with SupabaseUrl, SupabaseKey, LlmApiKey.
- FlaskAppFunction with Environment (containing CRON_FUNCTION_ARN and SCHEDULER_ROLE_ARN) and Policies (with scheduler:CreateSchedule, scheduler:DeleteSchedule, scheduler:GetSchedule, and iam:PassRole).
- CronJobFunction with environment variables for Supabase and LLM keys.
- SchedulerExecutionRole trusting scheduler.amazonaws.com with lambda:InvokeFunction on the CronJobFunction.
- Outputs with ApiEndpoint, CronJobFunctionArn, and SchedulerRoleArn.
Your infrastructure wiring is complete. The IAM role, permissions, and environment variables are all in place. Next up, you will build the Flask endpoint that actually creates per-user schedules using this wiring.
Build the Schedule Registration Endpoint
You have two Lambda functions deployed and an IAM role ready for EventBridge Scheduler to assume. The missing piece is the trigger: nothing actually creates a schedule for a user yet.
In this step, you will build an API endpoint that programmatically creates a dedicated daily EventBridge schedule for any user who calls it. A single POST request will wire up the entire per-user cron pattern end-to-end.
In this step, get ready to:
- Add a /schedules POST route to your Flask app that creates per-user EventBridge schedules.
- Handle duplicate schedule conflicts and return appropriate HTTP status codes.
- Redeploy and verify the full loop from API call to scheduled Lambda execution.
Add the route handler and input parsing
The Flask app needs a new route that accepts a user ID, their preferred time, and timezone. It will parse those inputs into the cron expression format that EventBridge Scheduler expects.
- In flask_app/app.py, find the line that says logger.setLevel(logging.INFO).
- Add the following code directly below it (before the @app.route("/health") decorator):
# Initialize the EventBridge Scheduler client
scheduler_client = boto3.client("scheduler")
# Read the cron Lambda ARN and scheduler role ARN from environment variables
CRON_FUNCTION_ARN = os.environ.get("CRON_FUNCTION_ARN", "")
SCHEDULER_ROLE_ARN = os.environ.get("SCHEDULER_ROLE_ARN", "")
What does this code do?
scheduler_client = boto3.client("scheduler") creates a boto3 client for the EventBridge Scheduler API. This client handles authentication and request signing automatically using the Lambda's execution role.
CRON_FUNCTION_ARN and SCHEDULER_ROLE_ARN pull the values that SAM injected from your template.yaml (the !GetAtt references you set up in the previous step). The endpoint needs these to tell EventBridge which Lambda to invoke and which role to assume.
- Now add the new route. Find the @app.route("/health") section and paste the following code below the health() function, but above the def handler(event, context): function at the bottom:
@app.route("/schedules", methods=["POST"])
def create_schedule():
data = request.get_json()
# Validate that a user_id was provided
if not data or "user_id" not in data:
return jsonify({"error": "user_id is required"}), 400
# Extract request parameters with sensible defaults
user_id = data["user_id"]
schedule_time = data.get("schedule_time", "08:00")
timezone = data.get("timezone", "UTC")
# Parse the HH:MM time into separate hours and minutes
parts = schedule_time.split(":")
hours = int(parts[0])
minutes = int(parts[1])
# Build the schedule name and cron expression
schedule_name = f"{user_id}-daily-cron"
schedule_expression = f"cron({minutes} {hours} * * ? *)"
What does this code do?
- The route accepts a JSON body with user_id (required), schedule_time (defaults to 08:00), and timezone (defaults to UTC).
- EventBridge Scheduler uses a 6-field cron format: cron(minutes hours day-of-month month day-of-week year). The ? in day-of-week means "any day" since we want daily runs.
- Each user gets a unique schedule name like user-42-daily-cron. This naming pattern makes it easy to find and delete individual user schedules later.
Add the EventBridge Scheduler call with error handling
The route has parsed the user's request into a cron expression. Now it needs to call the EventBridge Scheduler API to actually create the schedule, and handle what happens when a schedule already exists for that user.
- Still in flask_app/app.py, add the following code directly below the schedule_expression line you just added (still inside the create_schedule() function):
try:
# Call EventBridge Scheduler to create the per-user schedule
response = scheduler_client.create_schedule(
Name=schedule_name,
ScheduleExpression=schedule_expression,
ScheduleExpressionTimezone=timezone,
FlexibleTimeWindow={"Mode": "OFF"},
Target={
"Arn": CRON_FUNCTION_ARN,
"RoleArn": SCHEDULER_ROLE_ARN,
"Input": json.dumps({"user_id": user_id}),
},
State="ENABLED",
)
return jsonify({
"message": f"Schedule created for user {user_id}",
"schedule_arn": response["ScheduleArn"],
"schedule_expression": schedule_expression,
"timezone": timezone,
}), 201
# Handle duplicate schedule (user already registered)
except scheduler_client.exceptions.ConflictException:
return jsonify({
"error": f"Schedule already exists for user {user_id}",
}), 409
except Exception as e:
logger.error(f"Error creating schedule: {str(e)}")
return jsonify({"error": "Failed to create schedule"}), 500
What does this code do?
- create_schedule() tells EventBridge Scheduler to create a new daily trigger. The Target specifies which Lambda to invoke (your cron job), which role to assume, and what payload to pass (the user_id).
- FlexibleTimeWindow set to OFF means the schedule fires exactly at the specified time, not within a flexible window.
- ConflictException fires when a schedule with the same name already exists. Returning a 409 tells the caller to delete the old schedule first or use a different user ID.
- Save flask_app/app.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on to redeploy.
ⓧ I'd like to double check the full code
Here is the complete flask_app/app.py file. Make sure yours matches:
import json
import os
import logging
import boto3
import serverless_wsgi
from flask import Flask, jsonify, request
app = Flask(__name__)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
scheduler_client = boto3.client("scheduler")
CRON_FUNCTION_ARN = os.environ.get("CRON_FUNCTION_ARN", "")
SCHEDULER_ROLE_ARN = os.environ.get("SCHEDULER_ROLE_ARN", "")
@app.route("/health")
def health():
return jsonify({"status": "ok"})
@app.route("/schedules", methods=["POST"])
def create_schedule():
data = request.get_json()
if not data or "user_id" not in data:
return jsonify({"error": "user_id is required"}), 400
user_id = data["user_id"]
schedule_time = data.get("schedule_time", "08:00")
timezone = data.get("timezone", "UTC")
parts = schedule_time.split(":")
hours = int(parts[0])
minutes = int(parts[1])
schedule_name = f"{user_id}-daily-cron"
schedule_expression = f"cron({minutes} {hours} * * ? *)"
try:
response = scheduler_client.create_schedule(
Name=schedule_name,
ScheduleExpression=schedule_expression,
ScheduleExpressionTimezone=timezone,
FlexibleTimeWindow={"Mode": "OFF"},
Target={
"Arn": CRON_FUNCTION_ARN,
"RoleArn": SCHEDULER_ROLE_ARN,
"Input": json.dumps({"user_id": user_id}),
},
State="ENABLED",
)
return jsonify({
"message": f"Schedule created for user {user_id}",
"schedule_arn": response["ScheduleArn"],
"schedule_expression": schedule_expression,
"timezone": timezone,
}), 201
except scheduler_client.exceptions.ConflictException:
return jsonify({
"error": f"Schedule already exists for user {user_id}",
}), 409
except Exception as e:
logger.error(f"Error creating schedule: {str(e)}")
return jsonify({"error": "Failed to create schedule"}), 500
def handler(event, context):
return serverless_wsgi.handle_request(app, event, context)
Seeing a syntax error?
- Make sure the entire try/except block is indented inside the create_schedule() function (4 spaces in from def).
- Confirm the def handler(event, context): function is still at the very bottom of the file, below the new route.
Help me debug a syntax error in my Flask /schedules route
Redeploy and test the endpoint
The code is ready. Time to deploy the updated Flask app and prove the full loop works: an API call creates a schedule, and that schedule targets your cron Lambda.
- Build and deploy the updated stack by running:
sam build && sam deploy
What does this command do?
sam build re-packages both Lambda functions with their dependencies. sam deploy uploads the new artifacts and updates the CloudFormation stack. Since you already ran sam deploy --guided earlier, your saved config means you can just use sam deploy from now on.
- Wait for the deployment to complete. You will see Successfully created/updated stack in your terminal when it finishes.
Deployment failing?
- If you see an import error during build, confirm boto3 is listed in flask_app/requirements.txt.
- If CloudFormation rolls back, check the error in the AWS Console under CloudFormation > your stack > Events tab for the specific failure reason.
Help me debug my SAM deployment failure
Before you test the endpoint, what do you think will happen when you POST a user ID and schedule time? Will you get back a success response immediately, or will something else happen first?
- Test the new endpoint by running this curl command (replace the URL with your API Gateway endpoint from the deployment outputs):
curl -X POST [[API_ENDPOINT="https://your-api-gateway-url/Prod"]]/schedules \
-H "Content-Type: application/json" \
-d '{"user_id": "user-42", "schedule_time": "09:00", "timezone": "America/New_York"}'
You should see a JSON response with a 201 status containing the schedule ARN, the cron expression, and the timezone.
What should I see?
The response looks something like this:
{
"message": "Schedule created for user user-42",
"schedule_arn": "arn:aws:scheduler:us-east-1:123456789:schedule/default/user-42-daily-cron",
"schedule_expression": "cron(0 9 * * ? *)",
"timezone": "America/New_York"
}
- Now verify the schedule was actually created. Open the AWS Console and navigate to Amazon EventBridge.
- In the left sidebar, click Schedules (under the Scheduler heading).
- Confirm you see a schedule named user-42-daily-cron with the expression cron(0 9 * * ? *).
Why verify in the console?
The console confirmation proves the full chain worked: your Flask app received the request, the Lambda's IAM role had the right permissions, and EventBridge Scheduler accepted the schedule definition. If the schedule exists, the per-user cron pattern is live.
- Test the conflict handling by running the same curl command again:
curl -X POST [[API_ENDPOINT="https://your-api-gateway-url/Prod"]]/schedules \
-H "Content-Type: application/json" \
-d '{"user_id": "user-42", "schedule_time": "09:00", "timezone": "America/New_York"}'
This time you should see a 409 response with the message Schedule already exists for user user-42. Your error handling is working.
Getting a 500 error instead of 201?
- Check that your template.yaml has the scheduler:CreateSchedule permission in the FlaskAppFunction's Policies. Without it, the boto3 call is denied.
- Verify the iam:PassRole permission is present. EventBridge Scheduler requires the caller to have PassRole on the execution role it will assume.
- Check CloudWatch Logs for the FlaskAppFunction. The generic except Exception block logs the actual error message there.
Help me debug a 500 error from my /schedules endpoint
The architecture is fully wired. A single API call now creates a dedicated daily schedule for any user, and EventBridge Scheduler handles the rest. Each user's cron job fires independently at their configured time with their ID as the payload. You have a production-ready per-user scheduling system running at zero cost on the free tier.
Secret mission
Add a Dead-Letter Queue for Failed Cron Jobs
In production, cron jobs will occasionally fail. LLM APIs go down, Supabase times out, rate limits kick in. Without a dead-letter queue, those failed executions vanish silently. In this secret mission, you'll add an SQS dead-letter queue and a CloudWatch alarm so you always know when a user's job fails.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All resources in this project fall within the AWS free tier at 100 users running daily, so there is no ongoing cost while you remain within free-tier limits.
Cost warning
API Gateway's free tier covers 1M requests/month for 12 months only. After 12 months, leaving the stack deployed may incur charges. EventBridge Scheduler (14M invocations/month) and Lambda (1M requests/month) remain on the perpetual free tier.
Resources you used:
- 2 Lambda functions (Flask app + Cron job).
- 1 API Gateway HTTP API.
- 1 IAM role for EventBridge Scheduler.
- EventBridge Schedules (1 per registered user, created at runtime).
- 1 SQS dead-letter queue (Secret Mission).
- 1 CloudWatch Alarm (Secret Mission).
- 1 S3 bucket (SAM deployment artifacts).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with per-user schedules.
- At 100 users with daily schedules, your monthly cost remains $0 under free-tier allowances.
- EventBridge Schedules you created via the API will continue to fire at their configured times.
- AWS SAM CLI remains installed on your machine for future projects.
Pause - I'll come back to this later
Disable your schedules to stop cron executions without deleting infrastructure. There is no charge for disabled schedules.
- List your schedules by running:
- For each schedule you want to pause, disable it in the EventBridge Scheduler console by navigating to Amazon EventBridge, then Scheduler, then Schedules. Select the schedule and click Disable.
- Your Lambda functions and API Gateway remain deployed but will not be invoked by disabled schedules.
aws scheduler list-schedules
When you are ready to resume, re-enable the schedules from the same console page.
Delete - I don't want to use this again
Remove all project resources completely. This deletes both Lambda functions, the API Gateway, the IAM role, the SQS queue, and the CloudWatch Alarm.
First, delete any EventBridge Schedules you created via the API. These live outside the CloudFormation stack, so sam delete will not remove them.
- List schedules you created by running:
aws scheduler list-schedules
- Delete each schedule by running (replace the name with each schedule from the list):
aws scheduler delete-schedule --name [[SCHEDULE_NAME="user-42-daily-cron"]]
Next, delete the SAM stack and all CloudFormation-managed resources.
- Run this command to delete the stack:
sam delete --stack-name [[STACK_NAME="your-stack-name"]]
- Confirm both prompts when SAM asks whether to delete the stack and the S3 bucket.
Finally, remove the local project files from your Desktop.
- Delete the project folder by running (macOS/Linux):
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
- On Windows, run:
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]
That's a Wrap!
That's a Wrap!
Nice work! You just deployed a production-ready serverless architecture where every user gets their own dedicated daily cron job, all managed through a single API call and deployed with one command.
You've learned how to:
- Deploy a Flask application serverlessly on AWS Lambda behind API Gateway using SAM and serverless-wsgi.
- Build an API endpoint that programmatically creates per-user daily schedules with timezone support via EventBridge Scheduler.
- Prove the full loop end-to-end: an API call creates a schedule, the schedule triggers a Lambda function, and the function logs per-user execution in CloudWatch.
- Secret Mission: Added a dead-letter queue and CloudWatch alarm for production-grade failure handling.
Ready to quiz yourself?