Automatic Preview Environments with SST and GitHub Actions

Automatic Preview Environments with SST and GitHub Actions

Written on June 16th, 2025 by Danny Steenman.

Last updated: June 16th, 2025.


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 GitHub Actions Pull request commenter with SST preview url

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:

  1. Deploy Workflow: Creates preview environments for pull requests
  2. 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 authentication
  • DATABASE_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

  1. Automatic Deployment: Every PR gets a preview environment
  2. Cost Efficient: Environments are destroyed when PRs close
  3. Secure: Uses OIDC for AWS authentication
  4. Scalable: Each preview environment is completely isolated
  5. Developer Friendly: Clear preview URLs in PR comments