When using Vercel, one of the most beloved features is automatic preview deployments for every pull request. Each PR gets its own unique URL where you can test changes before merging. With SST and OpenNext for AWS deployments, you can recreate this same workflow using GitHub Actions.
In this post, I'll show you how to set up automatic preview environments that deploy on pull request creation and clean up when PRs are closed or merged.
Plus a bot will comment in the Pull Request to share the preview environment url so you can validate your changes, see the example image below
Why Preview Environments Matter
Preview environments provide several key benefits:
- Isolated Testing: Each PR gets its own environment, preventing conflicts
- Stakeholder Reviews: Non-technical team members can review changes easily
- Integration Testing: Test with real AWS services in an isolated environment
- Faster Feedback: Catch issues before they reach production
Overview of the Solution
The solution consists of two GitHub Actions workflows:
- Deploy Workflow: Creates preview environments for pull requests
- Destroy Workflow: Cleans up environments when PRs are closed
Both workflows use SST's staging feature to create isolated deployments with unique stage names based on the PR number.
Deploy Preview Environment Workflow
This workflow triggers on pull request events and creates a new SST stage with a unique name based on the PR number. The deployment job handles the entire process from checkout to deployment, while a separate comment job posts the preview URL back to the PR. The workflow uses OIDC authentication for secure AWS access and includes concurrency controls to prevent deployment conflicts.
name: Deploy PR Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
branch:
description: 'Branch to deploy'
required: true
type: string
permissions:
contents: write
pull-requests: write
id-token: write
deployments: write
env:
STAGE_NAME: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.inputs.branch }}
concurrency:
group: ${{ github.workflow }}-${{ env.STAGE_NAME }}
cancel-in-progress: false
jobs:
deploy:
name: Deploy Preview Environment
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
outputs:
preview_url: ${{ steps.get_url.outputs.url }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ">=20"
cache: pnpm
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Install dependencies
run: pnpm install
- name: Deploy with SST
run: npx sst deploy --stage ${{ env.STAGE_NAME }}
- name: Get preview URL
id: get_url
run: |
URL=$(npx sst --stage ${{ env.STAGE_NAME }} --json | jq -r '.url')
echo "url=$URL" >> $GITHUB_OUTPUT
comment:
name: Add Preview Environment Comment on PR
if: github.event_name == 'pull_request'
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Find existing comment
uses: peter-evans/find-comment@v3
id: find_comment
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: "š Preview Environment"
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find_comment.outputs.comment-id }}
edit-mode: replace
body: |
## š Preview Environment
| | |
|---|---|
| **Latest commit** | `${{ github.sha }}` |
| **Status** | ā
Deploy successful |
| **Preview URL** | [${{ needs.deploy.outputs.preview_url }}](${{ needs.deploy.outputs.preview_url }}) |
Destroy Preview Environment Workflow
This workflow automatically runs when a PR is closed or merged, ensuring no orphaned resources remain in AWS. It uses the same stage naming convention to identify and remove the correct preview environment. The cleanup includes both the SST infrastructure and any associated state files stored in S3.
name: Destroy PR Preview Environment
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
stage_name:
description: 'Stage name to destroy'
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
destroy:
name: Destroy Preview Environment
runs-on: ubuntu-latest
env:
STAGE_NAME: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.inputs.stage_name }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ">=20"
cache: pnpm
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Install dependencies
run: pnpm install
- name: Destroy SST environment
run: npx sst remove --stage ${{ env.STAGE_NAME }}
- name: Clean up SST state (optional)
continue-on-error: true
run: |
# Remove SST state files if using custom state bucket
aws s3 rm s3://your-sst-state-bucket/app/your-app-name/${{ env.STAGE_NAME }}.json
aws s3 rm s3://your-sst-state-bucket/secret/your-app-name/${{ env.STAGE_NAME }}.json
Key Configuration Points
1. AWS Credentials
Set up OIDC authentication with AWS to avoid long-lived access keys. If you need help setting up the OIDC provider and IAM role, check out this detailed guide on configuring OpenID Connect for GitHub in AWS CDK:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
2. Stage Naming
Use consistent stage naming based on PR numbers:
env:
STAGE_NAME: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.inputs.branch }}
3. Secrets Management
SST secrets are scoped to stages, so set them for each preview environment:
npx sst secret set DATABASE_URL '${{ secrets.DATABASE_URL }}' --stage ${{ env.STAGE_NAME }}
4. Concurrency Control
Prevent multiple deployments to the same stage:
concurrency:
group: ${{ github.workflow }}-${{ env.STAGE_NAME }}
cancel-in-progress: false
Required GitHub Secrets
Set these secrets in your GitHub repository:
AWS_ROLE_ARN
: Your AWS IAM role ARN for OIDC authenticationDATABASE_URL
: Your database connection string- Any other environment-specific secrets your app needs
SST Configuration
Ensure your sst.config.ts
supports multiple stages:
export default {
config(input) {
return {
name: "your-app-name",
region: "us-east-1",
stage: input.stage,
}
},
stacks(app) {
app.stack(function Site({ stack }) {
const site = new NextjsSite(stack, "site", {
// Your configuration
})
return {
url: site.url
}
})
}
}
Benefits of having this setup
- Automatic Deployment: Every PR gets a preview environment
- Cost Efficient: Environments are destroyed when PRs close
- Secure: Uses OIDC for AWS authentication
- Scalable: Each preview environment is completely isolated
- Developer Friendly: Clear preview URLs in PR comments