API
Lambda, API Gateway per PR
Day Three #
Today we give that preview behavior. We will keep the same per-PR stack shape and add one small API to it, so the environment can now execute code, return data, and behave like the beginning of an application.
Goal #
For every pull request:
- deploy a Lambda function
- expose it through API Gateway
- keep the static site preview from the previous chapter
- output both the website URL and the API URL
What already exists #
From the previous chapters, you already have:
- a per-PR preview stack deployed from
bin/preview.ts - a private S3 bucket fronted by CloudFront
- GitHub Actions assuming AWS access through OIDC
- deterministic stack naming with
Stack-PR<number> - teardown on PR close
The shape of the preview app #
The preview flow now looks like this:
Developer pushes to PR
|
v
+----------------------+
| GitHub Actions |
| deploy preview stack |
+----------------------+
|
v
+----------------------+
| CDK |
| Stack-PR<number> |
+----------------------+
|
+------------------------------+
| |
v v
+----------------------+ +----------------------+
| CloudFront + S3 | | API Gateway |
| static preview site | | /hello |
+----------------------+ +----------------------+
^ |
| v
| +----------------------+
| | Lambda |
| | returns JSON |
| +----------------------+
|
Browser
The site and API stay separate. That keeps the mechanics obvious: CloudFront serves the frontend, API Gateway serves the backend, and the PR comment can show both URLs.
CDK - add Lambda and HTTP API #
We will evolve the same preview stack from the previous chapter. Add one Lambda function, one HTTP API, and a new stack output.
Upgrade CDK for OAC #
This repo now uses aws-cdk-lib 2.248.0 and constructs 10.6.0. That upgrade matters because it lets the stack use origins.S3BucketOrigin.withOriginAccessControl(bucket) instead of the older Origin Access Identity pattern.
OAC is the modern CloudFront-to-S3 access model. It keeps the bucket private without introducing the legacy OAI resource, and it matches the current AWS CDK guidance.
Create a tiny function at lambda/hello/index.mjs:
export const handler = async () => {
return {
statusCode: 200,
headers: {
'content-type': 'application/json',
'access-control-allow-origin': '*',
},
body: JSON.stringify({
ok: true,
message: 'hello from lambda',
time: new Date().toISOString(),
}),
};
};
Then extend your preview stack. This example keeps the site from the previous chapter and adds the API pieces.
lib/stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { Tags } from 'aws-cdk-lib';
export class Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pr = String(this.node.tryGetContext('pr') ?? 'local');
const repo = this.node.tryGetContext('repo');
const sha = this.node.tryGetContext('sha');
const run = this.node.tryGetContext('run');
const acct = this.node.tryGetContext('acct');
const reg = this.node.tryGetContext('reg');
const bucketProps: s3.BucketProps = {
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
...(acct && reg
? {
bucketName: `pr-${String(pr)}-${String(acct)}-${String(reg)}`
.toLowerCase()
.replace(/[^a-z0-9.-]/g, '')
.slice(0, 63)
.replace(/[.-]+$/g, ''),
}
: {}),
};
const bucket = new s3.Bucket(this, 'SiteBucket', bucketProps);
const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
defaultRootObject: 'index.html',
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' },
],
});
new s3deploy.BucketDeployment(this, 'DeploySite', {
destinationBucket: bucket,
sources: [s3deploy.Source.asset('site')],
distribution,
distributionPaths: ['/*'],
});
const hello = new lambda.Function(this, 'HelloFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/hello'),
timeout: cdk.Duration.seconds(5),
memorySize: 256,
environment: {
PR_NUMBER: pr,
},
});
const api = new apigwv2.HttpApi(this, 'PreviewApi', {
corsPreflight: {
allowHeaders: ['content-type'],
allowMethods: [apigwv2.CorsHttpMethod.GET],
allowOrigins: ['*'],
},
});
api.addRoutes({
path: '/hello',
methods: [apigwv2.HttpMethod.GET],
integration: new integrations.HttpLambdaIntegration('HelloIntegration', hello),
});
Tags.of(this).add('managed-by', 'cdk');
Tags.of(this).add('preview', 'true');
Tags.of(this).add('pr', pr);
if (repo) Tags.of(this).add('repo', String(repo));
if (sha) Tags.of(this).add('sha', String(sha));
if (run) Tags.of(this).add('run-id', String(run));
Tags.of(bucket).add('resource', 'preview-bucket');
Tags.of(hello).add('resource', 'preview-api-function');
new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
new cdk.CfnOutput(this, 'PreviewUrl', {
value: `https://${distribution.domainName}/?pr=${encodeURIComponent(pr)}`,
});
new cdk.CfnOutput(this, 'ApiUrl', {
value: `${api.url}hello`,
});
}
}
This keeps the infrastructure shape simple:
- S3 + CloudFront for static assets
- Lambda for application code
- API Gateway as the public HTTP entrypoint
- one stack per PR, destroyed with the PR
A tiny page that calls the API #
Now give the static preview page something to talk to.
At the repo root, update site/index.html so the preview page explains that an API now exists and gives you a simple way to test it. In this repo, the page does not call the API automatically; it shows a small curl template and tells you to use the ApiUrl value from the PR comment.
If you want the page itself to call the API automatically in CI, the usual next step is to inject the API URL into a generated config file during the workflow. We keep that extra wiring out of this chapter so the Lambda + API Gateway pattern stays easy to see.
CI - comment both URLs on the PR #
If you already added --outputs-file cdk-outputs.json in the previous chapter, keep that. The workflow shape does not change; only the PR comment gets richer.
Update the comment step so it reads both outputs while continuing to upsert a single bot comment:
- name: Comment preview URLs
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ secrets.PR_COMMENT_TOKEN || github.token }}
script: |
const fs = require('fs');
const marker = '<!-- pr-preview-urls -->';
const stackName = `Stack-PR${process.env.PR_NUMBER}`;
const outputs = JSON.parse(fs.readFileSync('cdk-outputs.json', 'utf8'));
const previewUrl = outputs?.[stackName]?.PreviewUrl;
const apiUrl = outputs?.[stackName]?.ApiUrl;
if (!previewUrl) throw new Error(`Missing PreviewUrl output for stack "${stackName}"`);
if (!apiUrl) throw new Error(`Missing ApiUrl output for stack "${stackName}"`);
const body = `${marker}\nPreview: ${previewUrl}\nAPI: ${apiUrl}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find((c) =>
c.user?.type === 'Bot' && typeof c.body === 'string' && c.body.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
Nothing about teardown changes. Closing the pull request still destroys the same stack, which now happens to contain a website and an API instead of only a website.
What you should see #
- opening a PR deploys a preview stack with both a site and an API
- the PR comment shows a
PreviewUrland anApiUrl - visiting the site still works over HTTPS through CloudFront
- calling the API returns JSON from Lambda
- closing the PR removes the stack, the site, and the API
You can verify the API directly:
curl "https://<api-id>.execute-api.<region>.amazonaws.com/hello"
Or with the concrete output from your stack:
curl "$(jq -r '."Stack-PR123".ApiUrl' cdk-outputs.json)"
Notes and hardening #
- Keep CORS broad only for the tutorial. Once the pattern works, restrict origins to the preview site domain.
- Start with a single route like
/hello; more routes are just more Lambda integrations or a fuller application framework. - Watch CloudWatch Logs for Lambda errors, timeouts, and malformed responses.
- API Gateway gives you a simple HTTP front door, but authentication is still missing. Add that later when the API needs real users.
- If you later want one origin for both frontend and backend, CloudFront can proxy
/api/*to API Gateway. Keeping them separate first makes the system easier to understand.
Well-Architected Framework #
- Security: short-lived preview stacks reduce blast radius, OIDC avoids stored AWS keys, and the Lambda function has only the execution role it needs.
- Operational Excellence: the same CI/CD path now deploys a more realistic application shape without adding a second release process.
- Reliability: API Gateway and Lambda are managed services, and per-PR isolation keeps failures local to a single preview environment.
- Cost Optimization: previews still disappear automatically, and Lambda + HTTP API remain cheap at tutorial scale.
- Performance Efficiency: the static site stays cached at the edge while dynamic requests go only where computation is needed.
- Sustainability: ephemeral environments still prevent idle infrastructure and forgotten test systems.