All posts
github-actionspreview-environmentsci-cddevops

GitHub Actions Preview Environment: The 200-Line DIY vs the 3-Click Setup

PreviewDrop Team·May 20, 2026·12 min read

You're three hours into a GitHub Actions workflow. The YAML file is 187 lines and growing. You've configured a self-hosted runner, an OIDC provider for AWS, a Docker registry, a Cloudflare DNS record, and a cleanup cron job that you're 60% sure will orphan $200 worth of EC2 instances by next month.

All you wanted was a temporary URL so your designer could see the button color change.

This is the reality of DIY preview environments in 2026. GitHub Actions is incredibly powerful, but using it to build branch previews is like assembling furniture with a Swiss Army knife — possible, impressive to watch, and almost certainly going to leave you with extra screws and a nagging sense that something is structurally unsound.

In this post, we'll build the full DIY solution first — every line of YAML, every gotcha, every "oh wait I forgot the cleanup job" moment. Then we'll show the alternative that takes 3 clicks. By the end, you'll know exactly what 200 lines of YAML buys you, and whether it's worth it.


The DIY approach: GitHub Actions + Docker + Cloudflare

Here's the setup we're building:

  1. On every PR opened or pushed, GitHub Actions builds a Docker image
  2. The image is pushed to a container registry (AWS ECR in this example)
  3. A container is spun up on an EC2 instance
  4. A Cloudflare DNS record points a subdomain to the instance
  5. A comment is posted on the PR with the preview URL
  6. On PR close, everything is torn down

Sounds straightforward. Let's build it.


Step 1: The workflow dispatcher

First, we need a workflow that triggers on PR events:

# .github/workflows/preview-deploy.yml
name: Preview Deploy
on:
  pull_request:
    types: [opened, synchronize, reopened]
  pull_request:
    types: [closed]

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

jobs:
  deploy:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-oidc
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
          ECR_REPOSITORY: myapp-preview
          IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
          echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Deploy to EC2
        env:
          IMAGE: ${{ steps.build-push.outputs.image }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          # Create unique container name per PR
          CONTAINER_NAME="preview-pr-$PR_NUMBER"
          PREVIEW_PORT=$((3000 + PR_NUMBER))

          # Pull and run on the EC2 host via SSM
          aws ssm send-command \
            --instance-ids i-0abc123def456789 \
            --document-name AWS-RunShellScript \
            --parameters commands="[
              'docker stop $CONTAINER_NAME || true',
              'docker rm $CONTAINER_NAME || true',
              'docker pull $IMAGE',
              'docker run -d --name $CONTAINER_NAME -p $PREVIEW_PORT:3000 \\
                -e DATABASE_URL=\$DATABASE_URL \\
                -e PREVIEW_URL=https://pr-$PR_NUMBER.preview.myapp.com \\
                --restart unless-stopped \\
                $IMAGE'
            ]"

      - name: Update Cloudflare DNS
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          EC2_IP: ${{ secrets.EC2_PUBLIC_IP }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
        run: |
          # Create or update A record for this PR
          curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records" \
            -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
            -H "Content-Type: application/json" \
            --data "{
              \"type\": \"A\",
              \"name\": \"pr-$PR_NUMBER.preview\",
              \"content\": \"$EC2_IP\",
              \"ttl\": 120,
              \"proxied\": false
            }"

      - name: Comment on PR
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = context.payload.pull_request.number;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: `🚀 Preview deployed: https://pr-${prNumber}.preview.myapp.com\n\n⏱ Build time: ~4 min`
            });

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-oidc
          aws-region: us-east-1

      - name: Tear down preview
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          CONTAINER_NAME="preview-pr-$PR_NUMBER"
          aws ssm send-command \
            --instance-ids i-0abc123def456789 \
            --document-name AWS-RunShellScript \
            --parameters commands="[
              'docker stop $CONTAINER_NAME || true',
              'docker rm $CONTAINER_NAME || true',
              'docker image prune -af --filter label=pr=$PR_NUMBER || true'
            ]"

      - name: Remove Cloudflare DNS
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
        run: |
          RECORD_ID=$(curl -s -X GET \
            "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records?name=pr-$PR_NUMBER.preview.myapp.com" \
            -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
            | jq -r '.result[0].id')
          if [ "$RECORD_ID" != "null" ]; then
            curl -s -X DELETE \
              "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records/$RECORD_ID" \
              -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
          fi

That's 120 lines before we've even addressed DNS propagation delays, SSL certificates, load balancer configuration, or the fact that running docker stop via SSM is genuinely the worst way to manage containers.


Step 2: The infrastructure prerequisites

Before this workflow runs, you need:

  • An AWS account with ECR, EC2, and SSM configured
  • An IAM role for GitHub's OIDC provider (that's 40 more lines of Terraform or a very careful afternoon in the AWS console)
  • An EC2 instance running Docker, with the SSM agent installed and a security group that allows inbound traffic on ports 3000-3100
  • A Cloudflare zone with API token permissions for DNS record management
  • GitHub repository secrets for EC2_PUBLIC_IP, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, and AWS_*
  • A wildcard TLS certificate (*.preview.myapp.com) or a Traefik instance on the EC2 box to handle Let's Encrypt automatically

None of this is hard. All of it is tedious. And every piece is a potential failure point when your designer is waiting for that button color change.


Step 3: The cleanup problem

The workflow above tears down containers on PR close. But what about:

  • A PR that's force-pushed? The old container keeps running because cancel-in-progress kills the workflow mid-execution.
  • The EC2 instance running out of disk space because docker image prune only runs on PR close?
  • A dev opens 6 PRs in a day, and your EC2 instance now has 6 containers fighting for a single CPU core?

The real solution is a scheduled cleanup job:

# .github/workflows/preview-cleanup.yml
name: Preview Cleanup Cron
on:
  schedule:
    - cron: '0 */6 * * *'   # Every 6 hours
  workflow_dispatch:

jobs:
  cleanup-orphans:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-oidc
          aws-region: us-east-1

      - name: Find and remove orphaned previews
        run: |
          # List running preview containers
          CONTAINERS=$(aws ssm send-command \
            --instance-ids i-0abc123def456789 \
            --document-name AWS-RunShellScript \
            --parameters commands="['docker ps --filter name=preview-pr --format \"{{.Names}}\"']" \
            --query 'CommandInvocations[0].CommandPlugins[0].Output' \
            --output text)

          for CONTAINER in $CONTAINERS; do
            PR_NUMBER=$(echo $CONTAINER | grep -oP '\d+')
            # Check if PR is still open
            PR_STATE=$(gh pr view $PR_NUMBER --json state -q '.state' 2>/dev/null || echo "CLOSED")
            if [ "$PR_STATE" = "CLOSED" ] || [ "$PR_STATE" = "MERGED" ]; then
              echo "Cleaning up orphaned preview for PR #$PR_NUMBER"
              docker stop $CONTAINER && docker rm $CONTAINER
            fi
          done

That's another 30 lines. We're now at ~180 lines of YAML, plus Terraform configs, plus secret management, plus the inevitable debugging sessions when Cloudflare's API rate-limits your DNS updates.


Step 4: Where it goes wrong (war stories)

Every team that's built this has a version of these stories:

The DNS propagation race: Cloudflare creates the A record, but the PR comment posts the URL immediately. The designer clicks it, gets NXDOMAIN, refreshes three times, then Slack-messages you: "The preview link is broken." DNS TTL is 120 seconds. It works fine. They just didn't wait.

The port collision: Two PRs get PR_NUMBER that are 1024 apart but have the same port offset because someone forgot to start at a high enough base. docker run -p 3004:3000 fails because port 3004 is already bound to a Node dev server someone left running.

The ECR auth expiration: AWS ECR tokens expire after 12 hours. If a build runs for 13 hours (unlikely, but it happens with large monorepo installs), the push step fails. The fix: aws ecr get-login-password --region us-east-1 | docker login... at the start of every step. One more line of YAML.

The "works on my machine" divergence: The GitHub Actions runner uses ubuntu-latest (Ubuntu 24.04 as of 2026). Your EC2 instance runs Amazon Linux 2023. The Docker image builds fine on the runner but hits a glibc mismatch on the instance. You spend 2 hours debugging before realizing you need --platform linux/amd64 in the build step.


The 3-step alternative

Now let's compare. Here's the same outcome — a live HTTPS URL for every branch — with no YAML required:

Step 1: Connect your GitHub repo.

Log into PreviewDrop, click "New Project," authorize the GitHub app, select your repo. This is one click and one authorization screen.

Step 2: Add environment variables.

Paste your .env.preview file into the environment variables section. PreviewDrop replaces the ${PREVIEW_URL} token at deploy time. If you have secrets (API keys, database URLs), they're encrypted at rest and injected only at runtime.

Step 3: Open a PR.

That's it. Push a branch, open a PR, and within minutes you get a comment with a live HTTPS URL. Merge the PR, the preview is automatically torn down. No DNS records, no EC2 instances, no cron jobs, no debugging port collisions at 11 PM.


Feature comparison

| Capability | GitHub Actions DIY | PreviewDrop | |---|---|---| | Setup time | 4-8 hours | 90 seconds | | Lines of YAML | 200+ | 0 | | Infrastructure to manage | EC2, ECR, Cloudflare, IAM, SSM | None | | HTTPS by default | No — requires Traefik/nginx + cert management | Yes — automatic TLS | | Multi-framework support | Whatever your Dockerfile builds | Auto-detects Next.js, Rails, Django, FastAPI, Laravel, SvelteKit, and 20+ more | | Concurrent previews | Limited by EC2 capacity | 2 on free tier, unlimited on paid | | DNS propagation wait | 120+ seconds (Cloudflare TTL) | Instant (subdomain pre-provisioned) | | Cleanup reliability | Manual cron + orphan detection | Automatic on PR merge/close | | Cost (excluding compute) | EC2 ~$20-30/month always-on | Free for 2 concurrent previews |


When should you DIY?

The GitHub Actions approach makes sense if:

  • You already have a Kubernetes cluster with ArgoCD or Flux managing deployments
  • Your compliance requirements mean every container must run in your VPC
  • You have a dedicated platform engineer who enjoys maintaining this pipeline

For everyone else — the startup CTO, the freelance developer, the team lead who just wants designers to see button color changes — the 3-click approach is the right call.


One more thing: the framework auto-detection advantage

Here's what you don't have to configure with a managed platform:

# PreviewDrop auto-detects and runs the right build command for:
# Next.js     → next build && next start
# Django      → python manage.py collectstatic && gunicorn myapp.wsgi
# Rails       → bundle exec rails assets:precompile && bundle exec puma
# FastAPI     → uvicorn main:app --host 0.0.0.0 --port 3000
# Laravel     → php artisan optimize && php artisan serve
# SvelteKit   → vite build && node build
# Astro       → astro build && astro preview

In the DIY approach, every framework change means updating your Dockerfile, testing the build pipeline, and possibly updating the base image. With auto-detection, it just works — same as Heroku did in 2016, but for the modern stack.


The bottom line

The DIY GitHub Actions workflow is a great learning project. You'll understand OIDC, ECR, SSM, DNS propagation, Docker networking, and the quiet despair of a 3 AM PagerDuty alert for a preview environment.

But if your goal is shipping software — not maintaining infrastructure — the 3-click approach is the right call. Your designer sees the button color change in 4 minutes instead of 4 hours, your velocity goes up, and you never debug a port collision again.

Want to learn more about how branch preview environments work under the hood? Check out our definitive guide to branch preview environments.


Ready to stop writing YAML and start shipping? Start a free trial → No credit card, no YAML, no EC2 instances to babysit.

Ready to give every branch a live URL?

Free tier — 2 concurrent previews, no credit card required.

Start free