Deploying Cloud Run Services with Pulumi Python

A Cloud Run service is roughly forty lines of Pulumi Python, and almost every one of the mistakes that follows is made in the first ten. The resource hides three separate lifetimes behind one constructor — the service itself, the immutable revision the container actually runs in, and the traffic routing between revisions — and a field placed on the wrong side of that boundary either silently fails to roll out or replaces the running workload. This guide sits under GCP provider configuration within Pulumi patterns and provider management, and it walks a containerised Python API from a typed config object to a digest-pinned, privately invocable service with a traffic-split rollout.

Where each Cloud Run v2 field lives Where each Cloud Run v2 field lives: layered from Service down to service_account. Service name, ingress, labels - mutates in place traffics[] percent split across revisions, no restart template revision-scoped: any change mints a new revision containers[0] image digest, port, cpu and memory limits service_account the identity every request executes as
A change above the template line reroutes traffic; a change below it builds a new revision.

Context

gcp.cloudrunv2.Service mirrors the Cloud Run Admin API v2 rather than the older Knative-shaped v1 surface, and that matters because the v2 field layout tells you exactly what will happen on the next pulumi up. Everything nested under template describes a revision: an immutable snapshot of image, environment, resources, scaling and identity. Change any field under template and the API mints a new revision with a new name; the previous revision continues to exist and can still be served. Everything outside templateingress, traffics, labels, the service name — is service-scoped and mutates in place with no new revision at all.

The practical consequence is that two changes that look equally small in a diff are wildly different in production. Bumping max_instance_request_concurrency from 80 to 40 rebuilds every serving instance. Flipping ingress from INGRESS_TRAFFIC_ALL to INGRESS_TRAFFIC_INTERNAL_ONLY changes who can reach the existing instances within seconds and starts nothing new. Reading a Pulumi preview for a Cloud Run service is mostly a matter of asking which side of template each changed property sits on.

The second thing worth internalising before writing code is that Cloud Run enforces reachability at two independent layers. ingress is a network-level filter applied by the Google front end. roles/run.invoker is an IAM check on the caller's identity. They are evaluated separately, they fail with different HTTP status codes, and setting one while forgetting the other is the single most common Cloud Run misconfiguration in infrastructure code.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 and pulumi-gcp>=7.0 — the cloudrunv2 module does not exist in older provider releases.
  • run.googleapis.com, artifactregistry.googleapis.com and iam.googleapis.com enabled on the target project.
  • A container image already pushed to Artifact Registry, and its digest. The image must listen on the port named in container_port and bind 0.0.0.0, not 127.0.0.1.
  • Deploy-time permissions: roles/run.admin plus roles/iam.serviceAccountUser on the runtime service account, otherwise the deploy fails with Permission 'iam.serviceaccounts.actAs' denied on service account.
  • mypy if you want the typed config below checked rather than merely annotated.
# CLI: enable the APIs and confirm the image digest before writing any Pulumi code
gcloud services enable run.googleapis.com artifactregistry.googleapis.com --project acme-prod
gcloud artifacts docker images describe \
  europe-west1-docker.pkg.dev/acme-prod/services/api:2026-08-01 \
  --format='value(image_summary.digest)'

Implementation

1. Create a runtime identity instead of inheriting the default

If template.service_account is omitted, revisions run as the Compute Engine default service account, [email protected]. On projects created before the default-grant policy change that account holds roles/editor, which means a request-handling container that only needs to publish to one Pub/Sub topic can instead delete Cloud SQL instances. Even on newer projects the default identity is shared by every workload in the project, so an incident in one service is an incident in all of them.

Create one service account per service, grant it nothing at the project level, and attach roles to individual resources.

# infra/identity.py — one runtime identity per service, scoped at the resource
# CLI: mypy --strict infra/ && pulumi preview --diff
from __future__ import annotations

import pulumi
import pulumi_gcp as gcp

runtime_sa = gcp.serviceaccount.Account(
    "api-runtime",
    account_id="cloud-run-api",
    display_name="Runtime identity for the api service",
    description="Revisions of the api Cloud Run service execute as this principal",
)

member: pulumi.Output[str] = pulumi.Output.concat("serviceAccount:", runtime_sa.email)

# Provider note: TopicIAMMember is additive — it adds one member to one role on
# one topic and leaves every other binding on that topic untouched.
gcp.pubsub.TopicIAMMember(
    "api-orders-publisher",
    topic=orders_topic.name,
    role="roles/pubsub.publisher",
    member=member,
)

gcp.secretmanager.SecretIamMember(
    "api-db-password-accessor",
    secret_id=db_password.secret_id,
    role="roles/secretmanager.secretAccessor",
    member=member,
)

The choice between the additive IAMMember used here and its authoritative siblings is not cosmetic, and it is worth reading managing GCP IAM bindings with Pulumi Python before you reach for anything else.

2. Declare the service, pinning the image by digest

Pulumi diffs the string you give to image. If that string is …/api:latest, then pushing a new build under the same tag changes nothing in the Pulumi program, produces an empty preview, and creates no revision — the deploy appears to succeed while the old code keeps serving. A digest reference changes on every build, so the diff is real and the revision is reproducible: the digest recorded in the revision is exactly what runs, forever.

# infra/service.py — the service, its revision template, and its container
# CLI: pulumi up --stack prod
from __future__ import annotations

from dataclasses import dataclass

import pulumi
import pulumi_gcp as gcp

from infra.identity import runtime_sa


@dataclass(frozen=True)
class ServiceConfig:
    name: str
    location: str
    image: str                 # MUST be repo@sha256:… , never a mutable tag
    concurrency: int = 80
    min_instances: int = 0
    max_instances: int = 10
    cpu: str = "1"
    memory: str = "512Mi"


cfg = ServiceConfig(
    name="api",
    location="europe-west1",
    image=(
        "europe-west1-docker.pkg.dev/acme-prod/services/api@sha256:"
        "9b2cf1c7a0f4b0f2b9f3f4b7c2a1d6e5f8c3b0a9d7e6f5c4b3a2918273645566"
    ),
)

service = gcp.cloudrunv2.Service(
    "api",
    name=cfg.name,
    location=cfg.location,
    ingress="INGRESS_TRAFFIC_ALL",
    deletion_protection=False,
    template=gcp.cloudrunv2.ServiceTemplateArgs(
        service_account=runtime_sa.email,
        execution_environment="EXECUTION_ENVIRONMENT_GEN2",
        max_instance_request_concurrency=cfg.concurrency,
        timeout="30s",
        scaling=gcp.cloudrunv2.ServiceTemplateScalingArgs(
            min_instance_count=cfg.min_instances,
            max_instance_count=cfg.max_instances,
        ),
        containers=[
            gcp.cloudrunv2.ServiceTemplateContainerArgs(
                image=cfg.image,
                ports=gcp.cloudrunv2.ServiceTemplateContainerPortArgs(container_port=8080),
                resources=gcp.cloudrunv2.ServiceTemplateContainerResourcesArgs(
                    limits={"cpu": cfg.cpu, "memory": cfg.memory},
                    cpu_idle=True,           # bill CPU only while a request is in flight
                    startup_cpu_boost=True,  # full CPU during container start
                ),
                envs=[
                    gcp.cloudrunv2.ServiceTemplateContainerEnvArgs(name="LOG_LEVEL", value="info"),
                ],
                startup_probe=gcp.cloudrunv2.ServiceTemplateContainerStartupProbeArgs(
                    tcp_socket=gcp.cloudrunv2.ServiceTemplateContainerStartupProbeTcpSocketArgs(
                        port=8080,
                    ),
                    failure_threshold=6,
                    period_seconds=5,
                ),
            ),
        ],
    ),
    # State implication: every field above under `template` is revision-scoped, so a
    # change here creates a new revision; `ingress` above it mutates in place.
)

pulumi.export("service_url", service.uri)

Two of those fields carry most of the cost behaviour. max_instance_request_concurrency is the number of requests one container handles simultaneously; the default of 80 means a Python service that is mostly waiting on I/O needs one instance per eighty in-flight requests. Dropping it to 1 — which people do reflexively when they hit a thread-safety bug — multiplies instance count, and therefore billed instance-seconds, by up to eighty for the same traffic.

Instances required for 400 concurrent requests Instances required for 400 concurrent requests: concurrency = 1, concurrency = 10, concurrency = 80 (default), concurrency = 250. concurrency = 1 400 instances concurrency = 10 40 instances concurrency = 80 (default) 5 instances concurrency = 250 2 instances
max_instance_request_concurrency is a direct multiplier on billed instance-seconds.

cpu_idle=True selects request-based billing: CPU is throttled to near zero between requests, and you are charged for CPU only while a request is being handled. That is correct for an API and wrong for anything doing background work — a container that spawns a thread to flush a batch after returning a response will find that thread frozen. If your container works outside the request lifecycle, set cpu_idle=False and accept instance-based billing.

3. Set ingress and IAM together, never one alone

Cloud Run's two access layers fail differently, and the difference is diagnostic. A request rejected by ingress never reaches your IAM policy and comes back as HTTP 404, deliberately hiding whether the service exists. A request that passes ingress but whose caller lacks roles/run.invoker comes back as HTTP 403. If you set ingress="INGRESS_TRAFFIC_INTERNAL_ONLY" and stop there, the service is unreachable from the internet but still invocable by anything inside the VPC — including every other workload in the project — because you have filtered the network path and granted nothing about identity.

Ingress and IAM are checked separately Ingress and IAM are checked separately: comparison across What it filters, External caller sees. Configuration What it filters External caller sees ingress=ALL, no invoker binding nothing at network layer 403 Forbidden INTERNAL_ONLY, member allUsers every non-VPC caller 404 Not Found INTERNAL_LOAD_BALANCER only anything not via the LB 404 Not Found ingress=ALL plus allUsers invoker nothing 200 from the container
The status code tells you which of the two layers rejected the request.
# infra/invoker.py — the invoker grant, written for the private case
# CLI: pulumi up && gcloud run services get-iam-policy api --region europe-west1
from __future__ import annotations

import pulumi_gcp as gcp

from infra.service import service

# Private: exactly one caller identity may invoke the service.
gcp.cloudrunv2.ServiceIamMember(
    "api-invoker-frontend",
    name=service.name,          # the Cloud Run service name, NOT the Pulumi resource name
    location=service.location,
    role="roles/run.invoker",
    member="serviceAccount:[email protected]",
)

# Public alternative — one line, and the only line that makes a service anonymous.
# Provider note: `allUsers` is refused outright if the org policy
# constraints/iam.allowedPolicyMemberDomains excludes it.
# gcp.cloudrunv2.ServiceIamMember(
#     "api-invoker-public",
#     name=service.name,
#     location=service.location,
#     role="roles/run.invoker",
#     member="allUsers",
# )

The name= argument on ServiceIamMember refers to the Cloud Run service, not the Pulumi resource — a genuinely confusing overload that produces a 404 from the IAM API when someone passes the logical name by mistake.

4. Name the revision and split traffic

By default the service sends 100% of traffic to the latest ready revision, which means every pulumi up that touches template is an immediate full cutover. To roll out gradually, name the revision deterministically and declare the split explicitly.

# infra/rollout.py — a 90/10 split with a named, addressable canary
# CLI: pulumi up && gcloud run revisions list --service api --region europe-west1
from __future__ import annotations

import pulumi_gcp as gcp

REVISION = "api-2026-08-01-9b2cf1c"   # must start with the service name

canary = gcp.cloudrunv2.Service(
    "api",
    name="api",
    location="europe-west1",
    template=gcp.cloudrunv2.ServiceTemplateArgs(
        revision=REVISION,
        # …container, scaling and service_account exactly as in step 2…
    ),
    traffics=[
        gcp.cloudrunv2.ServiceTrafficArgs(
            type="TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
            revision="api-2026-07-24-31ac09b",
            percent=90,
        ),
        gcp.cloudrunv2.ServiceTrafficArgs(
            type="TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST",
            percent=10,
            tag="next",   # gives the new revision its own https://next---api-…run.app URL
        ),
    ],
    # State implication: percentages must total exactly 100 or the API rejects the update.
)
A 90/10 rollout with a tagged canary A 90/10 rollout with a tagged canary: pulumi up → Run Admin API → revision -9b2cf1c → callers. pulumi up Run Admin API revision-9b2cf1c callers template changed create revision ready traffics 90/10 10% of requests tag URL: 100%
The new revision is created and probed before any production share is routed to it.

The tag is the part people skip and then miss. A tagged revision gets a permanent URL of the form https://next---api-<hash>-ew.a.run.app that serves only that revision regardless of the split, so you can drive real requests at the new code before giving it any production share at all.

Verification

Prove the access model rather than assuming it. Against a private service with no invoker binding for anonymous callers:

# CLI: an unauthenticated request to a private Cloud Run service
curl -s -i "$(pulumi stack output service_url)/healthz" | head -n 12
HTTP/2 403
content-type: text/html; charset=UTF-8
server: Google Frontend

<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>403 Forbidden</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Forbidden</h1>
<h2>Your client does not have permission to get URL <code>/healthz</code> from this server.</h2>
</body></html>

That HTML body is the IAM refusal. If you instead see HTTP/2 404 with Error: Page not found, the request was stopped by ingress before IAM was consulted — a different bug with a different fix. Confirm the positive case with an identity token:

# CLI: the same request with a caller that holds roles/run.invoker
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
  "$(pulumi stack output service_url)/healthz"

Then check that the digest, identity and split are what the program declared:

# CLI: read the live service back and compare against the Pulumi program
gcloud run services describe api --region europe-west1 --format=yaml \
  | grep -E 'image:|serviceAccountName:|revisionName:'
gcloud run services describe api --region europe-west1 \
  --format='table(status.traffic.revisionName, status.traffic.percent, status.traffic.tag)'
gcloud run services get-iam-policy api --region europe-west1 --format=json

The image: line must contain @sha256:. If it contains a tag, the revision was created from a mutable reference and you cannot reconstruct what ran.

Gotchas & Edge Cases

Reusing a revision name with different content. Set template.revision to a fixed string and change the container underneath it and the API returns Revision named 'api-2026-08-01' with different configuration already exists. Derive the revision suffix from the image digest so the two can never disagree.

Traffic percentages that do not total 100. The update is rejected outright. This bites when a revision referenced by a 90% entry has been garbage-collected or renamed — Cloud Run cannot route to a revision that no longer exists.

deletion_protection defaults to true. From pulumi-gcp 7.x onwards, cloudrunv2.Service refuses to be destroyed until you set deletion_protection=False and run an update first. A stack teardown will otherwise stop halfway with the service still present and everything downstream of it already gone.

Cloud SQL and VPC egress need explicit wiring. A container that connects to a private IP reaches nothing until you attach vpc_access with a connector or Direct VPC egress. The symptom is a connection timeout inside your application logs, not an infrastructure error, so it is usually diagnosed late.

min_instance_count bills continuously. One warm instance at 1 vCPU and 512 MiB costs money every second of the month whether or not a request arrives. It is the right fix for cold-start latency and the wrong default for twenty internal services.

The container must bind 0.0.0.0:$PORT. Binding localhost produces a startup failure with The user-provided container failed to start and listen on the port defined provided by the PORT=8080 environment variable, which the Pulumi preview cannot possibly predict.

Operational Notes

Treat the image digest as stack configuration rather than a literal in the program. A CI job that builds and pushes the container should write the digest with pulumi config set api:imageDigest sha256:… and let the deployment step read it; the digest then appears in the stack's update history, so "what was running on the 3rd?" has an answer that does not depend on Artifact Registry retention.

Concurrency and memory should be measured, not guessed. Deploy with the default concurrency of 80, watch the run.googleapis.com/container/memory/utilizations and container/instance_count metrics under real traffic, and adjust one variable at a time. A Python service that holds a large model in memory usually wants low concurrency and high memory; a thin async API in front of a database usually wants the opposite, and the two configurations differ in monthly cost by an order of magnitude.

Keep the runtime service account's grants in the same Pulumi program as the service. Splitting them across programs means a revision can deploy successfully and then fail its first request with a permission error, because the two applies are not ordered with respect to each other. Within one program the depends_on implied by passing runtime_sa.email into the template gives you that ordering for free.

Finally, resist putting a load balancer in front of a Cloud Run service until you need one. A serverless network endpoint group plus an external HTTPS load balancer adds Cloud Armor, custom domains and a static IP — and also adds a second, independent place where reachability can be misconfigured. When you do add it, switch ingress to INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER so the *.run.app URL stops being a bypass around your WAF rules.

FAQ

Why did my new image not deploy even though the build succeeded?

Almost certainly the image field is a tag rather than a digest. Pulumi compares the declared string to the state; an unchanged :latest produces an empty diff and no new revision. Pin the digest and the problem disappears permanently.

What is the difference between the 403 and the 404 from a Cloud Run URL?

403 means the request reached the IAM check and the caller lacks roles/run.invoker. 404 means ingress rejected the request at the network layer before IAM ran, and Cloud Run deliberately does not reveal whether the service exists. Fix the invoker binding for the first, the ingress setting for the second.

Should I use cloudrun.Service or cloudrunv2.Service?

Use cloudrunv2.Service for anything new. The v1 resource models the Knative shape with metadata/spec nesting and annotation-driven settings, and several newer features — including direct VPC egress and the v2 scaling block — are only expressed cleanly in v2. Migrating later means an import, not an edit.

How do I stop the service being publicly reachable while still allowing my frontend to call it?

Leave ingress at INGRESS_TRAFFIC_ALL if the caller is another Cloud Run service outside the VPC, and grant roles/run.invoker only to that caller's service account. The frontend then attaches an identity token to each request. Reachability is controlled by the IAM grant, not by hiding the URL.

Does changing the traffic split restart any containers?

No. traffics sits outside template, so adjusting percentages is a service-level update that reroutes requests to revisions that are already running. This is why a canary promoted from 10% to 100% is effectively instantaneous and why rolling back is equally cheap.

Can I run a scheduled job on Cloud Run this way?

Not with this resource. cloudrunv2.Service is request-driven; for batch work use gcp.cloudrunv2.Job with an execution template, and trigger it from Cloud Scheduler. The identity and image-pinning advice above applies unchanged to jobs.