JONAS STAMM
— FIELD NOTES
Build in PublicMay 12, 2026· 3 min read

AWS Amplify PR Preview Links in 15 Minutes

AWS Amplify PR Preview Links in 15 Minutes Why this matters Your team opens a frontend PR. The reviewer has to either: - Check out the branch locally (5-10 min...

JS
Jonas Stamm
Founder, BauGPT

AWS Amplify PR Preview Links in 15 Minutes

Why this matters

Your team opens a frontend PR. The reviewer has to either:

None of these are good. Amplify already builds a preview deployment for every branch. The URL just doesn't show up anywhere useful.

Here's how to surface it automatically in every PR, in 15 minutes.


What you'll build

A GitHub Actions workflow that:

  1. Posts a comment the moment a PR is opened: "Building... expected URL: https://pr-42.d1xxxxxxxxx.amplifyapp.com"
  2. Polls Amplify every 30 seconds until the build completes
  3. Updates the same comment to ✅ with the live link (or ❌ on failure)

No third-party services or polling scripts needed. The URL is deterministic: Amplify always uses pr-{PR_NUMBER}.{APP_ID}.amplifyapp.com.


Prerequisites

Gen 2 note: If you're using Amplify Gen 2 (CDK-based), PR previews work differently. This tutorial covers Gen 1 — the "Amplify Hosting" option in the console.


Step 1: Enable PR Previews in Amplify

AWS Console → Amplify → your app → Hosting → Previews

Toggle "Enable pull request previews" and connect your GitHub repo. This tells Amplify to spin up a branch deployment for every PR.


Step 2: Create an IAM policy

Create a policy for a CI user with this minimum permission:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["amplify:ListJobs"],
    "Resource": "arn:aws:amplify:eu-central-1:ACCOUNT_ID:apps/APP_ID/branches/*"
  }]
}

Generate access keys for this user (IAM → User → Security credentials → Create access key → Application running outside AWS).

Security note: Static access keys need periodic rotation. For a more secure setup, use OIDC federation to grant GitHub Actions short-lived tokens with no key management. For a quick internal setup, static keys are fine.


Step 3: Add GitHub secrets

Settings → Secrets and variables → Actions → New repository secret:

SecretValue
AWS_ACCESS_KEY_IDFrom IAM
AWS_SECRET_ACCESS_KEYFrom IAM
AWS_REGIONe.g. eu-central-1
AMPLIFY_APP_IDFrom Amplify App settings (starts with d)

Step 4: Add the workflow

Create .github/workflows/amplify-preview.yml:

name: Amplify PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write

concurrency:
  group: amplify-preview-${{ github.event.pull_request.number }}
  cancel-in-progress: false

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - name: Post initial comment
        uses: actions/github-script@v7
        id: initial-comment
        with:
          script: |
            const prNumber = context.payload.pull_request.number;
            const appId = '${{ secrets.AMPLIFY_APP_ID }}';
            const expectedUrl = `https://pr-${prNumber}.${appId}.amplifyapp.com`;
            const comment = await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: `🔄 **Amplify preview building...**\n\nExpected URL: ${expectedUrl}\n\n_This comment will update when the build completes._`
            });
            return comment.data.id;

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Wait for Amplify build
        id: wait-build
        run: |
          PR_NUMBER="${{ github.event.pull_request.number }}"
          APP_ID="${{ secrets.AMPLIFY_APP_ID }}"
          BRANCH="pr-${PR_NUMBER}"
          MAX_ATTEMPTS=20
          ATTEMPT=0

          while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
            STATUS=$(aws amplify list-jobs \
              --app-id "$APP_ID" \
              --branch-name "$BRANCH" \
              --max-results 1 \
              --query 'jobSummaries[0].status' \
              --output text 2>/dev/null || echo "PENDING")

            [ "$STATUS" = "None" ] && STATUS="PENDING"

            echo "Attempt $ATTEMPT: $STATUS"

            if [ "$STATUS" = "SUCCEED" ]; then
              echo "build_status=success" >> $GITHUB_OUTPUT
              break
            elif [ "$STATUS" = "FAILED" ]; then
              echo "build_status=failed" >> $GITHUB_OUTPUT
              break
            fi

            ATTEMPT=$((ATTEMPT + 1))
            sleep 30
          done

          if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
            echo "build_status=timeout" >> $GITHUB_OUTPUT
          fi

      - name: Update comment with result
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = context.payload.pull_request.number;
            const commentId = ${{ steps.initial-comment.outputs.result }};
            const appId = '${{ secrets.AMPLIFY_APP_ID }}';
            const previewUrl = `https://pr-${prNumber}.${appId}.amplifyapp.com`;
            const buildStatus = '${{ steps.wait-build.outputs.build_status }}';

            const messages = {
              success: `✅ **Amplify preview ready**\n\n🔗 [Open preview](${previewUrl})\n\`${previewUrl}\``,
              failed: `❌ **Amplify build failed**\n\nCheck the [Amplify console](https://console.aws.amazon.com/amplify) for logs.`,
              timeout: `⏱️ **Build taking longer than expected**\n\nExpected URL (may not be ready yet): ${previewUrl}`
            };

            await github.rest.issues.updateComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              comment_id: commentId,
              body: messages[buildStatus] || messages.timeout
            });

How it works

The preview URL is deterministic: Amplify always names preview branches pr-{PR_NUMBER}, so the URL can be constructed before the build even starts. That's why we post the "building" comment immediately. Reviewers can bookmark it right away.

The polling loop runs every 30 seconds for up to 10 minutes (20 attempts). For most frontend builds that's more than enough. Bump MAX_ATTEMPTS if your builds regularly run longer.


What it looks like

When a PR is opened:

🔄 Amplify preview building...

Expected URL: https://pr-42.d1abc123.amplifyapp.com

This comment will update when the build completes.

After build succeeds:

Amplify preview ready

🔗 Open preview https://pr-42.d1abc123.amplifyapp.com


Troubleshooting

PENDING for several attempts: When a PR is first opened, Amplify may take 30-60 seconds before queueing the first build job. The polling loop handles this. If it stays PENDING past 5 minutes, check that PR previews are enabled in the Amplify console (Hosting → Previews).

Comments pile up on rapid pushes: Each push to the PR triggers a new workflow run, which posts a new "building..." comment. Runs execute in sequence rather than in parallel, but you'll still see one comment thread per push. For repos with lots of commits per PR, consider replacing createComment with peter-evans/find-comment + peter-evans/create-or-update-comment to always update a single comment.

Fork PRs don't get preview links: GitHub doesn't expose repository secrets to workflows triggered by fork PRs. External contributors opening PRs from their own fork won't see preview comments. For public/OSS repos, consider Amplify webhooks and a Lambda instead.

Preview URL 404 after build succeeds: Amplify sometimes takes 30-60 seconds after marking the build SUCCEED to serve traffic. The URL is correct, just wait and refresh.


The tradeoff

This approach polls the Amplify API from GitHub Actions. An alternative is using an Amplify webhook to trigger a Lambda that posts to GitHub, but that's 3x the setup complexity for the same result. Unless you have very high PR volume, polling from GHA is the simpler call.


Wrap-up

Four steps, one YAML file. The core trick is that Amplify's preview URLs are deterministic. You know the URL before the build starts, so you can post the link immediately and update it in place when the build finishes. It works out of the box for team repos, with no external services to maintain.

The one real limitation: fork PRs from external contributors won't get preview links (GitHub secrets restriction). If you hit issues, the Amplify console build logs are your first stop. The pr-{N} branch and its job history are right there.


Written 2026-04-27 based on actual implementation in Crafthunt-App/baugpt_frontend. See TKT #747 for context.

Keep reading

All writing →
build-in-public · Jun 1, 2026
$ we run ai agents inside baugpt.
# build-in-public
read-time: 7min

We run AI agents inside BauGPT. Here's what it taught us about building them.

We build AI for the construction industry. We also run AI agents inside our own company to handle scheduling, ticket routing, code review, and content ops. That...

7 MIN READ
product · May 21, 2026
02

Our enterprise onboarding takes 90 minutes. The procurement took 11 weeks.

A construction company with a four-billion-euro annual turnover signed up for BauGPT last quarter. Their procurement process took eleven weeks. The actual onboa...

4 MIN READ
product · May 21, 2026
03

We process 40,000 WhatsApp messages a week. Here's why we built there.

BauGPT processes 40,000 WhatsApp messages a week. About 40% of them are voice notes. I mention this not to flex on a number. I mention it because it explains ev...

4 MIN READ
— THE NEWSLETTER

One note a week.
No fluff, just what works.

AI engineering, growth hacks, and messy lessons from shipping BauGPT. Unsubscribe anytime. I'll even miss you.

FIELD NOTES · NEXT ISSUE DROPS MONDAY
↳ No spam. One note weekly.