Code review has a ceiling. A reviewer can read a diff and reason about it, but they can't click on it. For a product with a UI, the most useful review comment is "I opened it, tried the flow, and the second step loses the form state", and you only get that comment if the reviewer has a URL. Vercel built a company on this insight. If your app is a static front-end, use Vercel and stop reading.
Ours isn't. The platform we run for a US client is three Next.js applications plus an API layer, Lambdas, DynamoDB tables and an Aurora database, all defined in CDK and deployed to AWS. A front-end-only preview pointed at the shared dev backend is a lie: it shows the reviewer the new UI talking to the old API. We wanted the whole stack per pull request, and we wanted it to cost about a dollar. Here's what we built.
What is per pull request, and what isn't
The trick is deciding what needs to be duplicated. Duplicating everything is slow to create and expensive to keep. Duplicating nothing is Vercel. The line we drew:
Everything with a per-hour price and a slow creation time is shared: the VPC, the NAT, the Aurora cluster. Everything that is free when idle and fast to create is per PR: App Runner at minimum size, Lambdas, DynamoDB tables. The database is the compromise. A separate cluster per PR would take 10 minutes and cost $40 a month at the 0.5 ACU floor; a separate database on the shared cluster takes one CREATE DATABASE and costs nothing. Migrations run against pr_123 on stack creation, exactly as they run in production, so a PR that changes the schema previews with its own schema.
The CDK side
The environment already existed as a CDK context value: dev, staging, prod. A preview is a fourth flavour, pr, with a number.
// bin/app.ts
const env = app.node.tryGetContext('env') ?? 'dev';
const pr = app.node.tryGetContext('pr'); // "123" or undefined
const suffix = pr ? `-pr-${pr}` : '';
new PlatformStack(app, `platform-${env}${suffix}`, {
env: accounts[env === 'pr' ? 'dev' : env],
config: {
...configs[env === 'pr' ? 'dev' : env],
removalPolicy: pr ? RemovalPolicy.DESTROY : configs[env].removalPolicy,
warmInstances: pr ? 1 : configs[env].warmInstances,
instanceSize: pr ? 'small' : configs[env].instanceSize,
databaseName: pr ? `pr_${pr}` : 'app',
previewToken: pr ? Secret.fromSecretNameV2(...).secretValue : undefined,
},
});
The stack itself doesn't know it's a preview. It receives a config where the removal policy says DESTROY, the instance is small, the database name has a number in it. Shared resources are imported with Vpc.fromLookup, DatabaseCluster.fromDatabaseClusterAttributes and Repository.fromRepositoryName, which is what the dev stack already did for cross-stack references. The diff to support previews was under a hundred lines, and most of it was the config plumbing above.
The GitHub Actions side
Two workflows. The first runs on pull_request with types opened, synchronize and reopened:
jobs:
preview:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read, pull-requests: write }
concurrency: preview-${{ github.event.number }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<dev-account>:role/github-deploy-dev
aws-region: us-east-1
- run: ./scripts/build-images.sh --tag pr-${{ github.event.number }}-${{ github.sha }}
- run: npx cdk deploy platform-pr-${{ github.event.number }} --context env=pr --context pr=${{ github.event.number }} --require-approval never
- run: ./scripts/comment-preview-url.sh ${{ github.event.number }}
The second runs on pull_request with type closed, and it's one step: cdk destroy of the same stack name, followed by DROP DATABASE pr_123 through a small Lambda that has network access to the cluster. Merged or abandoned, the PR takes its environment with it.
The comment script reads the App Runner URL from the stack outputs and posts it once, then edits the same comment on every push so the PR doesn't fill up with bot noise. The URL is the *.awsapprunner.com hostname. We don't create custom domains for previews: a certificate validation per PR adds five minutes and buys nothing.
From push to URL: about six minutes
| Step | Time |
|---|---|
| Checkout, assume role | 20 s |
| Build three images in CodeBuild, in parallel | ~3 min |
cdk deploy on a new stack, App Runner service creation included |
~2.5 min |
Migrations against pr_123 |
10 s |
| Comment on the PR | 2 s |
On subsequent pushes the stack exists, so only the image tag changes and App Runner performs a rolling update: about four minutes. Not Vercel's forty seconds, but fast enough that a reviewer who asks for a change sees it in the same sitting.
The three things that keep it from becoming a mess
A sweeper. Workflows fail. A closed event gets lost when GitHub has an incident, or someone deletes the branch from the CLI. A scheduled Lambda lists CloudFormation stacks named platform-pr-*, checks the creation time, and destroys anything older than seven days. It has run for real four times. Without it, dead previews accumulate at $5 a month each, quietly.
A preview token. Previews are on public URLs with real-looking data. A Next.js middleware checks for a cookie set by /preview?token=…, with the token read from a shared secret at runtime, not baked at build time. Reviewers click the link in the PR comment once, which carries the token, and then browse normally. It's not a security boundary against a determined attacker, but it keeps crawlers and accidental sharing out, and the data behind it is fixtures, never production.
Fixtures, not prod data. The per-PR database is seeded from a fixture file checked into the repo: a handful of accounts, orders, the states that matter for review. Copying production data into previews is the fastest way to leak it, and it also makes previews slow to create. If a reviewer needs a specific state, they add it to the fixture, and every future preview has it.
What it costs
| Item | Per PR, open for 3 days |
|---|---|
| App Runner, 0.25 vCPU / 0.5 GB, mostly idle | ~$0.50 |
| DynamoDB on-demand, a few thousand requests | ~$0.01 |
| Lambda | ~$0.00 |
| Database on the shared cluster | $0 |
| CodeBuild, 3 images × ~4 builds | ~$1.20 |
| Total | ~$1.70 |
With twenty pull requests a month it's about $35, of which CodeBuild is most. The shared Aurora cluster in dev already existed and auto-pauses; the previews keep it awake a bit more, which is real but hard to measure. Compared with a Vercel Pro seat per developer, it's cheaper, and unlike Vercel it previews the API and the data layer too.
When not to do this
If your backend is stable and your PRs are almost always front-end, a front-end-only preview against shared dev is fine and much simpler. If your stack takes twenty minutes to create because it has an ALB, an RDS instance and a Redis cluster, per-PR environments will be too slow to be useful, and you should look at namespaces on a shared cluster or at ephemeral databases with branching. And if you have more than a handful of developers, watch the CloudFormation API rate limits: twenty concurrent cdk deploy runs in one account will hit them.
For a team of three, shipping several pull requests a day on a stack that creates in six minutes, it's the single best developer-experience investment we've made. Reviews got faster, and "works on my machine" stopped being an argument, because the machine is the same one.
Want previews for your own stack, on your own AWS account? Talk to us.