DEV Community

Cover image for Automating Serverless: A Guide to CI/CD for AWS Lambda with GitHub Actions
Breindel Medina
Breindel Medina

Posted on

Automating Serverless: A Guide to CI/CD for AWS Lambda with GitHub Actions

Deploying serverless applications to the cloud has never been easier with AWS. You just upload your files as a zip to Lambda, turn on the function URL or integrate with an API Gateway, and voila! you are able to deploy your serverless app.

But, this manual process has risks. You still have to manually check if the code is right. It might be that what you deploy is faulty, which causes your application to break.

Since most developers upload their code to GitHub, what if there was a way to automate all of this checking and deployment with a single push to your repository?

This is where GitHub Actions comes in.

What is GitHub Actions?

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform built directly into GitHub. It allows you to automate your software development workflows right from where your code lives.

At its core, GitHub Actions uses the following concepts:

Workflows: These are automated processes defined in a YAML file (e.g., .github/workflows/deploy.yml). A workflow can be triggered by an event.

Events: An event is a specific activity in your repository that triggers a workflow. The most common event is a push to a branch, but it can also be a pull_request, a new issue, or even a scheduled cron job.

Jobs: A workflow is made up of one or more jobs. By default, jobs run in parallel. You can also configure them to run sequentially (e.g., a deploy job that runs only if a test job succeeds).

Runners: A runner is a server (a fresh virtual machine) that runs your workflow's jobs. GitHub provides runners for Linux, Windows, and macOS.

Steps: A job is a series of steps. A step can be a simple shell command (like pip install -r requirements.txt) or a pre-built, reusable command called an Action (like actions/checkout to check out your code).

By combining these, you can create a workflow that, upon a push to your main branch, automatically:

  • Sets up a clean environment.
  • Installs your dependencies.
  • Runs your unit tests.
  • If the tests pass, it securely deploys your code to AWS Lambda.

Demonstration

Let's first start with a very simple code snippet which we will use in this demonstration

import json

def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "Hello dev.to article!",
        })
    }
Enter fullscreen mode Exit fullscreen mode

Code Breakdown (app.py):

def lambda_handler(event, context)

  • This is the main function that AWS Lambda will call. event contains data about the trigger (e.g., from an API call), and context contains runtime information.

return { ... }

  • The function returns a dictionary. For a Lambda function responding to an HTTP request (like from a Function URL or API Gateway), it must include statusCode (like 200 for "OK") and a body (which must be a string, hence json.dumps).

Prerequisite 2: Your Test File

Let's now create our test file which our CI will use

test_app.py

import app
import json

def test_simple_lambda_handler():
    # Call the handler
    event = {}
    context = {}
    response = app.lambda_handler(event, context)

    # Check the response
    assert response['statusCode'] == 200
    body = json.loads(response['body'])
    assert "Hello dev.to article!" in body['message']

# We run the test function directly
test_simple_lambda_handler()

print("All tests passed!")
Enter fullscreen mode Exit fullscreen mode

Code Breakdown (test_app.py):

import app

  • This line imports our app.py file so we can access its lambda_handler function.

response = app.lambda_handler(event, context)

  • We call our Lambda handler directly, passing in empty event and context objects (since our simple function doesn't use them).

assert response['statusCode'] == 200

  • This is the core of the test. assert checks if a statement is true. If it's false, the test fails and stops. Here, we check if the statusCode is 200.

assert "..." in body['message']

  • We check if the response message contains the text we expect.

test_simple_lambda_handler()

  • We call the test function to run it.

print("All tests passed!")

  • If all the assert statements pass, this line will run, letting us know our code works as expected.

Upload Your Code to GitHub

Before we can set up GitHub Actions, our code needs to live in a GitHub repository. We'll assume you've already:

  1. Created a new repository on GitHub.

  2. Committed your files (app.py, test_app.py, requirements.txt).

  3. Pushed your code to the main branch.

Create AWS IAM User & Get Keys

Now, let's get the AWS credentials GitHub Actions will use. We'll assume you're familiar with the AWS IAM console.

You'll need to create a new IAM User with CLI access. Give it a descriptive name like github-lambda-deployer.

For permissions, attach the AWSLambda_FullAccess policy.

(Note: For a real-world project, it's best practice to create a more restrictive custom policy that only allows the lambda:UpdateFunctionCode and lambda:UpdateFunctionConfiguration actions on your specific function's ARN.)

After creating the user, generate an Access key and Secret access key. Copy these immediately; you will need them for the next step.

Add Access Keys to GitHub Secrets

Now let's securely store those keys in GitHub.

  1. In your GitHub repository, go to Settings.

  1. In the left sidebar, navigate to Secrets and variables > Actions.

  1. Click the New repository secret button.

Create two secrets, one for the Access Key, and one for the Secret Access Key:

  • Click Add secret.

Create the second secret:

  • Click Add secret.

You should now have two secrets, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, listed under "Repository secrets." GitHub Actions can now access them using the ${{ secrets.NAME }} syntax.

Create Lambda Function

Let's now create the AWS Lambda function that we will use.

  1. Create a function with name "blog-func" and Python 3.13 runtime

  2. After it is done creating, Edit the handler from lambda_handler.lambda_handler to app.lamba_handler

Create the GitHub Actions Workflow (CI/CD)

This is the heart of our pipeline. In your repository, create a new directory .github, and inside that, another directory workflows. Finally, create the file deploy.yml inside it. Replace the placeholders in this file before you commit.

aws-region

  • Set this to the region where your Lambda function exists (e.g., us-west-2, ap-southeast-1).

--function-name

  • Change YOUR-LAMBDA-FUNCTION-NAME to the exact name of your function in AWS.

File Path: .github/workflows/deploy.yml

# Name of your workflow
name: CI-CD for AWS Lambda

# When this workflow is triggered
on:
  push:
    branches:
      - main  # Run on pushes to the main branch
  pull_request:
    branches:
      - main  # Run on pull requests targeting main

# A workflow run is made up of one or more jobs
jobs:
  # The "test" job (Continuous Integration)
  test:
    name: Run Unit Tests
    runs-on: ubuntu-latest # Use a Linux runner

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4 # Action to check out your code

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11' # Use a specific Python version

      - name: Install test dependencies
        run: |
          # No dependencies needed for this simple handler
          echo "No test dependencies to install."

      - name: Run tests (CI)
        run: |
          python test_app.py

  # The "deploy" job (Continuous Deployment)
  deploy:
    name: Deploy to AWS Lambda
    runs-on: ubuntu-latest
    needs: test # This job will ONLY run if the "test" job succeeds

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Create Zip File
        run: |
          zip lambda_function.zip app.py

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          # Use the secrets you created in Step 3
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-southeast-1 # Change to your Lambda's region

      - name: Deploy to AWS Lambda (CD)
        run: |
          aws lambda update-function-code \
            --function-name blog-func \ # Change to Lambda Name
            --zip-file fileb://lambda_function.zip
Enter fullscreen mode Exit fullscreen mode

Workflow Breakdown

Let's break down that YAML file.

The Triggers:

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
Enter fullscreen mode Exit fullscreen mode

This tells GitHub when to run the workflow. It will run on any push to the main branch, and also on any pull_request that targets the main branch. This is great for checking if a new change breaks the tests before you merge it.

Job 1: test (The "CI" part)

This job is our Continuous Integration check.

name: Run Unit Tests

  • The display name in the Actions tab.

runs-on: ubuntu-latest

  • It runs on a fresh Ubuntu virtual machine.

uses: actions/checkout@v4

  • This action checks out your repository code onto the runner.

uses: actions/setup-python@v5

  • This action sets up the Python version we want.

run: python test_app.py

  • This is the key CI step. It runs our test file. If test_app.py fails (e.g., an assert is false), it will exit with an error, which fails the job and stops the entire pipeline.

Job 2: deploy (The "CD" part)

This job is our Continuous Deployment step.

needs: test

  • This is the most important line for safety. It tells GitHub: Do not even start this job unless the test job finished successfully. This is what prevents you from deploying broken code.

run: zip lambda_function.zip app.py

  • This bundles our app.py into the .zip file that Lambda requires.

uses: aws-actions/configure-aws-credentials@v4

  • This is an official action from AWS. It uses the aws-access-key-id and aws-secret-access-key you provide to configure the AWS CLI on the runner.

with: ... ${{ secrets.AWS_ACCESS_KEY_ID }}

  • This is how you securely pass your GitHub Secrets to the action. They are injected at runtime and never printed in the logs.

run: aws lambda update-function-code ...

  • This is the final AWS CLI command that actually performs the deployment, uploading your new lambda_function.zip to the function you specified.

Commit and Push

Commit all your new files (specifically .github/workflows/deploy.yml) and push them to your main branch. Go to the "Actions" tab in your GitHub repository.

  • You will see your "CI-CD for AWS Lambda" workflow start.

It will first run the test job. You can click to see the output, including "All tests passed!".

Once the test job succeeds, the deploy job will begin. You will see it zip the code, configure credentials, and run the aws lambda update-function-code command.

Check AWS: Go to your Lambda function in the AWS Console. You should see that the "Last modified" time has just updated. If you test your function in the console, it will now be running the code from your repository!

You can also see the result by turning on the Function URL and opening it!

Conclusion and Next Steps

You now have a robust and automated CI/CD pipeline. Any time you push a change to your main branch, your code will be automatically tested, and if the tests pass, it will be securely deployed to AWS Lambda. This directly solves the problem of "manially checking" and deploying "faulty" code.

From here, you can expand this pipeline to:

  • Use different branches for staging and production environments.

  • Store your Lambda function name as a GitHub Secret instead of hard-coding it.

  • Add a build step that installs requirements.txt into a folder before zipping.

  • Run more complex integration tests that actually invoke the Lambda URL.


That is it for this demonstration! Thank you for reading this informative blog, you can check out the code for this in my github

Top comments (0)