Blog
github-actions

Preview Deployments for Static Sites with GitHub Actions + Cloudflare

How I built automated PR preview deployments for a static Astro site using GitHub Actions commit statuses and Cloudflare Workers.

Mladen Ruzicic
Mladen Ruzicic
8 min

When reviewing a PR, I want to see the changes live. Not a screenshot, not a description. The actual rendered page.

I built a preview deployment system that deploys every PR to an isolated environment and puts a link right in the PR.

The problem

Code review for a static site is awkward. You read the diff, imagine what it looks like, approve, merge, wait for deploy, then check production.

If something looks wrong, you’ve already shipped it.

What I wanted:

  • Every PR gets its own preview URL
  • Preview reflects the exact state of the PR
  • Reviewers see the link without hunting for it
  • Status shows whether the preview is ready

GitHub’s deployment primitives

GitHub provides three relevant APIs:

Commit Status: The colored check marks next to commits. Can be pending, success, failure, or error. Shows in PR checks section.

Deployments: Records of where code was deployed. Creates entries in the “Environments” section of the repo.

Deployment Status: Updates for a deployment (in_progress, success, failure). Can include an environment URL.

I use all three to create a complete experience:

flowchart TD
    A[PR opened] --> B[Commit status: pending]
    A --> C[Deployment created]

    B --> D[Build & deploy]
    C --> D

    D --> E[Commit status: success + URL]
    D --> F[Deployment status: success]
    D --> G[PR comment: Preview link table]

    style A fill:#e3f2fd
    style E fill:#c8e6c9
    style F fill:#c8e6c9
    style G fill:#c8e6c9

The workflow

Here’s the core of preview.yml:

name: Preview Deployment

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

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

jobs:
  preview:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      deployments: write
      statuses: write

The concurrency block is important. If I push multiple commits quickly, only the last one matters. Cancel in-progress builds to save resources.

Setting commit status

First step: tell reviewers a preview is building.

- name: Set commit status - pending
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.repos.createCommitStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        sha: '${{ github.event.pull_request.head.sha }}',
        state: 'pending',
        target_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
        description: 'Building preview...',
        context: 'Preview Deployment'
      });

The context field is the name shown in the checks list. The target_url links to the Actions run while building, then the preview URL once deployed.

Creating a deployment

Deployments are GitHub’s way of tracking where code goes:

- name: Create deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: '${{ github.event.pull_request.head.sha }}',
        environment: 'Preview',
        auto_merge: false,
        required_contexts: [],
        description: 'Preview for PR #${{ github.event.pull_request.number }}'
      });

      const id = deployment.data.id;
      core.setOutput('deployment_id', id);

      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: id,
        state: 'in_progress',
        description: 'Building and deploying preview...'
      });

The required_contexts: [] skips status checks. Preview deploys shouldn’t wait for tests.

Building with CDN fallback

Here’s the key optimization: previews don’t regenerate existing assets.

- name: Build
  env:
    PUBLIC_CDN_URL: https://cdn.fontalternatives.com
  run: |
    npm run precompute
    npm run build:html

The PUBLIC_CDN_URL tells the build to reference production CDN for images. New fonts in the PR get their assets uploaded to a preview namespace, but existing fonts use production assets.

This turns a 1-hour full build into a 2-minute HTML-only build. Each preview also runs through the three-level validation system before going live.

Deploying to Cloudflare

I use a separate Workers environment for previews:

- name: Deploy preview
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    command: deploy --env preview

The wrangler.jsonc defines the preview environment:

{
  "name": "fontalternatives",
  "env": {
    "preview": {
      "name": "fontalternatives-preview",
      "routes": [
        {
          "pattern": "fontalternatives-preview.ruzicic.workers.dev",
          "custom_domain": false
        }
      ]
    }
  }
}

All PRs deploy to the same preview worker. The HTML is replaced on each deploy. Since assets live on R2/CDN, there’s no conflict.

The PR comment

After deployment, post a comment with the preview link:

- name: Comment preview URL
  uses: actions/github-script@v7
  with:
    script: |
      const prNumber = ${{ github.event.pull_request.number }};
      const previewUrl = 'https://fontalternatives-preview.ruzicic.workers.dev';
      const sha = '${{ github.event.pull_request.head.sha }}'.substring(0, 7);

      const body = `## Preview Deployment

      | Status | URL |
      |--------|-----|
      | Ready | [${previewUrl}](${previewUrl}) |

      **Commit:** \`${sha}\``;

      // Find existing comment to update
      const { data: comments } = await github.rest.issues.listComments({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: prNumber,
      });

      const botComment = comments.find(c =>
        c.user?.login === 'github-actions[bot]' &&
        c.body?.includes('Preview Deployment')
      );

      if (botComment) {
        await github.rest.issues.updateComment({
          owner: context.repo.owner,
          repo: context.repo.repo,
          comment_id: botComment.id,
          body: body,
        });
      } else {
        await github.rest.issues.createComment({
          owner: context.repo.owner,
          repo: context.repo.repo,
          issue_number: prNumber,
          body: body,
        });
      }

Updating the existing comment (instead of creating new ones) keeps the PR clean when pushing multiple commits.

Font-specific preview URLs

For font request PRs, I include a direct link to the new font page:

- name: Update PR description
  uses: actions/github-script@v7
  with:
    script: |
      const { data: pr } = await github.rest.pulls.get({
        owner: context.repo.owner,
        repo: context.repo.repo,
        pull_number: prNumber,
      });

      let body = pr.body || '';

      // Extract font slug from branch name
      const branchMatch = pr.head.ref.match(/^font-request\/(.+)$/);
      const fontSlug = branchMatch ? branchMatch[1] : null;

      if (fontSlug && body.includes('<!-- PREVIEW_URL_PLACEHOLDER -->')) {
        const fontPageUrl = `${previewUrl}/alternatives/${fontSlug}/`;
        const newSection = `| Status | Preview | Font Page |
        |--------|---------|-----------|
        | Ready | [Site](${previewUrl}) | [/alternatives/${fontSlug}/](${fontPageUrl}) |`;

        body = body.replace('<!-- PREVIEW_URL_PLACEHOLDER -->', newSection);

        await github.rest.pulls.update({
          owner: context.repo.owner,
          repo: context.repo.repo,
          pull_number: prNumber,
          body: body,
        });
      }

The placeholder in the PR template gets replaced with the actual preview table.

Finalizing status

After successful deploy, update both commit status and deployment:

- name: Set commit status - success
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.repos.createCommitStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        sha: '${{ github.event.pull_request.head.sha }}',
        state: 'success',
        target_url: '${{ env.PREVIEW_URL }}',
        description: 'Preview ready',
        context: 'Preview Deployment'
      });

- name: Update deployment status - success
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: ${{ steps.deployment.outputs.deployment_id }},
        state: 'success',
        environment_url: '${{ env.PREVIEW_URL }}',
        description: 'Preview deployed successfully'
      });

The environment_url in deployment status creates the “View deployment” button in GitHub.

Handling failures

If the build fails, update status accordingly:

- name: Set commit status - failure
  if: failure()
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.repos.createCommitStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        sha: '${{ github.event.pull_request.head.sha }}',
        state: 'failure',
        target_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
        description: 'Preview deployment failed',
        context: 'Preview Deployment'
      });

The if: failure() condition runs this step only when previous steps failed.

The reviewer experience

When someone opens a PR:

  1. They see “Preview Deployment: pending” in the checks
  2. A few minutes later, it turns green with “Preview ready”
  3. Clicking the status goes directly to the preview site
  4. The PR comment has a formatted table with links
  5. For font PRs, there’s a direct link to the new font page

No hunting through Actions logs. No copying URLs from build output. The link is right there.

Smart production builds

The preview system pays dividends at merge time. When a PR is merged, the production workflow detects that preview assets already exist and skips regeneration:

- name: Check for preview assets
  run: |
    PROMOTED=$(npx tsx scripts/promote-all-previews.ts --count-only)
    echo "promoted_count=$PROMOTED" >> $GITHUB_OUTPUT

- name: Determine build type
  run: |
    if [ "$PROMOTED" != "0" ]; then
      echo "type=html" >> $GITHUB_OUTPUT  # Fast path
    elif [ "$CONTENT_CHANGED" = "true" ]; then
      echo "type=full" >> $GITHUB_OUTPUT  # New fonts need assets
    else
      echo "type=html" >> $GITHUB_OUTPUT  # No changes
    fi

The decision flow:

ScenarioBuild TypeTime
PR merged (has preview assets)HTML-only~2-3 min
Direct push (no new fonts)HTML-only~2-3 min
Direct push (new fonts)Full~12-15 min
Manual full build triggerFull~12-15 min

Preview assets are promoted before the build:

- name: Promote preview assets
  if: steps.check-preview.outputs.promoted_count != '0'
  run: npx tsx scripts/promote-all-previews.ts
  # Copies pr/{N}/* to production root, updates manifest, deletes pr/{N}/

This means most production deploys complete in under 3 minutes instead of 30+.

Tradeoffs

What I gained:

  • Visual review before merge
  • Confidence in changes
  • Faster review cycles (no back-and-forth about appearance)
  • Fast production deploys (~2-3 min when preview exists)

What I lost:

  • Build time per PR (~2-3 minutes)
  • Cloudflare Workers usage (minimal, free tier)
  • Complexity in the workflow

Decisions I’d reconsider:

  • Single preview environment means only one PR can be previewed at a time. For a solo project, this is fine. For a team, you’d want per-PR environments.
  • Asset isolation could be better. Currently, new assets go to pr/{number}/ in R2, but cleanup happens on PR close. Old preview assets can accumulate.

The complete flow

flowchart TD
    A[PR opened] --> B[Workflow triggered]
    B --> C[Set pending status]
    C --> D[Create deployment]

    subgraph build[Build Phase]
        D --> E[Checkout & build]
        E --> F[npm run build:html]
        E --> G[Upload new assets to R2]
    end

    F --> H[Deploy to Cloudflare Workers]
    G --> H

    subgraph status[Status Updates]
        H --> I[Update commit status: success]
        I --> J[Update deployment status: success]
        J --> K[Post/update PR comment]
    end

    K --> L[Reviewer clicks preview link]

    style A fill:#e3f2fd
    style L fill:#c8e6c9

Total time from push to preview: under 3 minutes.

Explore on FontAlternatives

#github-actions#cloudflare-workers#ci-cd#deployment

More from the blog