cd /news/ai-agents/running-a-serverless-ai-code-review-… · home › topics › ai-agents › article
[ARTICLE · art-140471] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Running a serverless AI code review agent on AWS Lambda with PR-Agent and CDK

Naor Peled, lead maintainer of the open-source PR-Agent project, published a guide and companion CDK repository (pr-agent-lambda-cdk) for self-hosting an AI code review agent on AWS Lambda. The setup runs PR-Agent's GitHub Lambda webhook handler behind FastAPI and Mangum, letting the agent update pull request descriptions, post code suggestions, and respond to slash commands such as /review, /improve and /ask. Configuration is handled through environment variables (e.g. GITHUB__WEBHOOK_SECRET) since Lambda has no mounted config file, and model calls route through LiteLLM so providers like OpenAI, Bedrock or local models are a config change away.

by read15 min views3 publishedSep 27, 2026

Code reviews are an essential part of our day to day work.

Their importance and way of execution have changed a bit in the GenAI era.

In this post, I'll show you how you can self host your own code review agent.

PR-Agent is an open-source project that allows you to perform code reviews using a customizable AI agent in a self-hosted way.

It works with all common AI model providers and local models.

The flow is the following:

You open a pull request, and PR-Agent updates the description and posts a set of code suggestions. You can also talk to it in the PR with slash commands like /review, /improve or /ask what does this migration do?.

The project initially started at Qodo, which works on an enterprise code review solution, and was donated to the community last year. It now lives in its own GitHub org and is maintained by the community.

In this post, we'll explore how to deploy it using AWS Lambda.

If you prefer to just clone and run it, everything in this post is also in pr-agent-lambda-cdk.

Hi, I'm Naor, a software engineer for the past 7 years professionally and an open source contributor/maintainer for about 6 years. I am also an AWS Community Builder in the DevTools category and love sharing insights about modern developer workflows and cloud tooling.

I'm the lead maintainer of PR-Agent and I also maintain projects like TypeORM and a few others you can find on my GitHub page. I currently work for groundcover, where we're building a BYOC, eBPF powered observability solution.

PR-Agent is a Python project that has two modes:

You can override the default configurations by creating a configuration file.

The configuration file needs to be named .pr_agent.toml and can sit in two locations:

pr-agent-settings that sits under your git provider's org. If you work on a monorepo, you can also add a .pr_agent.toml inside sub-directories, for example one per service. PR-Agent applies the ones on the path of the files changed in the PR, and the closest one wins. These files can change things like the model, the ignored files and the settings of each tool, while some settings can only be set in the root file. It's off by default, to turn it on set enable_per_directory_settings = true under [config] in your root .pr_agent.toml. For now it works on GitHub and GitLab.

Every setting can also be set with an environment variable, using the section and the key with a double underscore between them. For example, webhook_secret under [github] becomes GITHUB__WEBHOOK_SECRET. Environment variables are applied last, so they win over .pr_agent.toml. We'll use them a lot, since on Lambda there's no config file to mount.

Model calls go through LiteLLM, so moving from OpenAI to Bedrock or to a local model is just a config change. For big PRs, PR-Agent uses a compression strategy that fits the most relevant parts of the diff into a fixed token budget, so even big PRs fit.

The GitHub Action is the quickest way to get started, and for a single repo it works great. Once you have several repos, a git provider other than GitHub, or you don't want model credentials in your CI, it's easier to have one deployment that everything talks to.

For that I like Lambda, for a few reasons:

PR-Agent already supports Lambda. This is the GitHub Lambda handler, pr_agent/servers/github_lambda_webhook.py (I removed the logging from the except):

from fastapi import FastAPI
from mangum import Mangum
from starlette.middleware import Middleware
from starlette_context.middleware import RawContextMiddleware

from pr_agent.servers.github_app import router

try:
    from pr_agent.config_ import apply_secrets_manager_config
    apply_secrets_manager_config()
except Exception as e:
    ...  # falls back to environment variables

middleware = [Middleware(RawContextMiddleware)]
app = FastAPI(middleware=middleware)
app.include_router(router)

handler = Mangum(app, lifespan="off")

def lambda_handler(event, context):
    return handler(event, context)

It uses the same FastAPI router as the regular server, wraps it with Mangum, which translates Lambda events into ASGI requests, and loads the config from Secrets Manager on cold start. So the regular server and the Lambda deployment share the same code.

On Lambda, PR-Agent runs the whole review before it responds to the webhook. That's actually what we want, because Lambda freezes the function once it returns, so a review running in the background would get stuck. It does mean the function needs a long timeout (the docs recommend at least 3 minutes).

The side effect is that GitHub shows every delivery as timed out, since it only waits 10 seconds. The comments are still posted, so you can ignore it. GitLab.com is stricter, and disables the webhook after a few timeouts in a row.

The fix is a small function in front of PR-Agent that calls it asynchronously and responds right away. I left it out of this post to keep it short, but the companion repo has it (ASYNC_REVIEWS=true), and it's on by default for every provider except GitHub.

PR-Agent ships with heavy Python dependencies, so a zip deployment is not going to be fun. Lambda container images can be up to 10 GB uncompressed, compared to 250 MB unzipped for zips. Lambda pulls the image from ECR, in the same region as the function.

In front of the function we'll use a Lambda Function URL, a dedicated HTTPS endpoint without API Gateway. A webhook receiver doesn't need routing, stages or usage plans, but the main reason is the timeout. API Gateway times out after 29 seconds by default, and as we saw, the review runs inside the request. A Function URL waits for the function's own timeout.

It uses authType: NONE, since GitHub can't sign requests with SigV4, and PR-Agent checks the webhook's HMAC signature itself. The tradeoff is no WAF, no usage plans, and no custom domain unless you put CloudFront in front of it.

GitHub App private keys and webhook secrets go in Secrets Manager and not in environment variables, where anyone with console read access can see them. PR-Agent already knows how to read them from there.

For the model I'm using Amazon Bedrock, which keeps everything inside your own AWS account. LiteLLM supports Bedrock, so PR-Agent does too.

And all of it is defined in CDK, which compiles down to CloudFormation but lets you write actual TypeScript instead of YAML.

You'll need:

jq and openssl. Pick a region and use it for everything. I'll use us-east-1.

Then pick a model and make sure you have access to it before you build anything. Otherwise you'll end up debugging an IAM policy that's actually fine.

I'll use Claude Sonnet 4.5, with Haiku 4.5 as the fallback. Anthropic models need a First Time Use form, which you submit from the model catalog in the Bedrock console, either in each account or once from your org's management account. Without it, calls fail with an error saying the use case details weren't submitted.

The first call to a model that's sold through AWS Marketplace also creates a Marketplace subscription, and the caller needs aws-marketplace:Subscribe for that. The Lambda's role doesn't have it, so invoke each model once from the Bedrock console playground as an admin. If you skip this you'll get AccessDeniedException, sometimes only after the first few calls worked. The model access docs list what each model needs.

Any model LiteLLM can call on Bedrock works. After the stack, I'll show how to switch to DeepSeek V3.2, which doesn't need either of these steps.

PR-Agent authenticates as a GitHub App, so let's start there. Go to Settings, Developer settings, GitHub Apps and create a new one:

https://example.com as the URL for now, and set a webhook secret. GitHub won't save an active webhook without a URL, and we'll only get the real one in step 6. Generate the secret in the same shell you'll use for the rest of the steps, since the next commands read it from there:

   export WEBHOOK_SECRET=$(openssl rand -hex 32)
   echo $WEBHOOK_SECRET   # paste this into the GitHub App form
export APP_ID=123456
   export PEM=~/Downloads/your-app.2026-09-27.private-key.pem

Then install the App on the repos you want reviewed. The install docs have more details.

PR-Agent reads a single JSON secret, where the config keys are flattened into dotted strings. The private key has to be a single JSON string with escaped newlines. jq can build the whole file for us:

jq -Rs --arg app_id "$APP_ID" --arg secret "${WEBHOOK_SECRET:?run step 1 first}" \
  '{"github.app_id": $app_id, "github.webhook_secret": $secret, "github.private_key": .}' \
  < "$PEM" > config.json

That gives you:

{
  "github.app_id": "123456",
  "github.webhook_secret": "your-webhook-secret",
  "github.private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n"
}

Store it, and then delete the local copies. You don't need them anymore, and GitHub can generate a new key whenever you need one:

aws secretsmanager create-secret \
  --name pr-agent/config \
  --secret-string file://config.json \
  --region us-east-1 \
  && rm config.json "$PEM"

Make sure to pass --region. On Lambda, the secret is looked up in the function's region, so a secret in another region won't be found. When that happens, PR-Agent logs Failed to get secrets from AWS Secrets Manager on cold start and continues without credentials, and every webhook after that gets a 403 with Webhook secret not configured.

Two more things to know about this secret:

SECTION__KEY environment variable. The project publishes Lambda images to Docker Hub on every release: pragent/pr-agent:<version>-github_lambda, a rolling pragent/pr-agent:github_lambda tag, and the same for GitLab. So there's no need to build one yourself. I'm using 0.46.0, which is the latest at the time of writing.

You do need to copy it into your own ECR, because that's the only registry Lambda pulls from. The published tags are multi-arch (amd64 and arm64), and Lambda doesn't accept multi-arch images, so pull a single platform. I'd also pin a version and not use the rolling tag, so a redeploy a few months from now doesn't quietly pick up a different build.

ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGION=us-east-1
TAG=0.46.0-github_lambda

aws ecr create-repository --repository-name pr-agent --region $REGION

aws ecr get-login-password --region $REGION \
  | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$REGION.amazonaws.com

docker pull --platform linux/amd64 pragent/pr-agent:$TAG
docker tag pragent/pr-agent:$TAG $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/pr-agent:$TAG
docker push $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/pr-agent:$TAG

If you use Docker Desktop with the containerd image store, docker push might push the multi-arch index anyway. Run docker manifest inspect on the ECR tag. If you see a layers array you're good, if you see manifests, Lambda will reject it.

The stack looks up the secret from step 2 and the ECR repo from step 3 by name, and the image has to exist when you deploy. That's why we did those first.

mkdir pr-agent-infra && cd pr-agent-infra
npx cdk init app --language typescript

cdk init installs the dependencies and names things after the directory, so the stack goes in lib/pr-agent-infra-stack.ts. Replace what's there with:

import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

// the tag you pushed in step 3
const IMAGE_TAG = '0.46.0-github_lambda';

// cross-region inference profile ids, the "us." prefix is what makes them profiles
const MODEL = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
const FALLBACK_MODEL = 'us.anthropic.claude-haiku-4-5-20251001-v1:0';

export class PrAgentInfraStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const repo = ecr.Repository.fromRepositoryName(this, 'PrAgentRepo', 'pr-agent');
    const config = secretsmanager.Secret.fromSecretNameV2(
      this,
      'PrAgentConfig',
      'pr-agent/config',
    );

    const fn = new lambda.DockerImageFunction(this, 'PrAgentFunction', {
      code: lambda.DockerImageCode.fromEcr(repo, { tagOrDigest: IMAGE_TAG }),
      // has to match the platform you pulled
      architecture: lambda.Architecture.X86_64,
      // memory buys CPU on Lambda, and 2 GB is a reasonable spot for this
      memorySize: 2048,
      // the review runs inside the invocation, and a big PR runs describe,
      // review and improve back to back. you only pay for the time used
      timeout: cdk.Duration.minutes(15),
      // caps the blast radius when someone merges a train of 30 PRs.
      // new accounts start on a reduced concurrency quota, often 10. there,
      // drop this line: Lambda keeps 100 unreserved, so reserving fails
      reservedConcurrentExecutions: 5,
      // Lambda log groups never expire by default
      logGroup: new logs.LogGroup(this, 'PrAgentLogs', {
        retention: logs.RetentionDays.ONE_MONTH,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      }),
      environment: {
        // env vars can't contain dots, and Dynaconf reads SECTION__KEY into section.key
        CONFIG__GIT_PROVIDER: 'github',
        CONFIG__PUBLISH_OUTPUT: 'true',
        CONFIG__MODEL: `bedrock/${MODEL}`,
        CONFIG__FALLBACK_MODELS: `["bedrock/${FALLBACK_MODEL}"]`,
        // the default of 32000 is a fraction of what current models take
        CONFIG__MAX_MODEL_TOKENS: '128000',
        CONFIG__SECRET_PROVIDER: 'aws_secrets_manager',
        AWS_SECRETS_MANAGER__SECRET_ARN: config.secretArn,
        // read straight from the process env by the model layer. on Lambda it
        // makes boto3 resolve the execution role credentials the runtime
        // injects, so there are no static keys anywhere
        AWS_USE_IMDS: 'true',
        AWS_REGION_NAME: this.region,
        // /tmp is the only writable path in a Lambda container
        AZURE_DEVOPS_CACHE_DIR: '/tmp',
        HOME: '/tmp',
      },
    });

    config.grantRead(fn);

    // 1. the cross-region inference profile, in your account
    fn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
        resources: [
          `arn:aws:bedrock:${this.region}:${this.account}:inference-profile/${MODEL}`,
          `arn:aws:bedrock:${this.region}:${this.account}:inference-profile/${FALLBACK_MODEL}`,
        ],
      }),
    );

    // 2. the underlying foundation models, in every region the profile can route to.
    //    foundation-model ARNs have an empty account field. skip this and calls fail.
    fn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
        resources: ['us-east-1', 'us-east-2', 'us-west-2'].flatMap((r) => [
          `arn:aws:bedrock:${r}::foundation-model/${MODEL.replace(/^(us|eu|apac|global)\./, '')}`,
          `arn:aws:bedrock:${r}::foundation-model/${FALLBACK_MODEL.replace(/^(us|eu|apac|global)\./, '')}`,
        ]),
      }),
    );

    const url = fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE });

    new cdk.CfnOutput(this, 'FunctionUrl', { value: url.url });
    new cdk.CfnOutput(this, 'WebhookUrl', {
      value: `${url.url}api/v1/github_webhooks`,
    });
    new cdk.CfnOutput(this, 'LogGroup', { value: fn.logGroup.logGroupName });
  }
}

cdk init also generates bin/pr-agent-infra.ts with the env block commented out, which makes the stack region-agnostic. The region list in the second policy statement isn't, so set the region explicitly:

new PrAgentInfraStack(app, 'PrAgentInfraStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
});

Sonnet 4.5 can only be called through a cross-region inference profile (the us. prefix), which sends each request to one of a few US regions. AWS requires permissions on both the profile and the model in each of those regions, so the stack has two statements.

The region list above works for us-east-1, us-east-2 and us-west-2. In Europe, use the eu. prefix and your EU regions, and you can check the exact list with aws bedrock get-inference-profile.

For a simpler setup, you can use a model that runs in your own region, like DeepSeek V3.2 below.

DeepSeek V3.2 runs in your own region, and it doesn't need the use case form or a Marketplace subscription. Here I use it as both the main model and the fallback, and the region list only needs your region:

const MODEL = 'deepseek.v3.2';
const FALLBACK_MODEL = 'deepseek.v3.2';
// and in the second policy statement: ['us-east-1']

The first policy statement will then grant access to an inference profile that doesn't exist, which is harmless. aws bedrock list-foundation-models shows what's available in your region, but not what your account is allowed to call, so the first real call is what tells you.

npx cdk bootstrap   # once per account/region
npx cdk deploy

The server has a health check at /, so you can check that the image, the Function URL and the cold start all work before connecting GitHub:

curl -i "$(aws cloudformation describe-stacks --stack-name PrAgentInfraStack \
  --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
  --output text --region us-east-1)"

You should get {"status":"ok"}. If you call the WebhookUrl output instead, you'll get a 405, because that path only accepts POST.

A healthy response doesn't mean the secret was found. Tail the logs and look for Applied AWS Secrets Manager configuration on cold start. If you see Failed to get secrets from AWS Secrets Manager instead, the secret is in another region or has a different name:

aws logs tail "$(aws cloudformation describe-stacks --stack-name PrAgentInfraStack \
  --query "Stacks[0].Outputs[?OutputKey=='LogGroup'].OutputValue" \
  --output text --region us-east-1)" --follow

Take the WebhookUrl output and paste it into your GitHub App's webhook settings instead of example.com. Keep the same secret. Then open a test PR.

The App's "Recent Deliveries" tab shows what happened. Timed out is expected, as we saw earlier. A 403 means the signature check failed, and the response body tells you why: Request signatures didn't match! means the secret in the App is different, and Webhook secret not configured means the secret from step 2 wasn't found. For anything else, check the logs.

cdk destroy only removes what CDK created, so the secret and the ECR repo need to be deleted separately:

npx cdk destroy
aws ecr delete-repository --repository-name pr-agent --force --region us-east-1
aws secretsmanager delete-secret --secret-id pr-agent/config \
  --force-delete-without-recovery --region us-east-1

Then delete the GitHub App, or at least uninstall it, so it stops sending webhooks to a URL that doesn't exist anymore.

Without --force-delete-without-recovery, the secret stays in a 30 day recovery window. You aren't billed for it, but you can't create a new secret with the same name until the window ends.

Lambda is the cheap part. Its free tier includes 1 million requests a month, and enough compute time for over 3,000 one minute reviews with 2 GB of memory. After that, each review costs about a fifth of a cent. On top of that you pay $0.40 a month for the secret and about $0.10 per GB-month for the image in ECR. Most of the cost is the model, and it depends on how big your PRs are, so I'd run it for a week on a real repo and check the Bedrock pricing page before estimating.

PR-Agent is community owned and we're always looking for contributors. If you deploy this and run into issues, feel free to open an issue, that feedback really helps the project.

Feel free to reach out to me over LinkedIn, x or email me at me@naor.dev

If you enjoyed this post and want to follow along with my open source journey, feel free to follow me here and on GitHub.

Thanks for reading this!

── more in #ai-agents 4 stories · sorted by recency
── more on @pr-agent 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/running-a-serverless…] indexed:0 read:15min 2026-09-27 · —