Cloud Run Canary Deployment: 13 Steps, 75 Min [2026]

A canary deployment on Google Cloud Run sends a new build to a small slice of real users before everyone gets it. Cloud Run makes this cheap because every deploy already creates an immutable revision, and traffic can be split between revisions with one command. This tutorial walks through 13 steps, about 75 minutes of hands-on time, and ends with a Cloud Build pipeline that ships a shadow revision, tests it, shifts traffic in stages, and rolls back on its own when the numbers go bad.

Containers keep absorbing more workloads. Gartner predicted that more than 75% of AI and machine-learning deployments would run on container technology by 2027, up from fewer than 50% in 2024. More containers means more releases, and more releases means more chances to break production on a Friday. If you already followed our Google Cloud Run setup walkthrough, this guide picks up where it stops: the release process, not the first deploy.

One clarification up front. Google’s October 2026 guidance on continuous evaluation uses the terms live and shadow revisions, but a shadow revision is not a built-in Cloud Run mode that mirrors production traffic. It is a pattern. You deploy with --no-traffic, give the revision a tag, and call it through its tagged URL while it receives 0% of configured traffic. Cloud Build orchestrates the steps, and a plain Cloud Run traffic-update command does the promotion. Nothing in this tutorial depends on a preview feature.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Canary Deployment Fits Cloud Run So Well

Most platforms make you bolt on a service mesh or an ingress controller before you can split traffic by percentage. Cloud Run ships the primitive. Each deployment creates a new revision that holds a container image plus its configuration, and each service has a stable run.app URL that routes to whichever revisions you pick. A revision can receive 0% of production traffic and still answer requests on a tagged revision URL. That one fact unlocks both the shadow test and the percentage-based canary.

Compare that with running the same pattern on Kubernetes. The Google Kubernetes Engine setup guide shows how much machinery a cluster needs before you can even think about weighted routing. For teams weighing managed options, our Fargate vs Cloud Run vs Container Apps comparison covers how the three serverless container platforms handle revisions and traffic.

Before building anything, it helps to see where a canary sits among the common release strategies. The table below compares four of them on the dimensions that matter once real users are involved.

StrategyHow traffic movesBlast radius if the release is badRollback speedCloud Run fit
Big-bang100% switches to the new revision at onceAll usersFast, but after the damageDefault behavior of gcloud run deploy
Blue-greenOld and new run side by side, then flip to 100%All users after the flipFast (flip back)Tag the new revision, then promote
CanarySmall share first, then stagesOnly the canary shareFast (set share to 0)Native through revision traffic splits
Shadow (0% revision)No production traffic, only test traffic on a tagged URLNoneNothing to roll backNative through --no-traffic and tags

The strongest setup combines the last two rows. The shadow revision catches crashes, bad configuration and broken health checks without exposing anyone. The canary then catches the problems only real traffic reveals, such as a slow query under production data or a client that sends an odd header. We build both stages here.

What the pipeline will do

  • Build the container image with Cloud Build and push it to Artifact Registry.
  • Deploy a new revision with --no-traffic and a unique tag, so production users never see it.
  • Run a gate script against the tagged URL that checks status codes, error rate and latency.
  • Shift 5% of traffic to the revision, watch it, then move to 25%, 50% and 100%.
  • Roll back to the previous revision automatically if any stage fails its check.

Cloud Build does the orchestration. It does not split traffic itself. The split is a Cloud Run operation, and Cloud Build just calls the gcloud command at the right moment. Keeping that division clear makes debugging far easier later. Cloud Deploy, Google’s delivery-pipeline service, can also target Cloud Run, but the documented shadow pattern does not require it, so this guide skips it.

Prerequisites and Versions

You need a Google Cloud project with billing enabled, a terminal, and about 75 minutes. Everything below runs on a laptop, in Cloud Shell, or on a CI box. The sample service is a small Node.js API, but the pipeline does not care which language your real service uses. Google’s release notes list patched Node.js runtimes at 22.23.4, 24.22.1 and 26.12.1, so the tutorial targets the Node.js 24 line for the container image.

RequirementVersion or levelWhy you need it
Google Cloud projectBilling enabledCloud Run, Cloud Build and Artifact Registry all bill to a project
Google Cloud CLI (gcloud)Latest release (run gcloud components update)Deploys revisions, tags them and edits traffic
Node.js24 LTS lineRuns the sample API locally
Python3.10 or newerRuns the gate script (standard library only)
DockerOptionalOnly for local image tests, because Cloud Build builds remotely
IAM role on your userOwner, or Editor plus Project IAM AdminNeeded once to enable APIs and grant roles
Shellbash or zshCommands use bash variable syntax

Check your tooling before you start. The commands below print the versions you have installed and should return without errors.

gcloud --version
node --version
python3 --version
gcloud auth list
gcloud config get-value project

A note on naming. Google’s documentation now calls Cloud Functions Cloud Run functions, so older tutorials that mention Cloud Functions describe the same product line you use here. If you only need a single function, it deploys into the same revision and traffic system covered in this guide.

Set Up the Project, Registry and Service Accounts

The first three steps prepare the Google Cloud side. They take about 15 minutes, and most of that is waiting for API enablement. Do them in order, because the later steps assume the registry and service accounts exist.

Step 1: Set variables and enable the APIs

Pick a project ID and a region, then export them as shell variables so every later command stays copy-paste safe. The tutorial uses us-central1, but any Cloud Run region works. Enabling the APIs is a one-time cost and does not bill you by itself.

export PROJECT_ID="your-project-id"
export REGION="us-central1"
export SERVICE="ledger-api"
export REPO="canary-demo"

gcloud config set project "$PROJECT_ID"
gcloud config set run/region "$REGION"

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com \
  logging.googleapis.com \
  monitoring.googleapis.com

Expected output is a line reading Operation "operations/..." finished successfully. If the command hangs for more than two minutes, check that billing is attached to the project, because Cloud Build refuses to enable without it.

Step 2: Create an Artifact Registry repository

Cloud Run pulls container images from Artifact Registry. Source-based deploys would create a cloud-run-source-deploy repository for you, but a canary pipeline benefits from a repository you control, with a name you can reference in IAM and cleanup policies. Create a Docker-format repository in the same region as the service to avoid cross-region pull latency and egress charges.

gcloud artifacts repositories create "$REPO" \
  --repository-format=docker \
  --location="$REGION" \
  --description="Canary tutorial images"

gcloud artifacts repositories list --location="$REGION"

Note that stored images can incur Artifact Registry storage charges even when the service scales to zero. Google’s documentation points to its pricing calculator for estimates, and Step 13 shows how to delete old images so the bill stays flat.

Step 3: Create two service accounts with least privilege

Use two identities. The runtime account is what your container runs as, and it should hold almost no roles. The builder account is what Cloud Build uses to push images and move traffic, so it needs more. Splitting them means a compromised container cannot rewrite your traffic split.

gcloud iam service-accounts create canary-runtime \
  --display-name="Canary demo runtime"
gcloud iam service-accounts create canary-builder \
  --display-name="Canary demo builder"

RUNTIME_SA="canary-runtime@${PROJECT_ID}.iam.gserviceaccount.com"
BUILDER_SA="canary-builder@${PROJECT_ID}.iam.gserviceaccount.com"

for ROLE in roles/run.admin roles/artifactregistry.writer roles/logging.logWriter roles/logging.viewer; do
  gcloud projects add-iam-policy-binding "$PROJECT_ID" \
    --member="serviceAccount:${BUILDER_SA}" --role="$ROLE"
done

gcloud iam service-accounts add-iam-policy-binding "$RUNTIME_SA" \
  --member="serviceAccount:${BUILDER_SA}" \
  --role="roles/iam.serviceAccountUser"

The last binding is the one people forget. Deploying a revision that runs as another service account requires the deployer to hold iam.serviceAccountUser on that account. Without it, Step 11 fails with a permission error that looks unrelated to IAM. The table below lists which identity needs which role.

IdentityRoleScopeReason
canary-builderroles/run.adminProjectDeploy revisions, set tags, update traffic
canary-builderroles/artifactregistry.writerProjectPush built images
canary-builderroles/logging.logWriterProjectWrite build logs when using a custom account
canary-builderroles/logging.viewerProjectLet the pipeline read error logs for the new revision
canary-builderroles/iam.serviceAccountUserOn canary-runtime onlyAllow deploys that run as the runtime account
canary-runtimeNone by defaultNot applicableThe demo app calls no other Google APIs

Your runtime account may need Artifact Registry Reader if the image lives in a different project. In this tutorial the registry sits in the same project, so Cloud Run can pull the image without extra grants.

Build the Sample Service That Can Fail on Demand

A canary pipeline is only believable if you watch it catch a bad release. The sample service therefore has two knobs controlled by environment variables: FAIL_RATE makes a share of /checkout calls return HTTP 500, and LATENCY_MS adds artificial delay. Version 1 runs clean. Version 2 will ship with a deliberate fault so you can see the gate stop it.

Step 4: Write the application

The app uses only Node’s built-in http module, so there is no npm install step and no dependency to patch. It prints one JSON line per request. Cloud Logging parses the severity and message fields automatically, and Cloud Run injects K_REVISION so every log line names the revision that served it. That field is what lets you separate canary logs from baseline logs later.

// app/server.js
const http = require("node:http");

const PORT = Number(process.env.PORT || 8080);
const VERSION = process.env.APP_VERSION || "dev";
const REVISION = process.env.K_REVISION || "local";
const FAIL_RATE = Number(process.env.FAIL_RATE || 0);
const LATENCY_MS = Number(process.env.LATENCY_MS || 0);

function log(severity, message, extra = {}) {
  console.log(JSON.stringify({ severity, message, revision: REVISION, version: VERSION, ...extra }));
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const server = http.createServer(async (req, res) => {
  const started = Date.now();
  const send = (status, body) => {
    res.writeHead(status, { "content-type": "application/json" });
    res.end(JSON.stringify(body));
    log(status >= 500 ? "ERROR" : "INFO", "request", {
      path: req.url, status, ms: Date.now() - started,
    });
  };

  if (req.url === "/healthz") return send(200, { ok: true });
  if (req.url === "/version") return send(200, { version: VERSION, revision: REVISION });

  if (req.url.startsWith("/checkout")) {
    if (LATENCY_MS > 0) await sleep(LATENCY_MS);
    if (Math.random() < FAIL_RATE) return send(500, { error: "simulated failure" });
    return send(200, { order: "accepted", version: VERSION });
  }

  return send(404, { error: "not found" });
});

server.listen(PORT, () => log("INFO", "listening", { port: PORT }));
process.on("SIGTERM", () => server.close(() => process.exit(0)));

Two details matter for Cloud Run. The server must listen on the port named in the PORT variable, and it should handle SIGTERM so in-flight requests finish when an instance shuts down. Missing either one produces confusing failures during traffic shifts, which the troubleshooting table covers later.

Step 5: Add the Dockerfile and test locally

The Dockerfile uses a slim Node 24 base image and drops to the unprivileged node user. Rebuild the image regularly. Google patched its Node.js, Go, Java, Python and Ruby runtimes in October 2026 and told users to rebuild and redeploy to pick the fixes up, so a pinned image that never rebuilds drifts behind.

# app/Dockerfile
FROM node:24-slim
WORKDIR /app
COPY server.js .
ENV NODE_ENV=production
USER node
EXPOSE 8080
CMD ["node", "server.js"]

Run the app on your machine first. Local testing costs nothing and catches typos before they burn a Cloud Build minute.

cd app
APP_VERSION=1.0.0 node server.js &
curl -s localhost:8080/version
curl -s localhost:8080/checkout
kill %1

You should see {"version":"1.0.0","revision":"local"} and {"order":"accepted","version":"1.0.0"}. If the second call prints an error instead, check that FAIL_RATE is unset in your shell.

Deploy Version 1 as the Production Baseline

A canary needs something to compare against. This section ships a stable revision that takes 100% of traffic. You build it once by hand here, and from Step 11 onward the pipeline does the same work for you.

Step 6: Build the image and deploy the first revision

Build with Cloud Build so you do not need Docker locally. The image path follows the Artifact Registry format REGION-docker.pkg.dev/PROJECT/REPO/IMAGE:TAG. Tag images with the version number, never with latest, because a canary pipeline must know exactly which bytes each revision runs.

export IMAGE_BASE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${SERVICE}"

gcloud builds submit app --tag "${IMAGE_BASE}:1.0.0"

gcloud run deploy "$SERVICE" \
  --image "${IMAGE_BASE}:1.0.0" \
  --service-account "$RUNTIME_SA" \
  --allow-unauthenticated \
  --port 8080 \
  --max-instances 10 \
  --update-env-vars APP_VERSION=1.0.0

The deploy command ends with output similar to the block below. The service URL is stable across every future revision, and users never need to know it changed.

Deploying container to Cloud Run service [ledger-api] in project [your-project-id] region [us-central1]
OK Deploying new service... Done.
  OK Creating Revision...
  OK Routing traffic...
  OK Setting IAM Policy...
Done.
Service [ledger-api] revision [ledger-api-00001-abc] has been deployed and is serving 100 percent of traffic.
Service URL: https://ledger-api-xxxxxxxxxx-uc.a.run.app

The --allow-unauthenticated flag keeps the demo simple. Some organizations block it through a domain-restricted sharing policy. If yours does, skip the flag and send an identity token with each request, a technique the Advanced Tips section covers.

Step 7: Confirm the baseline is healthy

Capture the service URL into a variable and hit the endpoints. Then list revisions so you know the exact name of the stable one, because the rollback logic in Step 11 needs it.

export SERVICE_URL=$(gcloud run services describe "$SERVICE" \
  --format='value(status.url)')

curl -s "$SERVICE_URL/version"
curl -s "$SERVICE_URL/checkout"

gcloud run revisions list --service "$SERVICE"
gcloud run services describe "$SERVICE" --format='value(status.traffic)'

Expect {"version":"1.0.0","revision":"ledger-api-00001-abc"} from the first call and an accepted order from the second. The revisions list shows one active revision. Keep this terminal open, because you will compare against it throughout the next sections.

Ship a Shadow Revision at 0% Traffic

This is the core move of the whole tutorial. You deploy the new version with --no-traffic and a unique tag, so production users never touch it. Cloud Run creates the revision, gives it a tag, and exposes it on a URL of the form https://TAG---SERVICE-HASH-REGION.a.run.app. The triple hyphen separates the tag from the service name, which trips up people who try to guess the address.

Step 8: Build version 2 and deploy it with a tag and no traffic

Rebuild the image as 2.0.0. Then deploy it with --no-traffic and --tag canary. The environment variable FAIL_RATE=0.2 simulates a regression where one checkout in five fails. Use --update-env-vars rather than --set-env-vars, since the second flag wipes every existing variable and a real service usually has several.

gcloud builds submit app --tag "${IMAGE_BASE}:2.0.0"

gcloud run deploy "$SERVICE" \
  --image "${IMAGE_BASE}:2.0.0" \
  --no-traffic \
  --tag canary \
  --update-env-vars APP_VERSION=2.0.0,FAIL_RATE=0.2
Service [ledger-api] revision [ledger-api-00002-def] has been deployed and is serving 0 percent of traffic.
The revision can be reached directly at https://canary---ledger-api-xxxxxxxxxx-uc.a.run.app

The line “serving 0 percent of traffic” is the confirmation you want. Your stable revision still answers every real request. Tags are also not the same thing as Google Cloud resource labels. A tag creates a routable URL and can be used in traffic commands, while labels only organize resources for billing and filtering.

Step 9: Smoke-test the tagged URL by hand

Hit the canary URL directly and compare it with the production URL. A manual check teaches you what the gate script will automate in the next step.

export CANARY_URL=$(gcloud run services describe "$SERVICE" --format=json \
  | python3 -c "import json,sys; t=json.load(sys.stdin)['status']['traffic']; print(next(x['url'] for x in t if x.get('tag')=='canary'))")
echo "$CANARY_URL"

curl -s "$CANARY_URL/version"
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " "$CANARY_URL/checkout"; done; echo
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " "$SERVICE_URL/checkout"; done; echo

The canary loop will print a mix such as 200 200 500 200 200 200 500 200 200 200, while the production loop prints ten 200s. That contrast is the entire point. You found a regression with zero user impact, and the next step teaches a script to find it for you.

Write the Gate Script That Decides Go or No-Go

A gate turns a human glance into a repeatable decision. The script sends a fixed number of requests to a URL, measures the error rate and the 95th percentile latency, and exits with code 1 if either breaches a threshold. Cloud Build treats a non-zero exit as a failed step and stops the pipeline, so the gate needs no special integration.

Step 10: Create gate.py with the standard library only

Using only the standard library means the script runs in the stock Cloud SDK builder image without any pip install. It sends requests from a small thread pool to produce some concurrency, which surfaces issues like connection limits that a single-threaded loop would miss. An optional TOKEN environment variable adds a bearer header for private services.

# gate/gate.py
import argparse, json, os, sys, time, urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor

def hit(url):
    req = urllib.request.Request(url)
    token = os.environ.get("TOKEN")
    if token:
        req.add_header("Authorization", f"Bearer {token}")
    start = time.perf_counter()
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            status = resp.status
    except urllib.error.HTTPError as err:
        status = err.code
    except Exception:
        status = 599
    return status, (time.perf_counter() - start) * 1000

def main():
    p = argparse.ArgumentParser()
    p.add_argument("--url", required=True)
    p.add_argument("--path", default="/checkout")
    p.add_argument("--requests", type=int, default=200)
    p.add_argument("--workers", type=int, default=8)
    p.add_argument("--max-error-rate", type=float, default=0.01)
    p.add_argument("--max-p95-ms", type=float, default=500)
    a = p.parse_args()

    with ThreadPoolExecutor(max_workers=a.workers) as pool:
        results = list(pool.map(hit, [a.url + a.path] * a.requests))

    errors = sum(1 for s, _ in results if s >= 500)
    lats = sorted(ms for _, ms in results)
    p95 = lats[int(len(lats) * 0.95) - 1]
    rate = errors / len(results)

    summary = {"requests": len(results), "errors": errors,
               "error_rate": round(rate, 4), "p95_ms": round(p95, 1)}
    print(json.dumps(summary))

    if rate > a.max_error_rate or p95 > a.max_p95_ms:
        print("GATE FAILED", file=sys.stderr)
        sys.exit(1)
    print("GATE PASSED")

if __name__ == "__main__":
    main()

Run it against both URLs to prove it can tell good from bad. The thresholds default to a 1% error budget and a 500 ms p95 ceiling. Tune them to your service’s real service-level objectives, and never copy mine blindly.

python3 gate/gate.py --url "$SERVICE_URL"
python3 gate/gate.py --url "$CANARY_URL"; echo "exit code: $?"
{"requests": 200, "errors": 0, "error_rate": 0.0, "p95_ms": 118.4}
GATE PASSED
{"requests": 200, "errors": 41, "error_rate": 0.205, "p95_ms": 131.9}
GATE FAILED
exit code: 1

The latency numbers above are illustrative and will differ on your network. The pattern is what counts. The clean revision passes, the faulty one fails with an error rate near the 20% you injected, and the exit code carries that verdict to the pipeline. A sample size of 200 is enough to catch a 20% fault, but a subtle 1% regression needs thousands of requests, which the Advanced Tips section addresses.

Automate the Canary Deployment with Cloud Build

Manual commands prove the idea, and a pipeline makes it routine. The Cloud Build design here follows the pattern in Google’s updated Cloud Run guidance: build the image, push it, deploy a revision without traffic, evaluate it on its tagged URL, and promote only if the evaluation passes. The repository layout is small enough to hold in your head.

canary-demo/
  app/
    server.js
    Dockerfile
  gate/
    gate.py
  ci/
    snapshot.sh
    canary.sh
  cloudbuild.yaml

Step 11: Write the helper scripts and cloudbuild.yaml

Start with ci/snapshot.sh. Before the pipeline deploys anything, it records which revision currently holds 100% of traffic. That name is the rollback target, and capturing it up front avoids guessing later when two revisions share the split.

#!/usr/bin/env bash
# ci/snapshot.sh SERVICE REGION
set -euo pipefail
SERVICE="$1"; REGION="$2"

gcloud run services describe "$SERVICE" --region "$REGION" --format=json \
  | python3 -c "
import json, sys
traffic = json.load(sys.stdin)['status']['traffic']
stable = [t for t in traffic if t.get('percent') == 100]
if not stable:
    sys.exit('No revision holds 100 percent of traffic. Fix the split first.')
print(stable[0]['revisionName'])
" > /workspace/stable.txt

echo "Stable revision: $(cat /workspace/stable.txt)"

Next comes ci/canary.sh, the script that does the real work. It looks up the new revision through its tag, runs the gate against the 0% shadow, then walks through traffic stages. At every stage it waits for a soak period, runs the gate again and counts error-level log entries for the new revision. A shell trap restores the stable revision on any failure, so rollback needs no separate pipeline step.

#!/usr/bin/env bash
# ci/canary.sh SERVICE REGION TAG
set -euo pipefail
SERVICE="$1"; REGION="$2"; TAG="$3"

STAGES="${STAGES:-5 25 50}"
SOAK_SECONDS="${SOAK_SECONDS:-60}"
MAX_ERROR_RATE="${MAX_ERROR_RATE:-0.01}"
MAX_P95_MS="${MAX_P95_MS:-500}"
MAX_LOG_ERRORS="${MAX_LOG_ERRORS:-0}"

STABLE="$(cat /workspace/stable.txt)"

read -r NEW CANARY_URL < <(gcloud run services describe "$SERVICE" --region "$REGION" --format=json \
  | python3 -c "
import json, sys
tag = '$TAG'
hit = [t for t in json.load(sys.stdin)['status']['traffic'] if t.get('tag') == tag]
print(hit[0]['revisionName'], hit[0]['url'])
")

echo "Stable: $STABLE | Candidate: $NEW | URL: $CANARY_URL"

rollback() {
  echo "ROLLBACK: sending 100 percent back to $STABLE" >&2
  gcloud run services update-traffic "$SERVICE" --region "$REGION" --to-revisions "$STABLE=100"
}
trap 'rc=$?; if [ "$rc" -ne 0 ]; then rollback; fi' EXIT

gate() {
  python3 gate/gate.py --url "$CANARY_URL" \
    --max-error-rate "$MAX_ERROR_RATE" --max-p95-ms "$MAX_P95_MS"
}

echo "== Shadow gate (0 percent traffic) =="
gate

for PCT in $STAGES; do
  REST=$((100 - PCT))
  echo "== Stage: $PCT percent to $NEW =="
  gcloud run services update-traffic "$SERVICE" --region "$REGION" \
    --to-revisions "$STABLE=$REST,$NEW=$PCT"
  sleep "$SOAK_SECONDS"
  gate
  ERRS=$(gcloud logging read \
    "resource.type=\"cloud_run_revision\" AND resource.labels.revision_name=\"$NEW\" AND severity>=ERROR" \
    --freshness="${SOAK_SECONDS}s" --format='value(timestamp)' | wc -l)
  echo "Error log entries in last ${SOAK_SECONDS}s: $ERRS"
  if [ "$ERRS" -gt "$MAX_LOG_ERRORS" ]; then
    echo "Too many error logs at $PCT percent" >&2
    exit 1
  fi
done

echo "== Promote: 100 percent to $NEW =="
gcloud run services update-traffic "$SERVICE" --region "$REGION" --to-revisions "$NEW=100"
echo "Promotion complete."

Look closely at the promotion line. It names the revision explicitly instead of using --to-latest. The latest flag pins the service to always send traffic to the newest revision, and once a service is in that mode, every later deploy takes 100% of traffic immediately, even with --no-traffic in your script’s plan. Explicit revision names keep the split under your control.

Now tie everything together in cloudbuild.yaml. It runs as the dedicated builder account from Step 3, logs to Cloud Logging only, and allows 30 minutes, which is generous for three 60-second soaks plus builds.

# cloudbuild.yaml
substitutions:
  _SERVICE: ledger-api
  _REGION: us-central1
  _REPO: canary-demo
  _VERSION: "2.0.1"
  _TAG: canary
  _FAIL_RATE: "0"

serviceAccount: projects/${PROJECT_ID}/serviceAccounts/canary-builder@${PROJECT_ID}.iam.gserviceaccount.com
options:
  logging: CLOUD_LOGGING_ONLY
timeout: 1800s

steps:
  - id: build
    name: gcr.io/cloud-builders/docker
    args: ["build", "-t", "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/${_SERVICE}:${_VERSION}", "app"]

  - id: push
    name: gcr.io/cloud-builders/docker
    args: ["push", "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/${_SERVICE}:${_VERSION}"]

  - id: snapshot-stable
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: bash
    args: ["ci/snapshot.sh", "${_SERVICE}", "${_REGION}"]

  - id: shadow-deploy
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: gcloud
    args:
      - run
      - deploy
      - ${_SERVICE}
      - --region=${_REGION}
      - --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/${_SERVICE}:${_VERSION}
      - --no-traffic
      - --tag=${_TAG}
      - --update-env-vars=APP_VERSION=${_VERSION},FAIL_RATE=${_FAIL_RATE}

  - id: canary-and-promote
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: bash
    args: ["ci/canary.sh", "${_SERVICE}", "${_REGION}", "${_TAG}"]

Make the scripts executable before you submit, or call them through bash as the YAML does. The builder image ships Python 3, so gate.py and the inline JSON parsing run without extra installs.

Step 12: Run a bad release, then a good one

Submit the pipeline twice. The first run ships the faulty build with a nonzero FAIL_RATE and should die at the shadow gate. The second ships the fixed build with FAIL_RATE at zero and should walk all the way to 100%. Seeing both outcomes is the real test that the guardrails work.

chmod +x ci/*.sh

# Run 1: the faulty release
gcloud builds submit . --config cloudbuild.yaml \
  --substitutions=_VERSION=2.0.0,_FAIL_RATE=0.2

# Run 2: the healthy release
gcloud builds submit . --config cloudbuild.yaml \
  --substitutions=_VERSION=2.0.1,_FAIL_RATE=0

The first build should end with a failure, and its log should resemble the excerpt below. Production never moved, so users saw nothing.

Stable: ledger-api-00001-abc | Candidate: ledger-api-00002-def | URL: https://canary---ledger-api-xxxxxxxxxx-uc.a.run.app
== Shadow gate (0 percent traffic) ==
{"requests": 200, "errors": 38, "error_rate": 0.19, "p95_ms": 127.3}
GATE FAILED
ROLLBACK: sending 100 percent back to ledger-api-00001-abc
ERROR: build step 4 "gcr.io/google.com/cloudsdktool/cloud-sdk" failed: step exited with non-zero status: 1

The second build is the satisfying one. It should print three stage banners, three passing gates, and the final promotion.

== Shadow gate (0 percent traffic) ==
{"requests": 200, "errors": 0, "error_rate": 0.0, "p95_ms": 121.6}
GATE PASSED
== Stage: 5 percent to ledger-api-00003-ghi ==
Error log entries in last 60s: 0
== Stage: 25 percent to ledger-api-00003-ghi ==
Error log entries in last 60s: 0
== Stage: 50 percent to ledger-api-00003-ghi ==
Error log entries in last 60s: 0
== Promote: 100 percent to ledger-api-00003-ghi ==
Promotion complete.

Shift Traffic by Hand and Roll Back in Seconds

Pipelines fail at awkward moments, and sometimes you want to drive the release yourself. Every action in canary.sh is a single gcloud command, so you can replay any stage from your terminal. Knowing these commands also makes incident response faster, because the fastest rollback is one you have already typed before.

# See the current split and every tag
gcloud run services describe "$SERVICE" --format='yaml(status.traffic)'

# Send 10 percent to the candidate, 90 percent to the stable revision
gcloud run services update-traffic "$SERVICE" \
  --to-revisions ledger-api-00001-abc=90,ledger-api-00003-ghi=10

# Same idea using the tag instead of the revision name
gcloud run services update-traffic "$SERVICE" --to-tags canary=10

# Instant rollback: everything back to the stable revision
gcloud run services update-traffic "$SERVICE" \
  --to-revisions ledger-api-00001-abc=100

# Remove the tag once the release is finished
gcloud run services update-traffic "$SERVICE" --remove-tags canary

Cloud Run applies a traffic change in seconds, and it does so without restarting instances. That speed is what makes a canary safe. If the dashboard turns red at 5%, a single command ends the incident, and only about one request in twenty ever saw the bad build. When you assign less than 100% in total, Cloud Run spreads the remainder across existing revisions in proportion to their current share, which is another reason to name both revisions explicitly.

Choosing stage sizes and soak times

The defaults of 5, 25, 50 and 100 work for a service with steady traffic. Low-traffic services need different numbers, because a 5% slice of ten requests per minute is half a request per minute, which proves nothing. The table below suggests starting points by traffic level. Treat them as defaults to adjust after a few releases, not as rules.

Service trafficSuggested stagesSoak per stageGate sample sizeNotes
Under 10 requests per minuteShadow, then 50%, then 100%5 to 10 minutes500 requestsSmall slices produce no signal, so lean on the shadow gate
10 to 500 requests per minute10%, 50%, 100%5 minutes500 to 1,000Fewer stages, longer soak
500 to 5,000 requests per minute5%, 25%, 50%, 100%2 to 5 minutes1,000 to 5,000The tutorial defaults fit here
Over 5,000 requests per minute1%, 5%, 25%, 50%, 100%1 to 3 minutes5,000 and upA 1% slice still yields statistically useful volume

These ranges come from engineering judgment, not from a Google benchmark, so validate them against your own error budgets. The pipeline reads its stages from the STAGES variable, which means you can change them per environment without editing the script. Set STAGES="10 50" in a step’s env block and the loop adapts.

Watch the Canary in Cloud Logging and Monitoring

A gate script samples synthetic requests. Real users reveal different failure modes, so the pipeline also counts error logs for the new revision. You can run the same query by hand in the Logs Explorer, or from the terminal. Both approaches filter on the revision name that Cloud Run attaches to every log line.

gcloud logging read \
  'resource.type="cloud_run_revision"
   AND resource.labels.service_name="ledger-api"
   AND resource.labels.revision_name="ledger-api-00003-ghi"
   AND severity>=ERROR' \
  --freshness=10m --limit=20 \
  --format='table(timestamp, jsonPayload.path, jsonPayload.status)'

For metrics, Cloud Monitoring exposes the run.googleapis.com/request_count metric with a response_code_class label and a revision_name label. Chart the 5xx share per revision on one graph, and the canary and stable lines sit side by side. If the canary line climbs while the stable line stays flat, you have your answer without reading a single log.

Google’s observability documentation currently uses several names for related features, which confuses newcomers. Cloud Logging stores the logs, Cloud Monitoring holds metrics and alerts, and Observability Analytics is the SQL-style query layer that was previously called Log Analytics. Some older pages still use the old name. Google also describes Cloud Monitoring as gaining support for OpenTelemetry Protocol (OTLP) metrics in late October 2026, though that item is reported in a single Google-sourced announcement, so check the release notes before you build on it.

Build an alert that mirrors the gate

The pipeline only watches during the rollout window. An alert policy keeps watching afterward. Create a policy on the request-count metric, grouped by revision, that fires when the 5xx ratio for any revision passes your error budget for five minutes. Route it to the same channel your on-call engineer already reads. If the alert fires after promotion, you still have the previous revision available for an instant rollback, because Cloud Run keeps old revisions around until you delete them.

Seven Common Pitfalls When Canarying on Cloud Run

Most canary failures come from a handful of repeat mistakes. Each of the seven below has burned a real team, and each takes under a minute to prevent once you know about it.

  1. Promoting with --to-latest. The flag puts the service in follow-the-latest mode. Every later deploy then takes 100% of traffic at once, which silently turns your canary pipeline into a big-bang pipeline. Promote by revision name instead.
  2. Using --set-env-vars on a deploy. It replaces the entire environment of the new revision. A canary that lost its database URL fails every request and looks like a code bug. Use --update-env-vars.
  3. Treating a tag as a percentage. A tag only creates a routable URL. A tagged revision with no traffic assignment receives zero production requests, which is exactly why the shadow stage works, but it also means tagging alone does not start a canary.
  4. Ignoring cold starts in the gate. A fresh revision has no warm instances, so the first requests are slow and can breach a tight latency limit. Send a short warm-up burst before you measure, or set a minimum instance count on the candidate.
  5. Gating on too few requests. Two hundred requests cannot detect a 0.5% regression. Size the sample to the effect you need to see, and fail closed when the sample is too small.
  6. Forgetting that the split is per request. Cloud Run assigns each request to a revision independently, so one user can bounce between versions during a rollout. If your release changes an API contract, a user may see inconsistent behavior. Make versions backward compatible, or add session affinity where it fits.
  7. Shipping a breaking database migration. The stable and canary revisions run at the same time against the same database. A migration that drops a column breaks the stable revision the moment it runs. Use expand-and-contract migrations in two separate releases.

A related trap is running two pipelines at once. Both would use the tag canary, and the second deploy would move the tag away from the first revision mid-test. Either serialize releases with a concurrency lock, or make the tag unique per build by deriving it from the build ID or commit SHA.

Troubleshooting: 12 Errors and Their Fixes

Canary pipelines touch IAM, networking, build settings and traffic routing, so the errors come from several directions. The table maps the symptoms you are most likely to hit to their causes and fixes. Scan it first when a run fails, because nine times out of ten the answer sits in one of these rows.

#SymptomLikely causeFix
1PERMISSION_DENIED mentioning iam.serviceaccounts.actAsBuilder account lacks serviceAccountUser on the runtime accountRe-run the last binding in Step 3
2Build fails with a logs bucket or logging errorCustom build service account without a logging optionSet options.logging: CLOUD_LOGGING_ONLY and grant logging.logWriter
3Tagged URL returns 403Service is private, or allow-unauthenticated was blocked by an organization policySend an identity token (see Advanced Tips) and grant run.invoker
4Tagged URL shows a generic not-found pageWrong host format, or the tag was moved or removedCopy the URL from describe output and remember the triple hyphen
5Revision fails with a container failed to start and listen on the port errorApp ignores the PORT variable or binds to localhost onlyRead process.env.PORT and listen on all interfaces
6Shadow gate passes, but the 5% stage failsCold starts, or production data exposes a bug the synthetic path missesAdd a warm-up burst, raise the gate sample, test real endpoints
7gcloud logging read returns permission deniedBuilder account cannot read logsGrant roles/logging.viewer
8update-traffic reports a revision not foundTypo in the name, or the revision was deletedRun gcloud run revisions list and copy the exact name
9A --no-traffic deploy still takes all trafficService is in follow-the-latest mode from an earlier --to-latestPin traffic with --to-revisions NAME=100
10Build stops after 10 minutesDefault build timeout is shorter than your soak windowsSet timeout: 1800s in the YAML
11Snapshot step says no revision holds 100 percentA previous failed run left a split such as 90 and 10Reset with update-traffic --to-revisions STABLE=100
12Push fails with denied on Artifact RegistryMissing writer role, or a region mismatch in the image pathGrant artifactregistry.writer and match the repo region

When none of these fit, read the failing step’s log in the Cloud Build console first, then run the exact failing command from your own terminal. If it works there, the problem is the builder account’s permissions. If it fails the same way, the problem is in the command or the service configuration. That two-minute test splits most outages cleanly in half.

Advanced Tips for Production Canary Pipelines

Gate private services with identity tokens

Production services should not be public. Remove --allow-unauthenticated, grant the builder account roles/run.invoker, and pass a token to the gate. The script already reads a TOKEN variable, so the only change is minting one with the service URL as the audience. Test this locally before you rely on it in Cloud Build, because the way you mint tokens differs between your user account and a service account.

# Local test with your own identity
TOKEN=$(gcloud auth print-identity-token) \
  python3 gate/gate.py --url "$CANARY_URL"

# In CI, mint the token for the builder account
TOKEN=$(gcloud auth print-identity-token \
  --impersonate-service-account="$BUILDER_SA" \
  --audiences="$SERVICE_URL") \
  python3 gate/gate.py --url "$CANARY_URL"

Compare the canary against the baseline, not a fixed number

A fixed 1% error ceiling punishes the canary for problems the stable revision shares, such as a flaky downstream API on a bad afternoon. A relative gate removes that noise. Run the same request mix against both the stable tag and the canary tag, then fail only when the canary’s error rate exceeds the baseline’s by a set margin. To do this, tag the stable revision as well (for example --tag stable), then call gate.py on each URL and compare the two JSON summaries.

Keep infrastructure as code from fighting your pipeline

If Terraform or OpenTofu owns the Cloud Run service, a later apply can reset the traffic block and undo a canary in progress. Tell the tool to ignore the fields your pipeline manages, namely traffic and the image. Our Terraform on Google Cloud tutorial sets up the surrounding project and state, which makes a good base for this change.

Add human approval where the stakes are high

Google’s documentation does not show a built-in approval step for Cloud Run traffic promotion, so the pipeline above promotes automatically. For regulated systems, you can insert a manual gate in two ways. One option is to stop at 50% and require a second, manually triggered build for the final promotion. The other is Cloud Deploy, which lists Cloud Run as a deployment target and adds approvals between environments, at the cost of one more service to learn.

Other platforms and multi-region setups

The same ideas carry over to other clouds. Azure Container Apps uses a revision model with traffic weights, and our Azure Container Apps setup guide shows where those controls live. For a broader view of how the providers differ, see the AWS versus Google Cloud comparison. On the Google side, Google announced multi-region readiness probes and automated regional failover improvements in July 2026, according to coverage of its product updates. If you run in several regions, repeat the canary in one region first and treat it as a regional canary before the global rollout.

One boundary to respect: Cloud Run now spans services, jobs, worker pools and the newer Cloud Run instances. Instances are described as singleton, long-running runtimes rather than autoscaling services, so revision traffic splitting is a feature of services and not something to expect from them. For more guides in this area, browse our cloud computing coverage.

Complete Project Checklist, Cost and Cleanup

Before you call the project done, check that every file from the layout exists and runs. The one-shot sequence below recreates the whole demo from a clean shell, assuming Steps 1 to 3 are complete. It builds the baseline, ships a bad release that must fail, and then ships a good one that must reach 100%.

#!/usr/bin/env bash
# run-demo.sh : baseline, bad release, good release
set -euo pipefail

IMAGE_BASE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${SERVICE}"

gcloud builds submit app --tag "${IMAGE_BASE}:1.0.0"
gcloud run deploy "$SERVICE" --image "${IMAGE_BASE}:1.0.0" \
  --service-account "$RUNTIME_SA" --allow-unauthenticated \
  --update-env-vars APP_VERSION=1.0.0

chmod +x ci/*.sh

# Bad release: this build is expected to FAIL and roll back
gcloud builds submit . --config cloudbuild.yaml \
  --substitutions=_VERSION=2.0.0,_FAIL_RATE=0.2 || echo "Bad release stopped as designed"

# Good release: this build is expected to reach 100 percent
gcloud builds submit . --config cloudbuild.yaml \
  --substitutions=_VERSION=2.0.1,_FAIL_RATE=0

curl -s "$(gcloud run services describe "$SERVICE" --format='value(status.url)')/version"

The final call should print the new version and the name of the newest revision. If it prints the old version, the promotion never ran, so read the last build log.

What the demo costs

Cloud Run services are pay-per-use and scale to zero when idle, so the demo’s compute bill is small. Two other lines deserve attention. Cloud Build bills for the minutes a build runs, and your soak windows count because the build sits waiting. Artifact Registry charges for stored images. Google has not published a full, current price table in the sources we checked, so use the Google Cloud pricing calculator for your region rather than trusting a number from a blog post, including this one.

Google also said monthly Cloud Run spending caps are coming, with resources deactivated when a cap is reached. As of October 28, 2026, the announcements describe the feature as forthcoming, so do not treat it as a safety net yet. Set Cloud Billing budget alerts today, and compare the reporting tools in our cloud cost tool comparison if you manage more than one provider.

Step 13: Clean up revisions, tags and images

The last step keeps the bill flat. Remove the tag first, delete revisions you no longer need to roll back to, and prune old images. For a throwaway demo, tear everything down in one pass.

# Routine hygiene after a successful release
gcloud run services update-traffic "$SERVICE" --remove-tags canary
gcloud run revisions list --service "$SERVICE"
gcloud run revisions delete ledger-api-00002-def --quiet
gcloud artifacts docker images delete "${IMAGE_BASE}:2.0.0" --delete-tags --quiet

# Full teardown of the demo
gcloud run services delete "$SERVICE" --quiet
gcloud artifacts repositories delete "$REPO" --location="$REGION" --quiet
gcloud iam service-accounts delete "$RUNTIME_SA" --quiet
gcloud iam service-accounts delete "$BUILDER_SA" --quiet

Cloud Run refuses to delete a revision that still receives traffic, which protects you from deleting the stable one by accident. For ongoing cleanup, Artifact Registry supports cleanup policies that expire untagged or old images automatically, so you do not have to script pruning forever.

FAQ: Canary Deployment on Google Cloud Run

What is a canary deployment on Cloud Run?

It is a release method where a new revision first receives a small percentage of production traffic while the previous revision serves the rest. You raise the share in stages if metrics stay healthy, and send it back to zero if they do not.

Is a shadow revision the same as traffic mirroring?

No. The shadow pattern deploys a revision with no production traffic and tests it through its tagged URL. Based on the official material we reviewed, Cloud Run does not offer a first-party mode that copies live requests to a second revision.

Do I need Cloud Build, or can I use another CI system?

Any CI system works. Cloud Build only orchestrates. The deploy, tag and traffic commands are ordinary gcloud calls, so another CI system can run the same scripts with a suitable service account.

Does Cloud Run split traffic per user or per request?

Per request. A single user can land on different revisions during a rollout, so keep versions compatible with each other and consider session affinity if your release changes behavior users can see.

How fast can I roll back?

Changing the traffic split takes effect within seconds, because the old revision’s container image and configuration already exist. One update-traffic command that sets the stable revision to 100% completes the rollback.

What is the difference between a revision tag and a label?

A tag gives a revision its own URL and lets you reference it in traffic commands. A label is metadata for organizing resources, for example in billing reports. They serve different purposes and are unrelated in practice.

Does this work for Cloud Run functions?

Cloud Run functions is the current name for what was Cloud Functions, and it runs on the Cloud Run platform. Check Google’s current documentation for which traffic-splitting options your function type supports before you copy this pipeline over.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles