How to Deploy to Cloud Run with GitHub Actions: Step-by-Step (2026)
This guide is for engineers who want a GitHub repository to build a container and deploy it to Google Cloud Run on every push to main, without a service account key stored in GitHub Secrets. At the end you will have an Artifact Registry repository, a Workload Identity Federation provider locked to your repository's numeric ID, a workflow that pushes an image, deploys it as a tagged revision with no traffic, smoke-tests it and then promotes it, plus the commands to split traffic, roll back and attach a custom domain. Versions are current as of 31 October 2026: google-github-actions/auth v3.0.0, deploy-cloudrun v3.0.1, setup-gcloud v3.0.1, actions/checkout v7.0.1, docker/build-push-action v7.4.0, gcloud 591.0.0 and Go 1.27.3. You need a Google Cloud project with billing, Owner or equivalent rights to set it up once, the gcloud and gh CLIs, and a repository you control. If you are still choosing a platform, read Cloud Run vs App Runner vs Container Apps (2026) first.
Step 1: Set variables and enable the APIs
Everything below uses shell variables so you can paste the commands as they are. Get your repository's numeric IDs from the GitHub API; Google's deployment pipeline guide recommends the *_id claims over names because names can be re-registered by someone else after a rename or deletion.
export PROJECT_ID="my-project"
export PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
export REGION="europe-west1" # pick a region close to your users
export AR_REPO="apps" # Artifact Registry repository
export SERVICE="hello-api" # Cloud Run service name
export GITHUB_REPO="my-org/hello-api"
# Numeric IDs that never get reused, unlike org and repo names
gh api "repos/$GITHUB_REPO" --jq '{repository_id: .id, owner_id: .owner.id}'
For a public repository the output looks like this (this is google-github-actions/auth itself):
{"owner_id":71461757,"repository_id":407242578}
export REPO_ID="407242578" # replace with yours
export OWNER_ID="71461757" # replace with yours
gcloud services enable \
run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com \
iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \
cloudresourcemanager.googleapis.com \
--project="$PROJECT_ID"
iamcredentials is what lets GitHub impersonate a service account and sts is the token exchange behind Workload Identity Federation.
Step 2: Create an Artifact Registry repository
Container Registry (gcr.io) stopped accepting writes on 18 March 2025, so new pipelines push to Artifact Registry. Image names follow LOCATION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:TAG, and the deploy docs note that the repository must exist before you push.
gcloud artifacts repositories create "$AR_REPO" \
--repository-format=docker \
--location="$REGION" \
--description="Cloud Run images" \
--project="$PROJECT_ID"
Keep the repository in the same region as the service. Cross-region pulls work but add latency and egress to every cold start.
Step 3: Create the runtime and deployer service accounts
Use two identities. The runtime account is what your code runs as on Cloud Run; it gets only what the app needs (here, one secret). The deployer account is what GitHub Actions impersonates; it can push images and create revisions, and nothing else. Without a dedicated runtime account, Cloud Run falls back to the Compute Engine default service account, which the service identity docs warn may have been granted Editor on the whole project.
gcloud iam service-accounts create "${SERVICE}-run" \
--display-name="Runtime identity for $SERVICE" --project="$PROJECT_ID"
gcloud iam service-accounts create github-deployer \
--display-name="GitHub Actions deployer" --project="$PROJECT_ID"
export RUNTIME_SA="${SERVICE}-run@${PROJECT_ID}.iam.gserviceaccount.com"
export DEPLOYER_SA="github-deployer@${PROJECT_ID}.iam.gserviceaccount.com"
# Deployer can create and update Cloud Run services and revisions
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$DEPLOYER_SA" --role="roles/run.developer"
# Deployer can push to this one repository (writer includes reader)
gcloud artifacts repositories add-iam-policy-binding "$AR_REPO" \
--location="$REGION" --project="$PROJECT_ID" \
--member="serviceAccount:$DEPLOYER_SA" --role="roles/artifactregistry.writer"
# Deployer may attach the runtime identity to revisions (iam.serviceAccounts.actAs)
gcloud iam service-accounts add-iam-policy-binding "$RUNTIME_SA" \
--project="$PROJECT_ID" \
--member="serviceAccount:$DEPLOYER_SA" --role="roles/iam.serviceAccountUser"
The deploy-cloudrun README asks for roles/run.admin, but the Cloud Run deploy docs list Cloud Run Developer plus Service Account User on the service identity. Admin adds the right to change IAM policies on services, which a pipeline should not need: the README itself recommends that CI never toggles public access.
Step 4: Set up Workload Identity Federation for GitHub
GitHub's OIDC issuer is shared by every repository on github.com, so Google warns that you must add an attribute condition restricting tokens to your organisation. I go one step further and also require the main branch, so a workflow on a feature branch or a pull request cannot mint a deploy token at all.
gcloud iam workload-identity-pools create github \
--project="$PROJECT_ID" --location=global \
--display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc "$SERVICE-repo" \
--project="$PROJECT_ID" --location=global --workload-identity-pool=github \
--display-name="$SERVICE repo" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_id=assertion.repository_id,attribute.repository_owner_id=assertion.repository_owner_id,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository_owner_id == '${OWNER_ID}' && assertion.ref == 'refs/heads/main'"
# Only this repository (by numeric ID) may impersonate the deployer
gcloud iam service-accounts add-iam-policy-binding "$DEPLOYER_SA" \
--project="$PROJECT_ID" --role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github/attribute.repository_id/${REPO_ID}"
# The provider resource name goes into the workflow
gcloud iam workload-identity-pools providers describe "$SERVICE-repo" \
--project="$PROJECT_ID" --location=global --workload-identity-pool=github \
--format="value(name)"
Expected output, with your project number:
projects/123456789/locations/global/workloadIdentityPools/github/providers/hello-api-repo
You can map a claim only if it exists in the token, and the auth action's troubleshooting page points out that Google compares attribute values case-sensitively while GitHub does not, one more reason to use numeric IDs. Pool, provider and IAM changes can take up to five minutes to propagate. The auth README calls Direct Workload Identity Federation (granting roles to the principalSet itself, no service account) the preferred setup; I use a deployer account here because docker/login-action needs an OAuth access token, and the action only mints one when you give it a service account. For the concepts behind all this, see Workload Identity Federation Explained.
Store the non-secret values as repository variables:
gh variable set GCP_PROJECT_ID --repo "$GITHUB_REPO" --body "$PROJECT_ID"
gh variable set GCP_REGION --repo "$GITHUB_REPO" --body "$REGION"
gh variable set GCP_WIF_PROVIDER --repo "$GITHUB_REPO" --body "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github/providers/${SERVICE}-repo"
gh variable set GCP_DEPLOYER_SA --repo "$GITHUB_REPO" --body "$DEPLOYER_SA"
gh variable set GCP_RUNTIME_SA --repo "$GITHUB_REPO" --body "$RUNTIME_SA"
Step 5: Store secrets in Secret Manager
Do not put application secrets in GitHub at all. Put them in Secret Manager, grant the runtime account access, and reference them from the deploy. The secrets docs recommend pinning a version for environment variables, because Cloud Run resolves the value once when the instance starts and refuses to start the instance if it cannot.
printf '%s' 'replace-me' | gcloud secrets create db-password \
--data-file=- --replication-policy=automatic --project="$PROJECT_ID"
gcloud secrets add-iam-policy-binding db-password \
--project="$PROJECT_ID" \
--member="serviceAccount:$RUNTIME_SA" \
--role="roles/secretmanager.secretAccessor"
The deployer does not need access to the secret value. Only the runtime account reads it.
Step 6: Bootstrap the Cloud Run service once
The workflow deploys every revision with --no-traffic, and gcloud's own source rejects that flag on a service that does not exist yet (--no-traffic not supported when creating a new service.). So create the service once by hand with Google's sample image, which listens on $PORT.
gcloud run deploy "$SERVICE" \
--image=us-docker.pkg.dev/cloudrun/container/hello \
--region="$REGION" --project="$PROJECT_ID" \
--service-account="$RUNTIME_SA" \
--no-allow-unauthenticated
# Public web app? Grant invoke to everyone, once, as a human, not from CI.
gcloud run services add-iam-policy-binding "$SERVICE" \
--region="$REGION" --project="$PROJECT_ID" \
--member="allUsers" --role="roles/run.invoker"
If an organisation policy blocks allUsers (domain-restricted sharing), redeploy with --no-invoker-iam-check instead, which the gcloud reference documents as disabling the invoker check. For an internal API, skip both and call it with an ID token.
Step 7: Add the app and Dockerfile
Two rules from the container runtime contract cause most first-deploy failures: listen on the port in the PORT environment variable (8080 by default), and listen on 0.0.0.0, not 127.0.0.1. Binaries must be built for 64-bit Linux, and Cloud Run sends SIGTERM ten seconds before SIGKILL.
// main.go: Go 1.26 or later, standard library only
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
port := os.Getenv("PORT") // injected by Cloud Run
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// K_REVISION tells you which revision answered
fmt.Fprintf(w, "hello from %s (env=%s)\n", os.Getenv("K_REVISION"), os.Getenv("APP_ENV"))
})
srv := &http.Server{Addr: ":" + port, Handler: mux} // ":" + port = all interfaces
go func() {
log.Printf("listening on :%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, os.Interrupt)
<-stop // Cloud Run allows 10 s between SIGTERM and SIGKILL
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
// go.mod
module example.com/hello-api
go 1.26
Run it locally first. This is the real output from my machine (Go 1.26.1; the Dockerfile builds with 1.27.3):
PORT=9091 APP_ENV=local go run . &
curl -s localhost:9091/healthz
curl -s localhost:9091/
2026/10/24 16:09:01 listening on :9091
ok
hello from (env=local)
K_REVISION is empty locally and set on Cloud Run. The Dockerfile is a two-stage build onto distroless, which the image size guide explains in more depth:
# syntax=docker/dockerfile:1
FROM golang:1.27.3 AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
# Cloud Run needs linux/amd64; set it explicitly so Apple Silicon builds still work
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/hello-api .
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/hello-api /hello-api
USER nonroot:nonroot
ENTRYPOINT ["/hello-api"]
Step 8: Write the GitHub Actions workflow
Save this as .github/workflows/deploy.yml. Actions are pinned to commit SHAs with the version in a comment, which is the habit GitHub Actions Security in 2026 argues for; I resolved each SHA from the tag with gh api.
name: deploy-cloud-run
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: cloud-run-production # one deploy at a time, never cancel a deploy midway
cancel-in-progress: false
env:
SERVICE: hello-api
IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/apps/hello-api
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
permissions:
contents: read
id-token: write # lets the job request a GitHub OIDC token
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: auth
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
project_id: ${{ vars.GCP_PROJECT_ID }}
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOYER_SA }}
token_format: access_token # needed for docker login below
- uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
with:
version: '591.0.0'
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ vars.GCP_REGION }}-docker.pkg.dev
username: oauth2accesstoken
password: ${{ steps.auth.outputs.access_token }}
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- id: deploy
uses: google-github-actions/deploy-cloudrun@2028e2d7d30a78c6910e0632e48dd561b064884d # v3.0.1
with:
service: ${{ env.SERVICE }}
region: ${{ vars.GCP_REGION }}
image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} # deploy the exact digest we built
gcloud_version: '591.0.0'
env_vars: |-
APP_ENV=production
secrets: |-
DB_PASSWORD=db-password:1
tag: candidate # stable URL for the newest revision
no_traffic: true # users stay on the current revision for now
flags: --service-account=${{ vars.GCP_RUNTIME_SA }} --max-instances=10 --cpu-boost
- name: Smoke test the candidate revision
run: curl --fail --silent --show-error --retry 5 --retry-all-errors "${{ steps.deploy.outputs.url }}/healthz"
- name: Promote to 100% of traffic
run: |
gcloud run services update-traffic "$SERVICE" \
--region="${{ vars.GCP_REGION }}" --to-latest
A few details matter. The url output points at the tag URL when you pass tag, which I confirmed in the action's v3.0.1 source; without a tag it is the service URL. env_vars and secrets merge with what is already on the service by default; set env_vars_update_strategy: overwrite if you want the workflow to be the only source of truth. The GitHub OIDC token itself expires after about five minutes per the auth README, but the access token the action mints for the deployer defaults to one hour (access_token_lifetime), which covers a slow build and push.
Push to main and watch the run. The deploy step logs the exact gcloud run deploy command it ran, which is the first thing to read when something fails.
Step 9: Canary, promote and roll back revisions
Every deploy creates an immutable revision. Because the workflow deploys with --no-traffic, the rollouts docs apply: traffic stays pinned to whatever you last set until you move it. For a gradual rollout instead of the all-at-once promote, replace the last workflow step with manual commands:
# See revisions and where traffic goes now
gcloud run revisions list --service="$SERVICE" --region="$REGION"
gcloud run services describe "$SERVICE" --region="$REGION" --format="yaml(status.traffic)"
# Send 10% to the newest revision (tagged candidate), then all of it
gcloud run services update-traffic "$SERVICE" --region="$REGION" --to-tags=candidate=10
gcloud run services update-traffic "$SERVICE" --region="$REGION" --to-latest
# Roll back: 100% to a known-good revision, takes effect without a rebuild
gcloud run services update-traffic "$SERVICE" --region="$REGION" \
--to-revisions=hello-api-00042-abc=100
Rollback only moves routing; it does not rerun health checks or undo a database migration, so keep schema changes backward compatible across at least one revision. If you outgrow percentage splits and want analysis-driven canaries, the same ideas in Kubernetes are covered in How to Do Canary Deployments with Argo Rollouts.
Step 10: Add a custom domain
Google's custom domain page offers three routes. A global external Application Load Balancer is the recommended one for production (your own certificates, Cloud CDN, Cloud Armor). Cloud Run domain mappings are still Preview, are not recommended for production, and only exist in ten regions including europe-west1 and us-central1. Firebase Hosting is the cheap option. There are also short cloud.run custom URLs, in preview since September 2026, which need no DNS at all.
The quick route, for a side project in a supported region:
gcloud domains verify example.com # one-time ownership check in Search Console
gcloud beta run domain-mappings create --service="$SERVICE" --domain=api.example.com --region="$REGION"
gcloud beta run domain-mappings describe --domain=api.example.com --region="$REGION"
# Create the A/AAAA or CNAME records listed under resourceRecords at your DNS provider
The certificate usually provisions in about 15 minutes and can take up to 24 hours. The production route:
gcloud compute network-endpoint-groups create "$SERVICE-neg" --region="$REGION" \
--network-endpoint-type=serverless --cloud-run-service="$SERVICE"
gcloud compute backend-services create "$SERVICE-backend" --global --load-balancing-scheme=EXTERNAL_MANAGED
gcloud compute backend-services add-backend "$SERVICE-backend" --global \
--network-endpoint-group="$SERVICE-neg" --network-endpoint-group-region="$REGION"
gcloud compute url-maps create "$SERVICE-urlmap" --default-service="$SERVICE-backend"
gcloud compute ssl-certificates create "$SERVICE-cert" --domains=api.example.com --global
gcloud compute target-https-proxies create "$SERVICE-proxy" --url-map="$SERVICE-urlmap" --ssl-certificates="$SERVICE-cert"
gcloud compute addresses create "$SERVICE-ip" --global
gcloud compute forwarding-rules create "$SERVICE-https" --global --load-balancing-scheme=EXTERNAL_MANAGED \
--target-https-proxy="$SERVICE-proxy" --address="$SERVICE-ip" --ports=443
# Then point api.example.com at the address, and stop traffic bypassing the LB:
gcloud run services update "$SERVICE" --region="$REGION" --ingress=internal-and-cloud-load-balancing
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Container failed to start. Failed to start and then listen on the port defined by the PORT environment variable. |
App binds 127.0.0.1 or a hard-coded port, or crashes on start |
Listen on 0.0.0.0:$PORT; read the revision logs with gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="hello-api"' --limit=50 |
exec format error in revision logs |
Image built for arm64 on an Apple Silicon laptop | Build with platforms: linux/amd64 or GOARCH=amd64; the contract requires linux/amd64 in a multi-arch manifest |
GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN or $ACTIONS_ID_TOKEN_REQUEST_URL into this job |
Missing id-token: write, or the run comes from a fork |
Add the permissions block to the job; do not deploy from fork PRs |
Failed to generate Google Cloud federated token |
Attribute condition rejected the token: wrong owner ID, branch is not main, provider name has the project ID instead of the number |
Check the condition and the full provider resource name; wait five minutes after changes |
Failed to generate Google Cloud OAuth 2.0 Access Token |
No roles/iam.workloadIdentityUser binding for this principalSet, or wrong repository ID |
Re-run the binding in Step 4 with the numeric repository ID |
Permission "artifactregistry.repositories.uploadArtifacts" denied on push |
Deployer lacks writer on the repository, or the registry host's region differs from the repository's | Grant roles/artifactregistry.writer on the repo; make REGION-docker.pkg.dev match its location |
Permission 'iam.serviceaccounts.actAs' denied on service account |
Deployer cannot attach the runtime identity | Grant roles/iam.serviceAccountUser on the runtime account (Step 3) |
The request failed because the instance could not start successfully. |
Runtime account cannot read the secret, or the secret version does not exist | Grant roles/secretmanager.secretAccessor on the secret; check db-password:1 exists |
| New revision deployed but users still see the old one | --no-traffic pins traffic, and later deploys keep the split |
Run gcloud run services update-traffic SERVICE --to-latest |
HTTP 403: The request was not authenticated |
Service is private and the caller has no invoker role | Add the allUsers invoker binding once, use --no-invoker-iam-check, or send an ID token |
The exact error strings come from Cloud Run's troubleshooting page and the auth action's v3.0.0 source.
FAQ
Should I use Workload Identity Federation or a service account key?
Use Workload Identity Federation. A key is a long-lived credential that sits in GitHub Secrets until someone rotates it, while the federated flow issues tokens that expire within an hour and only for runs that match your attribute condition. Keys are only worth it for tools that cannot use federation.
Do I need roles/run.admin to deploy to Cloud Run from GitHub Actions?
No. Cloud Run's deploy docs list Cloud Run Developer on the service and Service Account User on the runtime identity, plus read access to the image. Admin is only needed if the pipeline changes IAM policies, for example to make a service public, and Google recommends CI does not do that.
Why does Cloud Run say the container failed to start on PORT 8080?
The container did not accept connections on the port in $PORT within the startup window, which the container contract sets at four minutes. The usual causes are binding to 127.0.0.1, a hard-coded port, an arm64 image, or the app crashing on a missing environment variable or secret. Run the image locally with PORT=8080 and read the revision logs.
How do I roll back a Cloud Run deployment?
Send all traffic to the previous revision with gcloud run services update-traffic SERVICE --to-revisions=REVISION=100. It changes routing immediately without rebuilding anything. Find revision names with gcloud run revisions list --service=SERVICE.
When a Cloud Run pipeline breaks, the first thing I check is which identity actually ran the failing command, because nine times out of ten the error is a missing binding on the right account rather than anything wrong with the code. In production I keep exactly this split of a runtime account and a deployer account per service, the provider condition locked to main and the numeric repository ID, every revision deployed dark behind a tag, and rollback as a single command someone has practised. The official references are the deploy-cloudrun README, the auth README, the Cloud Run container contract, rollouts and rollbacks and Secret Manager secrets on Cloud Run.
Related: How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026), Workload Identity Federation Explained (2026): OIDC Instead of Cloud Keys and GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up Nginx as a Reverse Proxy With Let's Encrypt (Ubuntu 24.04)
Step-by-step Nginx reverse proxy on Ubuntu 24.04 with Let's Encrypt: server blocks, proxy headers, WebSockets, certbot 5.9 and the nginx plugin, renewal timers, HTTP/2 and HSTS, the 2026 certificate changes, and fixes for 502 and permission errors.
14 minOct 31, 2026How to Host a Static Website on S3 and CloudFront with HTTPS (2026)
Step-by-step 2026 guide to hosting a static site or SPA on a private S3 bucket behind CloudFront with HTTPS: Origin Access Control, an ACM certificate in us-east-1, a CloudFront Function for routing, Route 53, Terraform, cache invalidation, and fixes for AccessDenied 403 errors.
12 minOct 20, 2026How to Self-Host n8n with Docker Compose in Queue Mode (2026)
Step-by-step n8n queue mode setup on Docker Compose: Postgres, Redis, a webhook processor, workers with task runner sidecars, the encryption key, N8N_WEBHOOK_URL, TLS with Caddy, backups, upgrades and patching. Tested on n8n 2.43.8.
16 min