Running a serverless AI code review agent on AWS Lambda with PR-Agent and CDK
Code reviews are an essential part of our day to day work. 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. The flow is the following: /review,

Code reviews are an essential part of our day to day work. 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. The flow is the following: /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: CLI. A FastAPI server that listens to requests from different git providers. You can override the default configurations by creating a configuration file. .pr_agent.toml and can sit in two locations: In your repo's root dir. In a repo called 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: A review only happens when something happens on a PR, and there's nothing to do in between. Running a container 24/7 for something that fires a few dozen times a day means paying for idle time. Every setting can be passed as an environment variable, so there's no config file or volume to mount. The token budget also limits how long a review can take, so it finishes well within Lambda's 15 minute limit. Nothing has to be kept between invocations. PR-Agent fetches everything it needs from the git provider's API. 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_loader 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: An AWS account with the CLI v2 configured, and credentials that can create IAM roles. Docker running locally. Node 20 or newer, 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: Permissions: Pull requests and Issues read and write, Contents and Metadata read only. Events: Pull request, Pull request review, Pull request review comment and Issue comment. Under Webhook, tick Active, put 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 Generate a private key, and copy the App ID from the top of the settings page: 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: Only keys with exactly one dot are read. Anything deeper is silently ignored. A key is only applied if the setting is unset or empty. So the secret is only for credentials. To change a setting that has a default, use a 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!
Key Takeaways
- β’Code reviews are an essential part of our day to day work. 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. The flow is the following: /review,
- β’This story was reported by Dev.to, covering developments in the dev space.
- β’AI advancements continue to reshape industries β read the full article on Dev.to for complete coverage.
π Continue reading the full article:
Read Full Article on Dev.to βShare this article



