CI/CD Pipeline with GitHub Actions
Automate testing, containerization, and deployment to AWS Lambda.
Introduction
30 Second Summary
Every time a developer changes their code, it needs to travel from their laptop to a live server that users can reach. Doing this manually is slow, error-prone, and the number one reason "it works on my machine" became a meme.
In this project, you will build a CI/CD pipeline that automatically tests, containerizes, and deploys a Python API to AWS Lambda every time you push code to GitHub.
What You'll Build
A fully automated pipeline that takes a Python REST API from code push to live deployment using GitHub Actions, Podman, and AWS.
By the end of this project, you'll have:
- A containerized Python API deployed to AWS Lambda with a public HTTPS endpoint you can hit from your browser.
- An ECR repository storing your container images and a GitHub Actions workflow that automatically tests, builds, and deploys on every push.
- Secure, keyless OIDC authentication between GitHub and AWS so your pipeline never stores long-lived credentials.
- Secret Mission: Add a multi-stage Docker build and implement a staging/production deployment strategy with environment protection rules.
Are there any prerequisites?
You should be comfortable with basic Python, Git, and running commands in the terminal. You will also need a GitHub account, an AWS account, and VS Code installed on your machine.
Set Up Your Tools
Before you can build a pipeline that deploys code automatically, you need the right tools installed and connected.
In this step, you will install the AWS CLI, verify Python and Podman, and create a GitHub repository to hold your project code.
In this step, get ready to:
- Install and configure the AWS CLI to manage cloud resources from your terminal.
- Verify Python and Podman are ready to build and test your app.
- Create a public GitHub repository and clone it locally.
Install and verify AWS CLI
The AWS CLI lets you manage AWS services directly from your terminal. You will use it throughout this project to create cloud resources and deploy your container.
- Check if the AWS CLI is already installed by running this command:
aws --version
✔️ I see a version starting with 2
The AWS CLI v2 is already installed. You are ready to configure it.
ⓧ I see a version starting with 1
You have AWS CLI version 1 installed. Version 2 is the current major release with features this project needs. Follow the instructions below to upgrade.
macOS
- Download and install the AWS CLI v2 package by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- Your Mac will ask for your password. Type it and press Enter.
- Verify the installation by running:
aws --version
You should see output starting with aws-cli/2.
Windows
- Download and run the AWS CLI v2 MSI installer from this URL in your browser:
https://awscli.amazonaws.com/AWSCLIV2.msi
- Follow the on-screen prompts to complete the installation.
- Close and reopen your terminal, then verify by running:
aws --version
You should see output starting with aws-cli/2.
ⓧ Command not found
The AWS CLI is not installed yet. Follow the instructions below for your operating system.
macOS
- Download and install the AWS CLI v2 package by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- Your Mac will ask for your password. Type it and press Enter.
- Verify the installation by running:
aws --version
You should see output starting with aws-cli/2.
Windows
- Download and run the AWS CLI v2 MSI installer from this URL in your browser:
https://awscli.amazonaws.com/AWSCLIV2.msi
- Follow the on-screen prompts to complete the installation.
- Close and reopen your terminal, then verify by running:
aws --version
You should see output starting with aws-cli/2.
Still not seeing version 2?
If you see version 1 output after installing version 2, close all terminal windows and reopen a fresh one. Both versions use the same aws command name, and your shell may be caching the old path.
Help me troubleshoot my AWS CLI installation.
Now configure the CLI with your AWS credentials so it can manage resources in your account.
- Run the configure command:
aws configure
What will this command ask?
The aws configure command prompts you for four values:
- AWS Access Key ID and AWS Secret Access Key from your IAM user credentials.
- Default region name: enter us-east-1 (the region this project uses).
- Default output format: press Enter to accept the default (json).
- Enter your AWS Access Key ID when prompted.
- Enter your AWS Secret Access Key when prompted.
- Enter us-east-1 for the default region.
- Press Enter to accept the default output format.
Where do I find my access key?
In the AWS Console, go to IAM and select Users. Click your username, then the Security credentials tab. Under Access keys, click Create access key.
Copy the Access Key ID and Secret Access Key when they appear. You will only see the secret key once, so save it somewhere safe.
Verify Python and Podman
This project uses Python for the API code and tests, and Podman to build container images. Both need to be working before you continue.
- Check your Python version by running:
python --version
✔️ I see a version starting with 3
Python 3 is ready. Move on to verifying Podman below.
ⓧ Command not found or I see Python 2
Try running python3 --version instead. On some systems (especially macOS), the python command points to Python 2 or does not exist.
python3 --version
If you see a version starting with 3, you are good to go. Use python3 instead of python throughout this project.
If you still get an error, download Python from python.org and install it.
- Verify Podman is installed by running:
podman --version
You should see a version number confirming Podman is installed.
- Start the Podman machine (the Linux VM that runs containers) by running:
podman machine start
What is a Podman machine?
Podman uses a lightweight Linux virtual machine under the hood to run containers on macOS and Windows. The podman machine start command boots that VM so container builds and runs work correctly.
If you see a message that the machine is already running, that is perfectly fine.
Podman machine won't start?
Make sure virtualization is enabled in your system BIOS (Windows) or that you have accepted the system extension prompt (macOS). If you have never initialized a machine before, run podman machine init first, then podman machine start.
Help me troubleshoot Podman machine start.
Create a new GitHub repository
Your CI/CD pipeline lives inside a GitHub repository. Making it public gives you unlimited free GitHub Actions minutes.
Why a public repository?
GitHub Actions is completely free for public repositories using standard GitHub-hosted runners. Private repositories on the GitHub Free plan are limited to 2,000 minutes per month. A public repo keeps this project at zero cost.
- Open your browser and go to github.com/new.
- Enter my-lambda-api as the Repository name.
- Select Public for the visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
GitHub shows a Quick setup page with your repository URL. You will use this URL to clone the repo.
- Open your terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running:
git clone [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/my-lambda-api.git"]]
- Move into the cloned folder by running:
cd my-lambda-api
- Open this folder in VS Code by running:
code .
What does code . do?
The code . command opens VS Code with the current folder as your workspace. The dot (.) means "this directory".
- Confirm the folder opened in VS Code by checking that the Explorer sidebar shows my-lambda-api as the workspace name.
code command not found?
If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open search, type VS Code, and press Enter. Then use File > Open Folder and select the my-lambda-api folder on your Desktop.
Help me open my project folder in VS Code.
Your tools are installed and your repository is ready. Next up, you will write the Flask API and tests that your pipeline will deploy.
Build the Flask API and Tests
Your tools are ready. Now it's time to write the code that will eventually run on AWS Lambda.
Lambda Function URLs pass HTTP requests as event objects directly to a handler function. This means you don't need a web framework. You'll write a lightweight handler that reads the incoming path and method from the event, then returns the appropriate response.
In this step, get ready to:
- Create an API handler with a health check and greeting endpoint.
- Write automated tests to verify both endpoints.
- Run the tests locally to confirm everything works.
Create the API handler
Lambda receives every HTTP request as a JSON event object. Your handler function reads the path and method from that event and returns a structured response.
- In VS Code, create a new file called app.py in your repository folder.
- Paste the following code into app.py:
import json
def handler(event, context):
# Extract the request path and HTTP method from the Lambda event
path = event.get("rawPath", "/")
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
# Health check endpoint
if path == "/health" and method == "GET":
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"status": "healthy"}),
}
# Greeting endpoint with optional name parameter
elif path == "/api/greeting" and method == "GET":
name = event.get("queryStringParameters", {}) or {}
name = name.get("name", "World")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"message": f"Hello, {name}!"}),
}
# Return 404 for any unrecognized path
else:
return {
"statusCode": 404,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Not found"}),
}
What does this code do?
The handler function is the entry point Lambda calls for every request. It receives an event dictionary containing the HTTP details and a context object (which you won't need here).
- rawPath holds the URL path the caller requested (like /health).
- The if/elif/else block routes the request to the correct handler logic based on path and method.
- Each branch returns a dictionary with statusCode, headers, and body. Lambda converts this into an HTTP response automatically.
Why no Flask?
Lambda Function URLs handle HTTP routing and pass requests directly to your handler as event objects. You don't need Flask or any web framework. This keeps your container smaller and faster to start.
- Save app.py.
- You should see app.py appear in the VS Code Explorer sidebar on the left.
Don't see the file in your sidebar?
Make sure you opened the correct folder in VS Code. The repository folder you cloned in Step 1 should be the active workspace. If you see a different folder name in the Explorer sidebar, click File then Open Folder and select your cloned repository.
help me open my cloned repository in VS Code
- Create another new file called requirements.txt in the same folder.
- Paste the following into requirements.txt:
pytest==8.3.5
What is this file for?
requirements.txt lists the Python packages your project depends on. Right now the only dependency is pytest for running tests. Later, this file tells your container image which packages to install.
- Save requirements.txt.
Write the test file
Automated tests verify that your handler responds correctly before you containerize it. You'll call the handler function directly with mock event objects and check the responses.
- Create a new file called test_app.py in the same folder.
- Paste the following code into test_app.py:
from app import handler
def test_health_endpoint():
# Simulate a GET request to /health
event = {
"rawPath": "/health",
"requestContext": {"http": {"method": "GET"}},
}
response = handler(event, None)
assert response["statusCode"] == 200
assert "healthy" in response["body"]
def test_greeting_default():
# Simulate a GET request to /api/greeting with no name parameter
event = {
"rawPath": "/api/greeting",
"requestContext": {"http": {"method": "GET"}},
"queryStringParameters": None,
}
response = handler(event, None)
assert response["statusCode"] == 200
assert "Hello, World!" in response["body"]
def test_greeting_with_name():
# Simulate a GET request to /api/greeting?name=DevOps
event = {
"rawPath": "/api/greeting",
"requestContext": {"http": {"method": "GET"}},
"queryStringParameters": {"name": "DevOps"},
}
response = handler(event, None)
assert response["statusCode"] == 200
assert "Hello, DevOps!" in response["body"]
def test_not_found():
# Simulate a GET request to an unknown path
event = {
"rawPath": "/unknown",
"requestContext": {"http": {"method": "GET"}},
}
response = handler(event, None)
assert response["statusCode"] == 404
What does this code do?
Each test function constructs a fake Lambda event dictionary and passes it to your handler function. The assert statements check that the response has the expected status code and body content.
- test_health_endpoint confirms /health returns a 200 with "healthy" in the body.
- test_greeting_default checks that /api/greeting without a name defaults to "Hello, World!".
- test_greeting_with_name verifies the name query parameter is used in the greeting.
- test_not_found confirms unknown paths return a 404.
- Save test_app.py.
- You should now see three files in your Explorer sidebar: app.py, requirements.txt, and test_app.py.
Missing a file?
Double check that all three files are saved in the same folder. They must be at the top level of your repository, not inside a subfolder. If test_app.py can't find app.py, the tests will fail with an import error.
help me organize my project files correctly
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files.
ⓧ I'd like to double check the full code
Here's what each file should contain at this point.
app.py:
import json
def handler(event, context):
path = event.get("rawPath", "/")
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
if path == "/health" and method == "GET":
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"status": "healthy"}),
}
elif path == "/api/greeting" and method == "GET":
name = event.get("queryStringParameters", {}) or {}
name = name.get("name", "World")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"message": f"Hello, {name}!"}),
}
else:
return {
"statusCode": 404,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Not found"}),
}
requirements.txt:
pytest==8.3.5
test_app.py:
from app import handler
def test_health_endpoint():
event = {
"rawPath": "/health",
"requestContext": {"http": {"method": "GET"}},
}
response = handler(event, None)
assert response["statusCode"] == 200
assert "healthy" in response["body"]
def test_greeting_default():
event = {
"rawPath": "/api/greeting",
"requestContext": {"http": {"method": "GET"}},
"queryStringParameters": None,
}
response = handler(event, None)
assert response["statusCode"] == 200
assert "Hello, World!" in response["body"]
def test_greeting_with_name():
event = {
"rawPath": "/api/greeting",
"requestContext": {"http": {"method": "GET"}},
"queryStringParameters": {"name": "DevOps"},
}
response = handler(event, None)
assert response["statusCode"] == 200
assert "Hello, DevOps!" in response["body"]
def test_not_found():
event = {
"rawPath": "/unknown",
"requestContext": {"http": {"method": "GET"}},
}
response = handler(event, None)
assert response["statusCode"] == 404
Run the tests locally
Before containerizing your app, confirm the handler logic is correct by running the tests locally.
- Install the test dependencies by running this command in your terminal:
pip install -r requirements.txt
- Run the tests by executing:
python -m pytest test_app.py -v
You should see all four tests pass with PASSED next to each test name.
What should I see?
The terminal will list each test function followed by PASSED and a progress percentage. At the bottom, you'll see a green summary line confirming all 4 tests passed.
Seeing test failures or import errors?
- If you see ModuleNotFoundError: No module named 'app', make sure your terminal is in the same folder as app.py and test_app.py.
- If you see No module named pytest, run pip install -r requirements.txt again and make sure it completes without errors.
- If a specific test fails, compare your app.py against the double-check tab above. A missing comma or mismatched quote will cause assertion errors.
help me debug my failing pytest tests
Your API logic is tested and working. Next up, you'll package this code into a container image that AWS Lambda can run.
Containerize with Podman
Your API works and passes all four tests. Now you need to package it into a format that can run identically on your machine, in CI, and on AWS Lambda.
Containers solve this problem by bundling your code, dependencies, and runtime into a single portable image. In this step, you will write a Dockerfile, build a container image with Podman, and test it locally to make sure Lambda will run your handler correctly.
In this step, get ready to:
- Write a Dockerfile using the AWS Lambda Python base image.
- Build the container image locally with Podman.
- Test the container to verify your handler responds correctly.
Write the Dockerfile
AWS provides pre-built base images for Lambda that include the Python runtime and a Runtime Interface Emulator for local testing. Your Dockerfile will use this base image and add your application code on top.
- In VS Code, create a new file called Dockerfile in the root of your repository folder (the same level as app.py).
- Add the following content to Dockerfile:
FROM public.ecr.aws/lambda/python:3.12
COPY requirements.txt ${LAMBDA_TASK_ROOT}/
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ${LAMBDA_TASK_ROOT}/
CMD ["app.handler"]
What does this Dockerfile do?
FROM public.ecr.aws/lambda/python:3.12 starts from AWS's official Lambda Python 3.12 image. This image includes the Lambda Runtime Interface Client and the Runtime Interface Emulator for local testing.
COPY requirements.txt and RUN pip install install your dependencies first. Putting this before copying app.py means Docker can cache the dependency layer when only your code changes.
CMD ["app.handler"] tells Lambda which function to invoke when requests arrive. It points to the handler function inside app.py.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile in the output.
Don't see the file?
Make sure you created the file at the root of your repository, not inside a subfolder. The Dockerfile must be in the same directory as app.py.
Also check the filename has no extension. It should be Dockerfile exactly, not Dockerfile.txt.
Help me troubleshoot my Dockerfile creation.
Build the container image
With the Dockerfile ready, you can build a container image that packages your API into a deployable unit.
- Build the container image by running this command:
podman build --format docker -t my-lambda-api .
Why use --format docker?
Podman defaults to OCI image format, which can cause issues when pushing to Amazon ECR. The --format docker flag ensures the image uses Docker manifest format, which AWS services expect.
The -t my-lambda-api flag tags your image with the name my-lambda-api so you can reference it easily later.
The build will take a minute or two the first time as Podman downloads the Lambda base image. You should see output ending with a line that confirms the image was successfully tagged.
- Verify the image was created by running:
podman images my-lambda-api
You should see one row showing my-lambda-api with the latest tag.
Build failed or image not listed?
If you see a connection error, make sure your Podman machine is running. Start it with podman machine start.
If the build fails on the pip install step, confirm your requirements.txt file is in the same directory as your Dockerfile.
Help me fix my Podman build.
Test the container locally
Before pushing to AWS, you should verify the container actually runs your handler correctly. The Lambda base image includes a Runtime Interface Emulator that simulates how Lambda invokes your function.
- Start the container by running this command:
podman run -p 9000:8080 my-lambda-api
What does this command do?
The -p 9000:8080 flag maps port 9000 on your machine to port 8080 inside the container (where the Lambda emulator listens). This creates a local endpoint you can send test requests to.
The terminal will appear to hang. This is normal. The container is running and waiting for requests.
- Open a second terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Send a test request to the health endpoint by running:
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"rawPath": "/health", "requestContext": {"http": {"method": "GET"}}}'
What is this URL?
The Lambda Runtime Interface Emulator exposes a local endpoint at localhost:9000/2015-03-31/functions/function/invocations. You POST a JSON event to this endpoint, and it passes the event to your handler function just like the real Lambda service would.
The JSON body mimics what Lambda receives when someone hits your Function URL. It includes the path and HTTP method your handler reads.
You should see a JSON response like this:
{"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "{\"status\": \"healthy\"}"}
- Test the greeting endpoint by running:
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"rawPath": "/api/greeting", "requestContext": {"http": {"method": "GET"}}, "queryStringParameters": {"name": "DevOps"}}'
You should see a response containing Hello, DevOps! in the body.
- Return to the first terminal window where the container is running.
- Press Ctrl+C to stop the container.
Getting "connection refused" from curl?
Make sure the container is still running in your first terminal. If it exited, restart it with podman run -p 9000:8080 my-lambda-api.
On Windows, if curl is not recognized, use PowerShell's Invoke-WebRequest or install curl from the official website.
Help me fix my local container test.
✔️ Awesome, I've got everything!
Great. Double check you have saved your Dockerfile and that your container responded correctly to both test requests.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM public.ecr.aws/lambda/python:3.12
COPY requirements.txt ${LAMBDA_TASK_ROOT}/
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ${LAMBDA_TASK_ROOT}/
CMD ["app.handler"]
Your containerized API is working locally. Next up, you will push this image to AWS and set up the infrastructure to run it as a live Lambda function with a public URL.
Set Up AWS Infrastructure
Your container runs perfectly on your local machine. Now you need somewhere to run it in the cloud.
In this step, you will create the AWS resources your pipeline needs: an ECR repository to store your container images, a Lambda function to run them, and an IAM role that lets GitHub Actions deploy without storing any long-lived credentials.
In this step, get ready to:
- Create an ECR repository and push your container image.
- Create a Lambda function with a public Function URL.
- Set up OIDC authentication so GitHub Actions can deploy securely.
Create an ECR repository
Amazon ECR is a container registry that stores your Docker images. Your CI/CD pipeline will push new images here on every deploy, and Lambda will pull from it to run your function.
- Create the ECR repository by running this command:
aws ecr create-repository --repository-name my-lambda-api --region us-east-1
What does this command do?
This creates a private container image repository called my-lambda-api in the us-east-1 region. You will push your locally built container image here, and Lambda will pull from it to create your function.
- You should see a JSON response containing your repository URI. It will look like 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-lambda-api (with your actual AWS account ID).
- Note your AWS Account ID from the output. You will need it in the next commands.
Getting an error?
If you see a "repository already exists" error, you already created it. Continue to the next command.
If you see an access denied error, confirm your AWS CLI is configured with the correct credentials by running aws sts get-caller-identity.
Help me troubleshoot my ECR repository creation error.
Push your image and create the Lambda function
Before Lambda can run your container, you need to push your local image to ECR and create the function pointing to it. You also need a Lambda execution role that gives the function permission to write logs.
- Authenticate Podman with ECR by running this command:
aws ecr get-login-password --region us-east-1 | podman login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com
- You should see Login Succeeded in the output.
- Tag your local image for ECR by running:
podman tag my-lambda-api:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/my-lambda-api:latest
- Push the image to ECR by running:
podman push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/my-lambda-api:latest
What are these commands doing?
First you authenticate Podman with your ECR registry (the token expires after 12 hours). Then you tag your local image with the full ECR URI so Podman knows where to push it. Finally, you push the tagged image to ECR.
- The push may take a minute depending on your internet speed. You should see progress bars for each layer.
Now create a Lambda execution role. This role gives the function permission to write logs to CloudWatch.
- Create the execution role by running:
aws iam create-role \
--role-name lambda-api-execution-role \
--assume-role-policy-document '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
- Attach the basic Lambda logging policy by running:
aws iam attach-role-policy \
--role-name lambda-api-execution-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Why two commands for the role?
The first command creates the role and defines who can assume it (Lambda). The second command attaches a permissions policy that tells AWS what the role is allowed to do (write logs to CloudWatch).
- Wait about 10 seconds for the role to propagate. IAM changes take a few seconds to become available across AWS.
- Create the Lambda function by running:
aws lambda create-function \
--function-name my-lambda-api \
--package-type Image \
--code ImageUri=[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/my-lambda-api:latest \
--role arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:role/lambda-api-execution-role \
--region us-east-1
- Add a public Function URL so you can hit your API from the browser:
aws lambda create-function-url-config \
--function-name my-lambda-api \
--auth-type NONE
- Grant public access to the Function URL by running:
aws lambda add-permission \
--function-name my-lambda-api \
--statement-id FunctionURLAllowPublicAccess \
--action lambda:InvokeFunctionUrl \
--principal "*" \
--function-url-auth-type NONE
What is a Function URL?
A Lambda Function URL gives your function a dedicated HTTPS endpoint without needing API Gateway. The URL format is https://<url-id>.lambda-url.<region>.on.aws. Setting auth type to NONE makes it publicly accessible.
- Copy the FunctionUrl from the output of the create-function-url-config command.
- Test your live endpoint by running:
curl [[LAMBDA_URL="your Lambda Function URL"]]/health
- You should see {"status": "healthy"} in the response.
Getting a 403 or no response?
If you get a 403 Forbidden, make sure you ran the add-permission command. Lambda Function URLs require an explicit resource-based policy for public access.
If the function is still in "Pending" state, wait 30 seconds and try again. Lambda needs time to optimize the container image.
Help me debug my Lambda Function URL error.
Set up GitHub OIDC for secure deployments
Instead of storing AWS access keys in GitHub Secrets, you will use OIDC (OpenID Connect) to let GitHub Actions assume an IAM role directly. This is more secure because there are no long-lived credentials to leak.
Why OIDC instead of access keys?
With OIDC, GitHub Actions gets short-lived temporary credentials every time a workflow runs. There is nothing stored in your repository that could be compromised. AWS verifies the request came from your specific repo before granting access.
- Create the IAM OIDC Identity Provider by running:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
What does this command do?
This registers GitHub Actions as a trusted identity provider in your AWS account. The URL https://token.actions.githubusercontent.com is GitHub's OIDC endpoint, and the audience sts.amazonaws.com tells AWS to accept tokens meant for STS (Security Token Service).
Next, create an IAM role that GitHub Actions will assume. This role needs a trust policy scoped to your specific repository.
- Create a file called trust-policy.json in your project folder with the following content:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:[[GITHUB_USERNAME=\"your-github-username\"]]/[[GITHUB_REPO=\"your-repo-name\"]]:ref:refs/heads/main"
}
}
}
]
}
What is this trust policy doing?
The Federated principal tells AWS to trust tokens from your GitHub OIDC provider. The Condition block restricts access to only your specific repository and the main branch. Nobody else's repo can assume this role.
- Create the IAM role by running:
aws iam create-role \
--role-name github-actions-lambda-deploy \
--assume-role-policy-document file://trust-policy.json
Now attach a permissions policy that allows this role to push images to ECR and update the Lambda function.
- Create a file called permissions-policy.json with the following content:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:us-east-1:[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:repository/my-lambda-api"
},
{
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode"
],
"Resource": "arn:aws:lambda:us-east-1:[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:function:my-lambda-api"
}
]
}
- Attach the permissions policy to the role by running:
aws iam put-role-policy \
--role-name github-actions-lambda-deploy \
--policy-name github-actions-deploy-policy \
--policy-document file://permissions-policy.json
What permissions did you grant?
The policy allows three things: getting an ECR login token, pushing and pulling images to your specific ECR repository, and updating your Lambda function's code. Each permission is scoped to only the resources the pipeline needs.
- Verify the role was created successfully by running:
aws iam get-role --role-name github-actions-lambda-deploy --query "Role.Arn" --output text
- You should see an ARN like arn:aws:iam::123456789012:role/github-actions-lambda-deploy.
Finally, you need to add your AWS Account ID as a GitHub Secret so the workflow can reference it.
- Open your GitHub repository in a browser.
- Click Settings in the top navigation bar.
- Click Secrets and variables in the left sidebar, then click Actions.
- Click New repository secret.
- Enter AWS_ACCOUNT_ID in the Name field.
- Enter your 12-digit AWS Account ID in the Secret field.
- Click Add secret.
Not sure where to find your account ID?
Run aws sts get-caller-identity --query "Account" --output text in your terminal. This prints your 12-digit account ID.
Help me troubleshoot my IAM OIDC setup.
Your AWS infrastructure is fully set up. You have a container registry, a live Lambda function, and a secure authentication path for GitHub Actions. Next up, you will wire everything together with a GitHub Actions workflow that automates the entire deploy.
Create the GitHub Actions Workflow
Your AWS infrastructure is live and ready to receive deployments. Now it is time to connect the dots with automation.
In this step, you will create a GitHub Actions workflow that triggers on every push to your main branch. The workflow will run your tests, build a container image, push it to ECR, and update your Lambda function. No manual steps required.
In this step, get ready to:
- Create the workflow file with a test job that runs pytest.
- Add a deploy job that authenticates with OIDC and pushes your container to ECR.
- Commit, push, and verify the full pipeline runs successfully.
Create the workflow file with the test job
GitHub Actions workflows live in a special directory at the root of your repository. The workflow file defines the trigger, permissions, and jobs that run on each push.
- Create the workflow directory by running these commands in your terminal:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github/workflows
You should see an empty directory listed with no errors.
- Create a new file called deploy.yml inside .github/workflows/ and open it in VS Code.
- Add the workflow configuration and the test job by pasting the following code:
name: Deploy to AWS Lambda
on:
push:
branches: ["main"]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-lambda-api
LAMBDA_FUNCTION_NAME: my-lambda-api
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
run: python -m pytest test_app.py -v
What does this configuration do?
The on: push: branches: ["main"] trigger fires this workflow every time you push commits to the main branch.
The permissions block grants id-token: write so GitHub can generate an OIDC token for AWS authentication. The env block defines reusable variables so you only specify region, repository name, and function name once.
The test job checks out your code, installs Python 3.12, installs pytest, and runs your test suite. If any test fails, the pipeline stops and the deploy job never runs.
- Save deploy.yml.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure you use spaces (not tabs) for indentation. Each nested level uses exactly 2 spaces. If VS Code shows a squiggly underline, check that your indentation is consistent.
help me fix YAML indentation in my GitHub Actions workflow
Add the build-and-deploy job
The deploy job handles the heavy lifting. It authenticates to AWS using OIDC (no stored credentials), logs into ECR, builds your container image, pushes it, and updates the Lambda function to use the new image.
- In deploy.yml, add the following deploy job directly below the test job (at the same indentation level as test:):
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6.1.0
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-lambda-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
run: |
aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
- name: Build and push container image
run: |
docker build --format docker -t ${{ env.ECR_REPOSITORY }} .
docker tag ${{ env.ECR_REPOSITORY }}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest
- name: Update Lambda function
run: |
aws lambda update-function-code \
--function-name ${{ env.LAMBDA_FUNCTION_NAME }} \
--image-uri ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest
What does the deploy job do?
The needs: test line ensures the deploy job only runs after all tests pass. This is the gate that prevents broken code from reaching production.
The aws-actions/configure-aws-credentials@v6.1.0 action exchanges GitHub's OIDC token for temporary AWS credentials using your github-actions-lambda-deploy role. No long-lived access keys are stored anywhere.
The remaining steps log into ECR, build the Docker image with --format docker for compatibility, tag and push it, then tell Lambda to use the new image.
- Save deploy.yml.
Before this workflow can run, it needs your AWS account ID available as a GitHub secret.
- Open your repository on GitHub in your browser.
- Click the Settings tab.
- In the Security section of the left sidebar, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, enter AWS_ACCOUNT_ID.
- In the Secret field, paste your 12-digit AWS account ID.
- Click Add secret.
Why use a GitHub secret for the account ID?
The workflow references ${{ secrets.AWS_ACCOUNT_ID }} to construct ARNs and ECR URLs. Storing the account ID as a secret keeps it out of your source code and makes the workflow portable across different AWS accounts.
Don't see the Settings tab?
Only repository owners and admins can access settings. Make sure you are signed in to the GitHub account that owns the repository. If you still do not see it, check that you are on the repository page (not your profile page).
help me find the Settings tab in my GitHub repository
✔️ Awesome, I've got everything!
Great. Double check you have saved deploy.yml and added the AWS_ACCOUNT_ID secret in GitHub.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/deploy.yml file:
name: Deploy to AWS Lambda
on:
push:
branches: ["main"]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-lambda-api
LAMBDA_FUNCTION_NAME: my-lambda-api
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
run: python -m pytest test_app.py -v
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6.1.0
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-lambda-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
run: |
aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
- name: Build and push container image
run: |
docker build --format docker -t ${{ env.ECR_REPOSITORY }} .
docker tag ${{ env.ECR_REPOSITORY }}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest
- name: Update Lambda function
run: |
aws lambda update-function-code \
--function-name ${{ env.LAMBDA_FUNCTION_NAME }} \
--image-uri ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest
Commit, push, and verify the pipeline
Pushing to the main branch will trigger the workflow automatically. This is the moment your CI/CD pipeline comes to life.
- Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Add CI/CD workflow"
git push origin main
What does this trigger?
The moment your push hits the main branch on GitHub, the Deploy to AWS Lambda workflow fires. It runs the test job first, and if all tests pass, the deploy job takes over.
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top.
- Click on the latest workflow run (it will be named Add CI/CD workflow from your commit message).
- Watch both the test and deploy jobs complete with green checkmarks.
Pipeline failed?
Click on the failed job to see the error logs. Common issues include:
- Trust policy mismatch: make sure the IAM role's trust policy matches your exact repository name and branch (repo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main).
- Missing secret: verify AWS_ACCOUNT_ID is added under your repository's Settings > Secrets and variables > Actions.
- Permission error: confirm the IAM role has policies for ECR push and Lambda update.
help me debug my GitHub Actions workflow failure
Once the pipeline is green, verify your Lambda function is serving the newly deployed code.
- Test your Lambda Function URL by running this command (replace the URL with your own Function URL from the previous step):
curl [[LAMBDA_URL="https://your-url-id.lambda-url.us-east-1.on.aws"]]/health
You should see the response:
{"status": "healthy"}
- Also test the greeting endpoint:
curl [[LAMBDA_URL="https://your-url-id.lambda-url.us-east-1.on.aws"]]/api/greeting?name=DevOps
You should see:
{"message": "Hello, DevOps!"}
Getting a 404 or timeout?
Lambda can take a few seconds to update after the workflow finishes. Wait 30 seconds and try again. If you still get errors, check that your Function URL is enabled by running:
aws lambda get-function-url-config --function-name my-lambda-api --region us-east-1
help me troubleshoot my Lambda Function URL not responding
Secret mission
Multi-Stage Build and Environment Protection
Your pipeline deploys straight to production on every push. In a real team, that would be terrifying. In this secret mission, you will add a multi-stage Dockerfile for smaller, more secure images and implement a staging/production deployment strategy with manual approval gates.
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. Your Lambda functions cost nothing when idle, and the ECR image uses minimal storage within the free tier.
Resources you used:
- Lambda function my-lambda-api (production) in us-east-1.
- Lambda function my-lambda-api-staging in us-east-1.
- ECR repository my-lambda-api in us-east-1.
- IAM role github-actions-lambda-deploy.
- IAM OIDC identity provider for token.actions.githubusercontent.com.
- GitHub repository (public) with Actions workflow.
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your pipeline ready for future pushes.
- Your Lambda functions cost nothing when idle. They only incur charges if you exceed 1 million requests or 400,000 GB-seconds per month (Always Free tier).
- Your ECR repository has 500 MB of free private storage for the first year. A single Python Lambda image is well under this.
- The IAM role and OIDC provider are free.
- GitHub Actions is free for public repositories.
Pause - I'll come back to this later
Since Lambda is serverless and costs nothing at rest, there is no meaningful difference between Keep and Pause for this project. Your resources sit idle until you push again.
- No running processes to stop.
- No containers to shut down.
- Everything remains ready for your next push.
Delete - I don't want to use this again
Remove all project resources and start fresh. Run the following commands in your terminal.
Delete both Lambda functions by running:
aws lambda delete-function --function-name my-lambda-api --region us-east-1
aws lambda delete-function --function-name my-lambda-api-staging --region us-east-1
Delete the ECR repository and all its images by running:
aws ecr delete-repository --repository-name my-lambda-api --region us-east-1 --force
Delete the IAM role. You must detach policies first, then delete inline policies, then delete the role itself. Run these commands:
aws iam detach-role-policy --role-name github-actions-lambda-deploy --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
aws iam delete-role-policy --role-name github-actions-lambda-deploy --policy-name LambdaDeployPolicy
aws iam delete-role --role-name github-actions-lambda-deploy
What if my policy names are different?
The exact policy names depend on how you set up the role in Step 4. To see what is attached, run:
aws iam list-attached-role-policies --role-name github-actions-lambda-deploy
aws iam list-role-policies --role-name github-actions-lambda-deploy
Use the ARNs and names from those outputs in the detach and delete commands above.
Delete the OIDC identity provider. First find its ARN, then delete it:
aws iam list-open-id-connect-providers
Look for the ARN containing token.actions.githubusercontent.com, then run:
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com
Delete your GitHub repository by going to your repository on GitHub, clicking Settings, scrolling to the bottom of the General page, and clicking Delete this repository.
Delete your local project files by running:
macOS
cd ~/Desktop
rm -rf my-lambda-api
Windows
cd ~\Desktop
rmdir /s /q my-lambda-api
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade CI/CD pipeline from scratch.
Every time you push code to your repository, your pipeline automatically tests it, builds a container image, pushes it to a registry, and deploys it live. That is the core DevOps workflow that modern engineering teams rely on to ship code reliably.
You've learned how to:
- Built and tested a Python API containerized with Podman using AWS Lambda base images, and pushed it to Amazon ECR.
- Deployed a containerized function to Lambda with a public Function URL accessible from anywhere via HTTPS.
- Automated the full build-test-deploy cycle with GitHub Actions and OIDC authentication for secure, keyless AWS access.
- Secret Mission: Added multi-stage Docker builds and environment protection rules for production-safe deployments with manual approval gates.
Ready to quiz yourself?