# Preview environments per pull request on AWS, without Vercel

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:

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two columns. Per pull request: one CloudFormation stack containing an App Runner service at minimum size, the Lambda functions, the DynamoDB tables and a database schema. Shared in the dev account: the VPC and NAT gateway, the Aurora Serverless v2 cluster, the ECR repository, Secrets Manager secrets and KMS keys. Arrow from the per-PR stack to the shared resources labelled looked up by name, never created.">
<g font-family="Inter,system-ui,sans-serif" font-size="13">
<rect x="20" y="20" width="400" height="260" rx="14" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="220" y="48" text-anchor="middle" fill="#4fffb0" font-size="14" font-weight="700">Per pull request · stack pr-123</text>
<rect x="40" y="66" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="91" text-anchor="middle" fill="#f1f3ff">App Runner service · 0.25 vCPU / 0.5 GB</text>
<rect x="40" y="114" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="139" text-anchor="middle" fill="#f1f3ff">Lambda functions (all of them)</text>
<rect x="40" y="162" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="187" text-anchor="middle" fill="#f1f3ff">DynamoDB tables · on-demand · DESTROY</text>
<rect x="40" y="210" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="235" text-anchor="middle" fill="#f1f3ff">Postgres database pr_123 on the shared cluster</text>
<text x="220" y="268" text-anchor="middle" fill="#9aa3c7" font-size="12">created in ~6 min · destroyed on merge or close</text>
<rect x="480" y="20" width="400" height="260" rx="14" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="680" y="48" text-anchor="middle" fill="#ffd166" font-size="14" font-weight="700">Shared · dev account</text>
<rect x="500" y="66" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="91" text-anchor="middle" fill="#f1f3ff">VPC · subnets · the one NAT gateway</text>
<rect x="500" y="114" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="139" text-anchor="middle" fill="#f1f3ff">Aurora Serverless v2 cluster (auto-pause)</text>
<rect x="500" y="162" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="187" text-anchor="middle" fill="#f1f3ff">ECR repositories · CodeBuild projects</text>
<rect x="500" y="210" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="235" text-anchor="middle" fill="#f1f3ff">Secrets Manager · KMS keys · WAF</text>
<text x="680" y="268" text-anchor="middle" fill="#9aa3c7" font-size="12">looked up by name from the PR stack, never created by it</text>
<path d="M420,150 L478,150" stroke="#7b8cff" stroke-width="2" stroke-dasharray="5,4"/>
</g>
</svg>
</div>

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.

```ts
// 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`:

```yaml
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.

<div class="article-figure">
<svg viewBox="0 0 900 190" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Lifecycle of a preview environment: pull request opened, images built and tagged with the PR number and commit, cdk deploy of stack pr-123 in the dev account, URL commented on the pull request, every push updates the same stack, pull request merged or closed triggers cdk destroy and drop database.">
<defs><marker id="arrPv" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="40" width="140" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="85" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">PR opened</text><text x="85" y="84" text-anchor="middle" fill="#9aa3c7">or pushed</text>
<line x1="157" y1="70" x2="185" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="188" y="40" width="150" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="263" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">build images</text><text x="263" y="84" text-anchor="middle" fill="#9aa3c7">tag pr-123-&lt;sha&gt;</text>
<line x1="340" y1="70" x2="368" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="371" y="40" width="170" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="456" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">cdk deploy pr-123</text><text x="456" y="84" text-anchor="middle" fill="#9aa3c7">dev account · DESTROY</text>
<line x1="543" y1="70" x2="571" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="574" y="40" width="140" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="644" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">URL on the PR</text><text x="644" y="84" text-anchor="middle" fill="#9aa3c7">one comment, edited</text>
<line x1="716" y1="70" x2="744" y2="70" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="747" y="40" width="140" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="817" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">merged / closed</text><text x="817" y="84" text-anchor="middle" fill="#ff6b8a">destroy + drop db</text>
<path d="M644,102 C644,140 263,140 263,102" fill="none" stroke="#9aa3c7" stroke-width="1.2" stroke-dasharray="4,3"/>
<text x="456" y="150" text-anchor="middle" fill="#9aa3c7">every push: same stack, new image tag, rolling update in ~4 min</text>
<text x="456" y="178" text-anchor="middle" fill="#ffd166">TTL sweeper: any pr-* stack older than 7 days is destroyed regardless</text>
</g>
</svg>
</div>

## 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](/contact).
