GitHub Actions to Cloud Run: Keyless Deploys, PR Previews & Rollbacks
Tutorials ci/cd github actions cloud run google cloud devops

GitHub Actions to Cloud Run: Keyless Deploys, PR Previews & Rollbacks

D. Rout

D. Rout

September 25, 2026 13 min read

On this page

Most "deploy to Cloud Run from GitHub Actions" guides stop at gcloud run deploy with a service-account key pasted into GitHub Secrets. That works right up until the key leaks, a bad build takes 100% of traffic, or someone asks "can I see this PR running somewhere?" This tutorial builds the pipeline I actually want in production: no stored credentials at all, every revision smoke-tested at its own URL before it gets traffic, a preview URL for every pull request, and a rollback button. The complete, working project is on GitHub at github-actions-cloud-run-pipeline.

If you're new to workflows, jobs and triggers, start with GitHub Actions: Automate Everything From Day One. This post assumes those basics and goes deep on the Google Cloud side, which is where most of the real-world failures happen.

What we're building

Three workflows around one small Express + TypeScript service:

Workflow Trigger What it does
deploy.yml push to main test → build image → deploy revision at 0% traffic → smoke test its tag URL → promote to 100%
preview.yml pull request deploy a pr-<number> tagged revision (0% traffic), comment the URL on the PR, remove the tag when the PR closes
rollback.yml manual route 100% of traffic to any revision you name

The GitHub repo stores zero secrets. GitHub mints a short-lived OIDC token per job, and Google's Security Token Service exchanges it for a short-lived credential. Everything the workflows need (project ID, region, provider path) goes in plain repository variables.

Prerequisites

  • A Google Cloud project with billing enabled, plus the gcloud CLI logged in as an Owner (needed only for the one-time setup)
  • A GitHub repository you control
  • Node.js 22+ locally
  • Working knowledge of Docker images and multi-stage builds. If you need a refresher, Docker: A Practical Guide for Developers covers everything used here.

Step 1: Build a service that behaves well on Cloud Run

Cloud Run's container runtime contract asks three things of your process. It must listen on the port in $PORT. It must be built for linux/amd64. And it must handle SIGTERM, because Cloud Run sends SIGTERM and then SIGKILLs 10 seconds later whenever it retires an instance. The sample app is a minimal Express API in the style of Developing a REST API with Express.js and TypeScript, with a /healthz route, a /version route and graceful shutdown:

// src/server.ts
import { createApp } from './app';

const port = Number(process.env.PORT ?? 8080); // never hard-code
const server = createApp().listen(port);

function shutdown(signal: string) {
  console.log(JSON.stringify({ severity: 'INFO', message: `${signal} received, draining` }));
  server.close(() => process.exit(0));          // finish in-flight requests
  setTimeout(() => process.exit(1), 8000).unref(); // stay inside the 10s window
}
process.on('SIGTERM', () => shutdown('SIGTERM'));

/version returns GIT_SHA, which the pipeline sets at deploy time. The smoke test uses it to prove a revision is running the commit we just built, not merely that something is up.

The gotcha I measured: npm start swallows SIGTERM

Graceful shutdown code does nothing if the signal never reaches Node. I benchmarked four common entrypoints with scripts/bench-startup.mjs (in the repo). It measures spawn-to-first-200 on /healthz, sends SIGTERM to the top-level process, and checks whether the app logged "draining". Setup: 20 runs each on a 2 vCPU Linux VM, Node 22.22.2, npm 10.9.7.

Entrypoint Median startup p90 Graceful SIGTERM
CMD ["node", "dist/server.js"] 134 ms 157 ms 20/20
CMD node dist/server.js (shell form → sh -c) 119 ms 138 ms 0/20
CMD ["npm", "start"] 262 ms 362 ms 0/20
npx tsx src/server.ts 709 ms 745 ms 0/20

Two findings. First, npm start roughly doubles time-to-ready, and that lands directly on cold-start latency. Second, and worse, only the exec-form node entrypoint shut down gracefully. With npm start, npm received the signal, and the Node child kept running as an orphan. The benchmark harness actually filled up with stray servers until I added process-group cleanup. On Cloud Run that means every scale-in or revision swap hard-kills requests in flight. Plain shell form started fast but also never delivered the signal. (These are local numbers. Absolute Cloud Run timings will differ, but the ratios and the signal behaviour are the point.)

Opinionated rule: exec form, node as PID 1, compiled JavaScript. No npm, no shell, no TypeScript runner in production.

Step 2: Write a Dockerfile for CI, not just for your laptop

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The separate deps stage matters more than it looks. For this app, node_modules is 31 MB with dev dependencies and 4.5 MB without, a 7× difference. Most of that is the TypeScript compiler (23 MB), which has no business in a runtime image. Keep lockfile-only COPY lines above the source copy so the npm ci layers stay cached between commits.

Also add gha-creds-*.json to .dockerignore and .gitignore. The auth action writes a credentials file into the workspace, and COPY . . will happily bake it into your image.

Step 3: One-time GCP setup with Workload Identity Federation

This is the step that replaces the JSON key. Run infra/setup-gcp.sh from the repo, or follow the core commands:

# Pool + GitHub OIDC provider, locked to ONE repository
gcloud iam workload-identity-pools create github --location=global

gcloud iam workload-identity-pools providers create-oidc github-oidc \
  --location=global --workload-identity-pool=github \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository == 'deepakrout/github-actions-cloud-run-pipeline'"

# Only workflows from that repo may impersonate the deployer SA
gcloud iam service-accounts add-iam-policy-binding \
  gha-deployer@$PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/deepakrout/github-actions-cloud-run-pipeline"

The script also creates two service accounts, deliberately kept apart:

  • gha-deployer is what CI impersonates. It gets roles/run.admin, roles/artifactregistry.writer on the one Docker repo, and roles/iam.serviceAccountUser on the runtime SA only.
  • hello-pipeline-runtime is what the container runs as, and it starts with no roles. If you skip --service-account on deploy, Cloud Run falls back to the Compute Engine default SA. That SA is often a project Editor, and your deployer then needs actAs on it too.

WIF: impersonation or direct access?

The google-github-actions/auth action supports three auth modes. Here's how they compare for Cloud Run specifically:

SA key JSON Direct WIF (no SA) WIF + SA impersonation
Long-lived secret stored in GitHub Yes, never expires by default No No
Can mint ID tokens (call private services) Yes No Yes
Can mint OAuth access tokens Yes No Yes
Blast radius if repo is compromised Everything the key can do, forever That repo's grants, per job That repo's grants, per job
Setup effort Lowest Medium Medium
My pick for Cloud Run Never Public services, simple cases Default

The row that decides it is ID tokens. The auth action's docs state plainly that generating access or ID tokens requires a service account email. The first time you smoke-test a private Cloud Run service (--no-allow-unauthenticated), you need an ID token for the tag URL, and direct WIF can't mint one. Starting with impersonation saves you a migration later.

Step 4: The deploy workflow (stage, smoke test, promote)

Set the seven variables the setup script prints (GCP_PROJECT_ID, GCP_REGION, GAR_REPOSITORY, CLOUD_RUN_SERVICE, GCP_WIF_PROVIDER, GCP_DEPLOYER_SA, GCP_RUNTIME_SA) under Settings → Secrets and variables → Actions → Variables. Then the heart of deploy.yml:

concurrency:
  group: deploy-production
  cancel-in-progress: false      # never kill a deploy halfway through a traffic shift

jobs:
  deploy:
    needs: test
    environment: production
    permissions:
      contents: read
      id-token: write            # without this: "Unable to get ACTIONS_ID_TOKEN_REQUEST_URL"
    steps:
      - uses: actions/checkout@v7          # must come BEFORE auth
      - uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_DEPLOYER_SA }}
      - uses: google-github-actions/setup-gcloud@v3
      - run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
      - uses: docker/setup-buildx-action@v4
      - uses: docker/build-push-action@v7
        with:
          push: true
          platforms: linux/amd64   # an arm64 image from a Mac fails to start on Cloud Run
          tags: ${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Tag images with the full commit SHA, never :latest. That makes every revision traceable and makes rollback a traffic change instead of a rebuild.

The deploy step handles a gotcha that breaks most "blue/green on Cloud Run" examples. --no-traffic is rejected when the service doesn't exist yet, so the very first run of a naive pipeline fails:

TAG="sha-${GITHUB_SHA::7}"
if gcloud run services describe "$SERVICE" --region "$REGION" >/dev/null 2>&1; then
  gcloud run deploy "$SERVICE" "${COMMON_FLAGS[@]}" --no-traffic --tag "$TAG"
else
  gcloud run deploy "$SERVICE" "${COMMON_FLAGS[@]}" --tag "$TAG"   # first deploy
fi

REVISION=$(gcloud run services describe "$SERVICE" --region "$REGION" \
  --format='value(status.latestCreatedRevisionName)')
TAG_URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format=json \
  | jq -r --arg t "$TAG" '.status.traffic[] | select(.tag==$t) | .url')

A tagged revision gets its own URL (https://sha-abc1234---hello-pipeline-….run.app) while serving zero production traffic. The smoke test hits that URL and checks both health and identity:

curl -fsS "${TAG_URL}/healthz" >/dev/null
ACTUAL_SHA=$(curl -fsS "${TAG_URL}/version" | jq -r .commit)
[ "$ACTUAL_SHA" = "$GITHUB_SHA" ] || { echo "::error::wrong revision"; exit 1; }

Only if that passes does the workflow promote, by revision name, explicitly:

gcloud run services update-traffic "$SERVICE" --region "$REGION" \
  --to-revisions "${REVISION}=100"
gcloud run services update-traffic "$SERVICE" --region "$REGION" \
  --remove-tags "$TAG"

I pin to the revision name rather than --to-latest on purpose. With PR previews creating revisions in parallel, "latest" is not necessarily the commit you just tested. If the smoke test fails, the job stops, and production never sees the bad revision.

Step 5: Preview URLs for every pull request

The preview workflow reuses the same trick with a stable tag per PR:

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

jobs:
  preview:
    if: >-
      github.event.action != 'closed' &&
      github.event.pull_request.head.repo.full_name == github.repository
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    steps:
      # ...auth, build, push as above...
      - run: |
          gcloud run deploy "$SERVICE" --image "$IMAGE:$TAG-$SHA" \
            --region "$REGION" --no-traffic --tag "pr-${{ github.event.pull_request.number }}" --quiet
      - env: { GH_TOKEN: "${{ github.token }}" }
        run: |
          gh pr comment "$PR" --edit-last --create-if-none \
            --body "🚀 Preview for \`$SHA\`: $URL"

Three details make this production-safe:

  1. --edit-last --create-if-none updates a single comment instead of adding a new one on every push.
  2. The fork guard (head.repo.full_name == github.repository). Fork PRs don't get an OIDC token on pull_request, so without the guard those runs fail noisily. Don't "fix" this with pull_request_target. That runs untrusted fork code with your cloud credentials, and actions/checkout v7 (with backports to older majors enforced since July 20, 2026) now refuses the common unsafe checkout patterns in exactly that setup.
  3. A closed job that runs --remove-tags pr-<n>. Without it, abandoned previews keep their public URLs indefinitely.

One caveat to be honest about: with --allow-unauthenticated, preview tag URLs are publicly reachable by anyone who has the link. For internal services, deploy without it and have the smoke test use an ID token from the impersonated SA.

Step 6: Rollback in one click

Because every image is immutable and every revision is kept, rollback is just a traffic change:

on:
  workflow_dispatch:
    inputs:
      revision:
        description: "Revision to route 100% traffic to"
        required: true
# ...
- run: |
    gcloud run revisions describe "$TARGET" --region "$REGION" --format='value(metadata.name)'
    gcloud run services update-traffic "$SERVICE" --region "$REGION" --to-revisions "${TARGET}=100"

It shares the deploy-production concurrency group, so a rollback can't race a deploy. I made the revision input required rather than auto-picking "the previous one". Once PR previews exist, the revision created just before the current one is often a preview, not the last good production build. Auto-rollback to an unreviewed PR is a worse incident than the one you're fixing.

Troubleshooting reference

The errors you're most likely to hit with this setup, what causes them, and the fix:

Error message (abridged) Cause Fix
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL No id-token: write, or fork PR Add the permission at job level; guard fork PRs
The given credential is rejected by the attribute condition Condition doesn't match owner/repo exactly (case, renamed repo) Update --attribute-condition on the provider
Permission 'iam.serviceAccounts.getAccessToken' denied principalSet lacks workloadIdentityUser on the deployer SA Re-run the binding with the project number, not ID
Permission "artifactregistry.repositories.uploadArtifacts" denied Missing artifactregistry.writer, or configure-docker region ≠ image host Grant on the repo; match REGION-docker.pkg.dev
Permission 'iam.serviceaccounts.actAs' denied Deployer can't act as the runtime SA Grant serviceAccountUser on the runtime SA
--no-traffic not supported when creating a new service First deploy with --no-traffic Detect a missing service and deploy normally once
Setting IAM policy failed on --allow-unauthenticated Deployer has run.developer, or org policy blocks allUsers Use run.admin, or grant invoker separately
container failed to start and listen on the port defined by the PORT Hard-coded port, crash on boot, or arm64 image Read $PORT; set platforms: linux/amd64; check logs

What's next

  • Gradual rollouts: replace the single =100 promotion with 10% → 50% → 100% steps, and gate each on error-rate metrics from Cloud Monitoring.
  • Private services: switch to --no-allow-unauthenticated, mint an ID token with token_format: id_token in the auth step, and pass it to the smoke test.
  • Deploy an SSR frontend the same way: an Angular SSR server is just another Node container, so the app from Build a Hybrid-Rendered Angular App: SSR, SSG & Hydration drops straight into this pipeline.
  • Image hygiene: add an Artifact Registry cleanup policy so SHA-tagged images and PR images don't pile up. If you outgrow Cloud Run, see how the same concepts map onto Kubernetes.

Further reading

Wrapping up

You now have a Cloud Run pipeline with no secrets to rotate, no untested revision ever taking traffic, a live URL for every PR, and a rollback that takes seconds. The most valuable part is the smallest: making sure SIGTERM actually reaches your process, so all of that zero-downtime machinery doesn't end in a hard kill. Clone the full project, including the setup script, all three workflows and the startup benchmark, from github-actions-cloud-run-pipeline. Run node scripts/bench-startup.mjs against your own service, and if your numbers differ, open an issue. I'd like to collect results across setups.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!