Deploy Automated PR Preview Environments with AWS Copilot and GitHub Actions

Step‑by‑step guide to auto‑create isolated preview environments per PR with AWS Copilot and GitHub Actions, including DB schema cloning, secret injection, and automatic cleanup.

Introduction

Deploying preview environments for every feature branch or pull request (PR) is a powerful way to catch bugs early, let stakeholders test new changes, and keep the mainline stable.
With AWS Copilot, you can spin up isolated ECS services, automatically provision the required networking, and expose the service via a public load balancer – all from a simple CLI.

In this post we’ll walk through a complete, production‑ready workflow that:

  1. Clones a database schema for isolation.
  2. Injects secrets stored in GitHub Secrets as environment variables.
  3. Builds a Docker image from a CI‑generated tag.
  4. Creates a short‑lived Copilot environment (preview‑pr‑<number>).
  5. Deploys the service, runs DB migrations, and posts the public URL back to the PR.
  6. Cleans up everything when the PR is closed.

All of this is driven by a single GitHub Actions workflow that you can drop into any repository that already uses Copilot.


High‑Level Architecture

GitHub PR  →  GitHub Actions
    ├─ Build Docker image → ECR
    ├─ Clone DB schema (PostgreSQL example) → RDS
    ├─ Run migrations against the new schema
    ├─ Create Copilot preview environment (VPC, subnets, ALB)
    ├─ Deploy ECS service with env‑vars & secrets
    └─ Post public URL as a PR comment

When the PR is closed, the workflow tears down the preview environment, drops the temporary schema, and optionally deletes the PR‑specific container image.


Prerequisites

Item Why it’s needed
AWS account with IAM permissions for ECR, ECS, CloudFormation, Secrets Manager, RDS, and VPC actions Copilot creates the infrastructure and pushes images.
AWS Copilot CLI installed locally (or in the CI runner) Provides copilot commands for env/service management.
Existing Copilot application (copilot init already run) The workflow assumes an app and service already exist.
RDS / Aurora database (PostgreSQL, MySQL, etc.) Preview environments need an isolated schema.
GitHub repository with GitHub Secrets for AWS credentials and DB connection details Secrets stay encrypted and are only exposed inside the workflow.
Dockerfile for the service The CI job builds the image.

Step‑by‑Step Implementation

1. Store Secrets in GitHub

Navigate to Settings → Secrets and variables → Actions and add:

Secret Example Value
AWS_ACCESS_KEY_ID IAM user access key
AWS_SECRET_ACCESS_KEY IAM user secret
AWS_ACCOUNT_ID 123456789012
DB_HOST mydb.xxxxxx.us-west-2.rds.amazonaws.com
DB_USER preview_user
DB_PASSWORD super‑secret (temporary, or use Secrets Manager)
DB_NAME myapp
DB_PASSWORD_SECRET_ID ARN of a Secrets Manager secret (recommended)
VPC_ID vpc-0a1b2c3d4e5f6g7h8
PUBLIC_SUBNETS subnet-111,subnet-222
PRIVATE_SUBNETS subnet-333,subnet-444

Tip: For production‑grade security, store DB_PASSWORD in AWS Secrets Manager and reference it via the --secrets flag (see later).

2. GitHub Actions Workflow Overview

Create .github/workflows/preview-deploy.yml. The file contains two jobs:

  • deploy-preview – runs on PR open/reopen/synchronize and builds the preview environment.
  • cleanup – runs when the PR is closed and destroys everything.

Below is the full workflow (copy‑paste ready). Comments explain each block.

name: Preview Deployment

# -------------------------------------------------------
# Trigger on PR events (open, sync) and on PR close
# -------------------------------------------------------
on:
  pull_request:
    types: [opened, reopened, synchronize]
  pull_request_target:
    types: [closed]

# -------------------------------------------------------
# Global env variables – easy to reuse
# -------------------------------------------------------
env:
  APP_NAME: my-copilot-app           # <-- Your Copilot app name
  SERVICE_NAME: frontend             # <-- Your Copilot service name
  AWS_REGION: us-west-2              # <-- Desired region
  ECR_REPOSITORY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/my-frontend
  DB_HOST: ${{ secrets.DB_HOST }}
  DB_USER: ${{ secrets.DB_USER }}
  DB_NAME: ${{ secrets.DB_NAME }}
  SCHEMA_PREFIX: pr_preview           # Prefix for temporary schemas

# =======================================================
# 1️⃣ Deploy preview when a PR is active
# =======================================================
jobs:
  deploy-preview:
    if: github.event_name == 'pull_request' && github.event.action != 'closed'
    runs-on: ubuntu-latest
    name: Deploy Preview Environment

    steps:
      # ----------------------------------------------------------------
      # Basic checkout & AWS setup
      # ----------------------------------------------------------------
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: ${{ env.AWS_REGION }}

      # ----------------------------------------------------------------
      # Install Docker (needed for building the image)
      # ----------------------------------------------------------------
      - name: Install Docker
        run: |
          curl -fsSL https://get.docker.com | sh
          sudo usermod -aG docker $USER

      # ----------------------------------------------------------------
      # Login to Amazon ECR (Docker registry)
      # ----------------------------------------------------------------
      - name: Login to Amazon ECR
        uses: aws-actions/amazon-ecr-login@v2

      # ----------------------------------------------------------------
      # Pull PR metadata into environment variables
      # ----------------------------------------------------------------
      - name: Extract PR metadata
        id: pr-meta
        run: |
          echo "PR_NUMBER=${{ github.event.number }}" >> $GITHUB_ENV
          echo "ENV_NAME=preview-pr-${{ github.event.number }}" >> $GITHUB_ENV
          echo "SCHEMA_NAME=${{ env.SCHEMA_PREFIX }}_${{ github.event.number }}" >> $GITHUB_ENV

      # ----------------------------------------------------------------
      # Install Copilot CLI
      # ----------------------------------------------------------------
      - name: Install Copilot
        run: |
          curl -Lo copilot https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux
          chmod +x copilot
          sudo mv copilot /usr/local/bin/copilot
          copilot --version

      # ----------------------------------------------------------------
      # Ensure the Copilot app exists (idempotent)
      # ----------------------------------------------------------------
      - name: Ensure Copilot app
        run: |
          copilot app init ${{ env.APP_NAME }} --domain myapp.com || echo "App already exists"

      # ----------------------------------------------------------------
      # Create a *preview* environment.
      # --------------------------------------------------------------
      # Using `--import-*` flags lets all preview envs share the same VPC,
      # avoiding VPC sprawl while still being logically isolated.
      # ----------------------------------------------------------------
      - name: Create preview environment
        run: |
          copilot env init \
            --app ${{ env.APP_NAME }} \
            --name ${{ env.ENV_NAME }} \
            --default-config \
            --import-vpc-id ${{ secrets.VPC_ID }} \
            --import-public-subnets ${{ secrets.PUBLIC_SUBNETS }} \
            --import-private-subnets ${{ secrets.PRIVATE_SUBNETS }}

      # ----------------------------------------------------------------
      # Build, tag (with PR number), and push Docker image to ECR
      # ----------------------------------------------------------------
      - name: Build & push Docker image
        run: |
          docker build -t $ECR_REPOSITORY:pr-${{ env.PR_NUMBER }} .
          docker push $ECR_REPOSITORY:pr-${{ env.PR_NUMBER }}

      # ----------------------------------------------------------------
      # ----------------------------------------------------------------
      # 2️⃣ Clone a fresh DB schema for the preview env
      # ----------------------------------------------------------------
      - name: Clone database schema (PostgreSQL example)
        run: |
          PGPASSWORD="${{ secrets.DB_PASSWORD }}" psql -h "${{ env.DB_HOST }}" -U "${{ env.DB_USER }}" -d "${{ env.DB_NAME }}" -c \
          "DROP SCHEMA IF EXISTS ${{ env.SCHEMA_NAME }} CASCADE; CREATE SCHEMA ${{ env.SCHEMA_NAME }} AUTHORIZATION ${{ env.DB_USER }};"

      # ----------------------------------------------------------------
      # 3️⃣ Run DB migrations *against* the newly created schema
      # ----------------------------------------------------------------
      - name: Run DB migrations
        run: |
          # Replace with your migration tooling (Flyway, Prisma, Alembic, etc.)
          docker run --rm \
            -e DATABASE_URL="postgresql://${{ env.DB_USER }}:${{ secrets.DB_PASSWORD }}@${{ env.DB_HOST }}/${{ env.DB_NAME }}?schema=${{ env.SCHEMA_NAME }}" \
            my-migration-image:latest \
            migrate up

      # ----------------------------------------------------------------
      # 4️⃣ Deploy the Copilot service into the preview env
      # ----------------------------------------------------------------
      - name: Deploy service with Copilot
        run: |
          copilot svc deploy \
            --app ${{ env.APP_NAME }} \
            --name ${{ env.SERVICE_NAME }} \
            --env ${{ env.ENV_NAME }} \
            --image $ECR_REPOSITORY:pr-${{ env.PR_NUMBER }} \
            --env-vars \
              DB_HOST='${{ env.DB_HOST }}',\
              DB_USER='${{ env.DB_USER }}',\
              DB_NAME='${{ env.DB_NAME }}',\
              DB_SCHEMA='${{ env.SCHEMA_NAME }}',\
              NODE_ENV='development' \
            --secrets \
              DB_PASSWORD='secretsmanager:${{ secrets.DB_PASSWORD_SECRET_ID }}'

      # ----------------------------------------------------------------
      # 5️⃣ Retrieve the public URL of the service
      # ----------------------------------------------------------------
      - name: Get service URL
        id: url
        run: |
          URL=$(aws cloudformation describe-stacks \
            --stack-name ${{ env.APP_NAME }}-${{ env.ENV_NAME }}-svc-${{ env.SERVICE_NAME }} \
            --query 'Stacks[0].Outputs[?OutputKey==`URL`].OutputValue' \
            --output text)
          echo "DEPLOYED_URL=$URL" >> $GITHUB_ENV

      # ----------------------------------------------------------------
      # 6️⃣ Post the URL as a comment on the PR (so reviewers can click)
      # ----------------------------------------------------------------
      - name: Comment URL on PR
        uses: mshick/add-pr-comment@v2
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          message: |
            🚀 **Preview deployment ready!**

             **URL:** ${{ env.DEPLOYED_URL }}  
             **Environment:** ${{ env.ENV_NAME }}  
             **DB schema:** ${{ env.SCHEMA_NAME }}

            This environment will be torn down automatically when the PR is closed.
          allow-repeats: true   # Overwrites the comment on subsequent pushes

# =======================================================
# 2️⃣ Cleanup when the PR is closed
# =======================================================
  cleanup:
    if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Cleanup Preview Resources

    steps:
      # -------------------------------------------------------
      # Pull the PR number again for consistent naming
      # -------------------------------------------------------
      - name: Extract PR metadata
        run: |
          echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
          echo "ENV_NAME=preview-pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
          echo "SCHEMA_NAME=${{ env.SCHEMA_PREFIX }}_${{ github.event.pull_request.number }}" >> $GITHUB_ENV

      # -------------------------------------------------------
      # AWS credentials again (needed for delete actions)
      # -------------------------------------------------------
      - 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: ${{ env.AWS_REGION }}

      # -------------------------------------------------------
      # Install Copilot CLI (same as earlier)
      # -------------------------------------------------------
      - name: Install Copilot
        run: |
          curl -Lo copilot https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux
          chmod +x copilot
          sudo mv copilot /usr/local/bin/copilot

      # -------------------------------------------------------
      # Delete the preview environment (CloudFormation & resources)
      # -------------------------------------------------------
      - name: Delete Copilot environment
        run: |
          copilot env delete \
            --app ${{ env.APP_NAME }} \
            --name ${{ env.ENV_NAME }} \
            --yes || echo "Environment not found – maybe already deleted."

      # -------------------------------------------------------
      # Drop the temporary DB schema
      # -------------------------------------------------------
      - name: Drop DB schema
        run: |
          PGPASSWORD="${{ secrets.DB_PASSWORD }}" psql -h "${{ env.DB_HOST }}" -U "${{ env.DB_USER }}" -d "${{ env.DB_NAME }}" -c \
          "DROP SCHEMA IF EXISTS ${{ env.SCHEMA_NAME }} CASCADE;"
          echo "🗑️ Schema ${{ env.SCHEMA_NAME }} removed."

      # -------------------------------------------------------
      # Optional: delete the PR‑specific Docker image from ECR
      # -------------------------------------------------------
      - name: Delete PR image from ECR (optional)
        run: |
          aws ecr batch-delete-image \
            --repository-name my-frontend \
            --image-ids imageTag=pr-${{ env.PR_NUMBER }} \
            || echo "Image not found or already deleted."

What each block does:

Block Purpose
Extract PR metadata Normalizes the PR number, environment name, and schema name so all later steps refer to the same identifiers.
Install Copilot Makes copilot available in the runner.
Create preview environment Calls copilot env init with VPC/subnet imports to keep networking consistent across previews.
Clone database schema Drops any existing temporary schema and creates a fresh one (pr_preview_42).
Run DB migrations Executes your migration tool (Flyway, Prisma, etc.) against the newly created schema.
Deploy service Deploys the built Docker image to the preview environment, passing DB connection info via --env-vars and the password as a Secrets Manager secret (--secrets).
Get service URL Queries the CloudFormation stack for the URL output (generated by Copilot for Load‑Balanced services).
Comment URL on PR Posts the public endpoint directly on the PR so reviewers can click a link immediately.
Cleanup When the PR is closed, deletes the Copilot env, drops the schema, and optionally removes the PR image from ECR.

Why This Approach Works

Concern Solution
Isolation Each preview gets its own ECS service, ALB listener, and DB schema. No cross‑talk.
Security Secrets never leave GitHub; they are either passed via --env-vars (non‑secret) or via AWS Secrets Manager (--secrets).
Cost control Environments are deleted as soon as the PR closes, preventing runaway charges. You can also cap resources (e.g., Fargate Spot, low‑CPU task definitions) in the Copilot manifest.
Speed The entire flow completes in ~5‑10 minutes (image build + env creation + migration).
Developer experience URL appears automatically on the PR; no manual copilot commands required.

Optional Enhancements

Feature How to add it
Custom domain per preview Create a wildcard CNAME (*.preview.myapp.com) in Route 53. After deployment, add a custom_domains section in the Copilot manifest and use the --custom-domain flag.
IP‑allowlist for preview URLs Set the ALB’s security group to allow only the GitHub Actions runner IP range or a small CIDR block, or enable Cognito/OIDC integration on the ALB.
Parallel preview cleanup via Lambda Instead of CI cleanup, push the environment name to an SQS queue and have a Lambda delete it after a TTL.
Use SSM Parameter Store instead of Secrets Manager Change --secrets DB_PASSWORD='ssm:/myapp/preview/db-password'.
Run tests against the preview Add a job after deployment that hits the public URL with Cypress/SuperTest, storing results as a check on the PR.
Automatic rollback on migration failure Wrap migration in a Copilot job that runs before svc deploy. If the job fails, the workflow aborts and the environment can be deleted.

Conclusion

By combining AWS Copilot, GitHub Actions, and dynamic DB schema cloning, you get a fully automated preview pipeline that:

  • Guarantees isolation between PRs.
  • Keeps sensitive data out of logs and source code.
  • Provides instant, clickable URLs for stakeholders.
  • Cleans up automatically, preventing unnecessary spend.

Copy the workflow into your repository, customize the manifest and migration commands for your stack, and you’ll have a robust preview environment infrastructure up and running in minutes. Happy previewing!

Made with chatblogr.com