Automatic previews for Cloud Run on every pull request

Jeff Huleatt

portrait of Jeff Huleatt

One feature I love about web hosting platforms like Vercel, Netlify, and Firebase Hosting is the ability to automatically deploy temporary preview URLs for every pull request. This is helpful for demoing in-progress work to teammates and demonstrating correct functionality to PR reviewers. Recently, I’ve wanted this same functionality for web servers I’ve deployed to Cloud Run (like a Zig web server or static React), so I decided to wire up a solution using GitHub Actions.

A screenshot of a GitHub Action commenting a preview URL on a pull request

Setting up permissions

Workload Identity Federation (WIF) is the recommended way to authenticate to Google Cloud from within GitHub Actions. To be honest, I was intimidated the first time I looked at the docs for WIF, but each step is straightforward with the gcloud command line tool. Here’s the basic flow:

  1. Enable WIF APIs
  2. Create a deployer service account that only has permission to deploy Cloud Run services (following the principle of least privilege)
  3. Configure WIF
    1. Create a Workload Identity Pool
    2. Create an Identity Provider for my repo
    3. Allow a Workload Identity User to impersonate the deployer service account

Here’s a cheat sheet of the gcloud commands to run:

Set up WIF with the gcloud CLI
export PROJECT_ID="your-gcp-project-id"
export GITHUB_REPO="your-github-username/your-repo-name" # e.g. "jhuleatt/my-site"
export RUNTIME_SA_EMAIL="your-existing-cloud-run-runtime-sa@your-gcp-project-id.iam.gserviceaccount.com"
# Define names for the new resources to create
export DEPLOYER_SA_NAME="github-deployer"
export BUILDER_SA_NAME="cloud-run-builder"
export POOL_NAME="github-pool"
export PROVIDER_NAME="github-provider"
# Auto-resolve email addresses and project number
PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format="value(projectNumber)")
export DEPLOYER_SA_EMAIL="${DEPLOYER_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
export BUILDER_SA_EMAIL="${BUILDER_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"

# ==========================================
# ENABLE REQUIRED APIS
# ==========================================
gcloud services enable iamcredentials.googleapis.com sts.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com --project="${PROJECT_ID}"

# ==========================================
# CREATE DEPLOYER SERVICE ACCOUNT (CI/CD ORCHESTRATOR)
# ==========================================
# Create the service account that GitHub Actions will impersonate
gcloud iam service-accounts create "${DEPLOYER_SA_NAME}" \
  --project="${PROJECT_ID}" \
  --display-name="GitHub Actions Cloud Run Deployer"
# Grant the deployer SA permission to manage Cloud Run services in the project
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${DEPLOYER_SA_EMAIL}" \
  --role="roles/run.developer"
# Grant the deployer SA permission to run containers as the existing Runtime Service Account
gcloud iam service-accounts add-iam-policy-binding "${RUNTIME_SA_EMAIL}" \
  --project="${PROJECT_ID}" \
  --member="serviceAccount:${DEPLOYER_SA_EMAIL}" \
  --role="roles/iam.serviceAccountUser"
# Grant the deployer SA permission to trigger Cloud Build jobs
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${DEPLOYER_SA_EMAIL}" \
  --role="roles/cloudbuild.builds.editor"
# Grant the deployer SA permission to write to Artifact Registry
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${DEPLOYER_SA_EMAIL}" \
  --role="roles/artifactregistry.admin"
# Grant the deployer SA permission to create and manage Cloud Storage staging buckets
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${DEPLOYER_SA_EMAIL}" \
  --role="roles/storage.admin"

# ==========================================
# CREATE BUILDER SERVICE ACCOUNT (CONTAINER COMPILER)
# ==========================================
# Create dedicated user-managed builder service account (Google Cloud best practice)
gcloud iam service-accounts create "${BUILDER_SA_NAME}" \
  --project="${PROJECT_ID}" \
  --display-name="Cloud Run Preview Builder"
# Grant single comprehensive build role (Storage, Logging, Artifact Registry permissions)
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${BUILDER_SA_EMAIL}" \
  --role="roles/cloudbuild.builds.builder"
# Grant the deployer SA permission to act as the Builder SA when submitting builds
gcloud iam service-accounts add-iam-policy-binding "${BUILDER_SA_EMAIL}" \
  --project="${PROJECT_ID}" \
  --member="serviceAccount:${DEPLOYER_SA_EMAIL}" \
  --role="roles/iam.serviceAccountUser"


# ==========================================
# CONFIGURE WORKLOAD IDENTITY FEDERATION
# ==========================================
# Create the Workload Identity Pool
gcloud iam workload-identity-pools create "${POOL_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="GitHub Actions Pool"
# Create the OIDC Identity Provider with repository restriction
gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="${POOL_NAME}" \
  --display-name="GitHub Actions Provider" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository == '${GITHUB_REPO}'"
# Authorize the GitHub Action principal set to impersonate the Deployer Service Account
gcloud iam service-accounts add-iam-policy-binding "${DEPLOYER_SA_EMAIL}" \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_NAME}/attribute.repository/${GITHUB_REPO}"

A GitHub workflow file to deploy on every PR

This workflow file deploys a new service with name preview-<PR number>-<prod service name>. Importantly, it sets the maximum instances to 1 in order to prevent runaway scaling on a service that should never receive high traffic. Once the service has been deployed, the workflow comments the service URL and commit number into the PR so that reviewers can quickly open the preview.

preview-deploy.yml
name: Deploy Cloud Run Preview

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

env:
  PROJECT_ID: 'my-gcp-project-id'       # TODO: Replace with your Google Cloud Project ID
  PROJECT_NUMBER: '123456789012'        # TODO: Replace with your Google Cloud Project Number
  REGION: 'us-east4'                 # TODO: Replace with your Cloud Run region
  SERVICE_NAME: preview-${{ github.event.pull_request.number }}-my-service # TODO: Replace 'my-service' with your prod service name

# Automatically cancel an in-progress deploy if a newer commit is pushed to the same PR
concurrency:
  group: pr-preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  deploy-preview:
    name: Deploy Preview Service
    # SECURITY GUARD: Only run for branches inside this repository (ignore external forks)
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    permissions:
      contents: read          # Required for actions/checkout
      id-token: write         # Required for Google Workload Identity Federation (OIDC)
      pull-requests: write    # Required for gh pr comment

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          persist-credentials: false

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/${{ env.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: github-deployer@${{ env.PROJECT_ID }}.iam.gserviceaccount.com

      - name: Deploy preview service to Cloud Run
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          # TODO: Replace 'pr-preview-runtime' with your runtime SA name
          # Add --allow-unauthenticated if needed
          gcloud run deploy "$SERVICE_NAME" \
            --region="$REGION" \
            --source=. \
            --min-instances=0 \
            --max-instances=1 \
            --build-service-account="projects/${PROJECT_ID}/serviceAccounts/cloud-run-builder@${PROJECT_ID}.iam.gserviceaccount.com" \
            --service-account="pr-preview-runtime@${PROJECT_ID}.iam.gserviceaccount.com" \ 
            --update-labels="managed-by=github-actions,env=pr-preview,pr=$PR_NUMBER"

      - name: Comment Preview URL on PR
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
          REPO: ${{ github.repository }}
        run: |
          SERVICE_URL="https://${SERVICE_NAME}-${PROJECT_NUMBER}.${REGION}.run.app"
          
          MARKER="<!-- cloud-run-preview-comment -->"
          BODY="🚀 **Cloud Run Preview Deployed!**
          - **Preview URL:** $SERVICE_URL
          - **Commit SHA:** \`$COMMIT_SHA\`
          - **Service Name:** \`$SERVICE_NAME\`
          $MARKER"
          
          # Find existing comment ID containing the marker
          COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \
            --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null || echo "")
          
          if [ -n "$COMMENT_ID" ]; then
            gh api -X PATCH "repos/$REPO/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null
          else
            gh pr comment "$PR_NUMBER" --body "$BODY"
          fi

A screenshot of a GitHub pull request showing a comment from GitHub Actions with a preview URL and a commit sha in the comment that matches the latest commit on the pull request

Notice that the comment gets updated for every new commit, so that you always know which commit’s changes have been deployed to the preview service.

Cloud Run service name

You can get more creative with the Cloud Run service name than the preview-<PR number>-<prod service name> name in my workflow. For example, you could include the PR title in the service name with github.event.pull_request.title.

Whatever you pick, just remember that Google Cloud Run service names have a maximum length of 49 characters. If the service name is too long, the deploy will fail, so you’ll need to truncate the name yourself.

Cleaning up

The above workflow will create lots of Cloud Run services in an active GitHub repo. In order to prevent surprise costs, it is important to clean up resources that aren’t needed any more.

Clean up images in Artifact Registry

Every commit in every PR will create a new container in Artifact Registry. In order to stop Artifact Registry usage from growing continuously, configure an automated cleanup policy by following the Google Cloud documentation on setting up a cleanup policy.

If it’s important to keep a long history of images for the production Run service, you can create an aggressive delete policy for previews, with a conditional keep policy for production containers.

Clean up Run services

A screenshot of the Cloud Run console showing multiple preview services

After a month or two, there could be hundreds or even thousands of preview services, most of which are no longer useful, as the PR they were created alongside will have been merged or abandoned. While Cloud Run’s autoscaling should mean those zombie services don’t have any active instances, it is best to delete them when they’re not needed any more.

Since going in and manually deleting all these services would be a chore, I set up workflows to automatically delete my preview services, too. There are two times when previews should be automatically deleted: when a PR is merged or closed, and when a PR becomes stale.

Delete on PR close

The following workflow deletes the preview Cloud Run service for a PR when that PR is closed.

preview-delete.yml
name: Delete Cloud Run preview on PR close

on:
  pull_request:
    types: [closed]

env:
  PROJECT_ID: 'my-gcp-project-id'       # TODO: Replace with your Google Cloud Project ID
  PROJECT_NUMBER: '123456789012'        # TODO: Replace with your Google Cloud Project Number
  REGION: 'us-east4'                 # TODO: Replace with your Cloud Run region
  SERVICE_NAME: preview-${{ github.event.pull_request.number }}-my-service # TODO: Replace 'my-service' with your prod service name

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

jobs:
  delete-preview:
    name: Delete Preview Service
    # SECURITY GUARD: Only run for branches inside this repository (ignore external forks)
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    permissions:
      id-token: write         # Required for Google Workload Identity Federation

    steps:
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/${{ env.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: github-deployer@${{ env.PROJECT_ID }}.iam.gserviceaccount.com

      - name: Delete preview Cloud Run service
        run: |
          echo "Checking if service $SERVICE_NAME exists in region ${{ env.REGION }}..."
          
          if gcloud run services describe "$SERVICE_NAME" --region="${{ env.REGION }}" >/dev/null 2>&1; then
            echo "Deleting service $SERVICE_NAME..."
            gcloud run services delete "$SERVICE_NAME" --region="${{ env.REGION }}" --quiet
            echo "Successfully deleted $SERVICE_NAME"
          else
            echo "Service $SERVICE_NAME does not exist or was already deleted."
          fi

A screenshot of a GitHub Action run that has successfully cleaned up a Cloud Run service after a pull request was closed.

Delete when a PR is stale

Sometimes, PRs go stale. The definition of “stale” can differ between every project, but in this case I’ve implemented a cron job that runs once per day and deletes the preview Cloud Run service of any PR that has been inactive for 30 days or more. It also posts a comment on the PR explaining that the service has been cleaned up.

preview-cleanup.yml
name: Clean up inactive Cloud Run previews

on:
  schedule:
    - cron: '0 8 * * *'  # Runs once a day at 08:00 UTC
  workflow_dispatch:     # Allows running manually from the Actions tab

env:
  PROJECT_ID: 'my-gcp-project-id'       # TODO: Replace with your Google Cloud Project ID
  PROJECT_NUMBER: '123456789012'        # TODO: Replace with your Google Cloud Project Number
  REGION: 'us-east4'                 # TODO: Replace with your Cloud Run region
  PROD_SERVICE_NAME: 'my-service'       # TODO: Replace with your production Cloud Run service name

jobs:
  reap-inactive-previews:
    name: Reap Inactive Previews
    runs-on: ubuntu-latest
    permissions:
      id-token: write         # Required for Google Workload Identity Federation
      pull-requests: write    # Required to comment on inactive PRs

    steps:
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/${{ env.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: github-deployer@${{ env.PROJECT_ID }}.iam.gserviceaccount.com

      - name: Find and Delete Stale Preview Services
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Calculate timestamp for 30 days ago in seconds since epoch
          CUTOFF_SEC=$(date -d "30 days ago" +%s)
          echo "Searching for open PRs with no activity since $(date -d '30 days ago' --utc)"

          # Fetch all open PRs with number and last update time
          PRS=$(gh pr list --state open --limit 100 --json number,updatedAt)

          echo "$PRS" | jq -c '.[]' | while read -r pr; do
            PR_NUM=$(echo "$pr" | jq -r '.number')
            UPDATED_AT=$(echo "$pr" | jq -r '.updatedAt')
            UPDATED_SEC=$(date -d "$UPDATED_AT" +%s)

            if [ "$UPDATED_SEC" -lt "$CUTOFF_SEC" ]; then
              SERVICE_NAME="preview-${PR_NUM}-${PROD_SERVICE_NAME}"
              echo "PR #${PR_NUM} inactive since ${UPDATED_AT}. Checking service: ${SERVICE_NAME}..."

              if gcloud run services describe "$SERVICE_NAME" --region="${REGION}" >/dev/null 2>&1; then
                echo "Deleting stale preview service: ${SERVICE_NAME}..."
                gcloud run services delete "$SERVICE_NAME" --region="${REGION}" --quiet

                COMMENT_BODY="♻️ **Cloud Run Preview Service Cleanup**

                The preview service \`${SERVICE_NAME}\` was automatically deleted due to 30 days of inactivity on this PR.
                
                > **Note:** If you resume work on this PR, push a new commit to the branch and a fresh preview environment will be deployed automatically."

                echo "Commenting on PR #${PR_NUM}..."
                gh pr comment "$PR_NUM" --body "$COMMENT_BODY"
              else
                echo "Service ${SERVICE_NAME} is already deleted or was never deployed."
              fi
            fi
          done

The service can be re-created by pushing a new commit to the PR, which will trigger a new deployment from the preview workflow above.

Considerations

Contributions from external contributors

My example preview workflow only allows previews to be created from branches within the repository. It won’t run for pull requests from forks. You could lock this down even more by only allowing certain branches to activate the workflow. For example, you could have an alpha branch with its own branch protection rules as the only branch that triggers the workflow.

Or, if you do want to allow external contributors’ PRs to generate previews, turn on “Require approval for all external contributors” in the GitHub Actions settings for a repo. Letting anyone with a fork open a PR and deploy their code to Cloud Run in your Google Cloud project poses potential billing and security risks, but the extra approval step helps mitigate that a bit, as long as you’re only clicking “Approve” for PRs from contributors that you trust.

GitHub Actions vs Cloud Build

I chose to implement this in GitHub Actions, but I could have used Cloud Build instead. They each have their pros and cons.

The main benefit of GitHub Actions is being able to trigger the cleanup workflow when a PR closes, which Cloud Build doesn’t support. In Cloud Build, cleanup would have to happen entirely with the stale preview cron job. That’s not the end of the world, but I’d like the services to be deleted as quickly as possible after a PR is closed.

On the other hand, Cloud Build would have let me skip all of the Workload Identity Federation setup. That’s a big benefit when it comes to setting up previews quickly in a new repository.

Conclusion

It takes a few steps, but now I have a reliable way to set up automatic preview deployments for my Cloud Run web services. This workflow makes it easier to quickly try out and share new ideas without having to run any manual deploy commands myself, and, as a bonus, I learned about WIF along the way.

I hope this helps you set up a preview workflow for Cloud Run, too. If you want to see other approaches to previews with Cloud Run, take a look at these posts: