Why CI/CD for Serverless?
When I first started deploying Lambda functions, I’d run cdk deploy from my laptop. It worked. But it also meant deployments happened whenever I remembered to run them, from whatever branch I happened to be on, with whatever environment variables I had locally.
If you’ve used Vercel or Netlify, you already know the better way: push to main, and your code deploys automatically. GitHub Actions gives you that same workflow for AWS infrastructure.
What We’re Setting Up
By the end of this article, you’ll have a GitHub Actions workflow that:
- Runs on every push to
main - Installs Python and uv
- Installs your project dependencies
- Runs your tests
- Deploys your Lambda functions with CDK
No manual steps. No laptop deploys.
OIDC vs Access Keys - Authenticating GitHub with AWS
The first question is: how does GitHub Actions talk to your AWS account?
The old way is to create an IAM user, generate access keys, and store them as GitHub secrets. It works, but those keys are long-lived. If they leak, someone has access to your AWS account until you rotate them.
The modern way is OIDC (OpenID Connect). Instead of static keys, GitHub requests a short-lived token from AWS each time the workflow runs. No keys to leak. No secrets to rotate.
Here’s how it works:
- You create an IAM role in AWS that trusts GitHub’s OIDC provider
- Your workflow assumes that role at runtime
- AWS gives it temporary credentials that expire after the job
Creating the IAM Role with CDK
Since we’re already using CDK, let’s define the OIDC provider and IAM role in code. Add this to your CDK stack:
from aws_cdk import Stack, aws_iam as iam
from constructs import Construct
class CiCdStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs):
super().__init__(scope, id, **kwargs)
# Create the OIDC provider for GitHub
provider = iam.OpenIdConnectProvider(
self, "GitHubOidc",
url="https://token.actions.githubusercontent.com",
client_ids=["sts.amazonaws.com"],
)
# Create the role GitHub Actions will assume
role = iam.Role(
self, "GitHubActionsRole",
assumed_by=iam.WebIdentityPrincipal(
provider.open_id_connect_provider_arn,
conditions={
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-username/your-repo:ref:refs/heads/main",
},
},
),
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name("AdministratorAccess"),
],
)
Replace your-username/your-repo with your actual GitHub repo. The StringLike condition ensures only pushes to main in that repo can assume this role.
Note:
AdministratorAccessis broad. For production, scope this down to only the permissions CDK needs.
Deploy this stack once from your laptop:
cdk deploy CiCdStack
Copy the role ARN from the output, you’ll need it in the workflow file.
Your First Workflow File
Create .github/workflows/deploy.yml in your repo:
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Install dependencies
run: uv sync
- name: Run tests
run: uv run pytest
- name: Install CDK CLI
run: npm install -g aws-cdk
- name: Deploy
run: uv run cdk deploy --all --require-approval never
Replace the role ARN with the one from your CDK stack output.
Triggering on Push to Main
The on block controls when the workflow runs:
on:
push:
branches: [main]
This means: every time code is pushed to main, deploy. If you use pull requests, code only reaches main after review, so this is your gate.
You can also add a path filter if you only want to deploy when specific files change:
on:
push:
branches: [main]
paths:
- "lambdas/**"
- "cdk/**"
- "pyproject.toml"
Installing Python and uv in the Workflow
Two steps handle the Python setup:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v3
The astral-sh/setup-uv action installs uv and makes it available for the rest of the job. Then uv sync installs everything from your lockfile, just like npm ci in a Node project.
Running Tests Before Deploying
Never deploy without testing first. Add a test step before the deploy:
- name: Run tests
run: uv run pytest
If any test fails, the workflow stops. CDK never runs. Your production stays safe.
If you want to run linting too:
- name: Lint
run: uv run ruff check .
- name: Run tests
run: uv run pytest
Deploying with CDK
The deploy step is straightforward:
- name: Deploy
run: uv run cdk deploy --all --require-approval never
--require-approval never skips the interactive confirmation prompt. In CI, there’s no one to type “y”, so you need this flag. The security gate is your PR review process, not a terminal prompt.
--all deploys every stack in your CDK app. If you have multiple stacks and want to deploy a specific one:
run: uv run cdk deploy MyLambdaStack --require-approval never
Caching Dependencies for Faster Builds
Installing dependencies on every run is slow. Add caching to speed things up:
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync
The enable-cache option on the uv action caches the uv package cache between runs. If your lockfile hasn’t changed, dependencies restore from cache instead of downloading again.
This can cut minutes off your workflow, especially when you have heavy dependencies like pandas or boto3.
Deploying to Staging vs Production
As your project grows, you’ll want separate environments. One approach: use branch-based deployments.
name: Deploy
on:
push:
branches: [main, staging]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set environment
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "ENV=production" >> $GITHUB_ENV
else
echo "ENV=staging" >> $GITHUB_ENV
fi
- name: Deploy
run: uv run cdk deploy --all --require-approval never --context env=${{ env.ENV }}
Then in your CDK code, read the context value to configure different settings per environment, different Lambda memory, different DynamoDB table names, whatever you need.
What Happens When a Deploy Fails
CDK uses CloudFormation under the hood, and CloudFormation has built-in rollback. If a deployment fails halfway through, it automatically rolls back to the previous working state.
In your GitHub Actions workflow, a failed deploy means:
- CloudFormation rolls back the stack
- The workflow step exits with a non-zero code
- GitHub marks the job as failed
- You get a notification (if you’ve set that up)
To debug, check two places:
- GitHub Actions logs - shows the CDK output and error messages
- CloudFormation console - shows exactly which resource failed and why
One gotcha: if your Lambda code deploys successfully but has a runtime bug, CloudFormation won’t catch that. It only checks that resources were created, not that your code works. That’s what your test step is for.
The Takeaway
Setting up CI/CD for Lambda functions isn’t much different from what you’d do for a Vercel or Netlify project, push to main, let the pipeline handle the rest. OIDC keeps the auth secure, uv keeps the installs fast, and CDK gives you repeatable deployments. Once this is in place, you’ll never want to go back to deploying from your laptop.