GCP Provider Configuration

Infrastructure as Code in Python requires strict typing, deterministic state management, and zero-trust credential handling. This guide details production-ready workflows for Pulumi and CDKTF targeting Google Cloud Platform, as part of the broader Pulumi Patterns & Provider Management practice.

Every configuration pattern below enforces Python 3.9+ type safety. We prioritize state integrity, explicit credential routing, and testable provider boundaries.

Problem Framing

Three properties of Google Cloud shape all of it, and they are the reasons a working AWS setup does not translate directly. The project is simultaneously the billing boundary, the IAM boundary and the API-enablement boundary, so "which project" is a question every resource must answer. Service APIs are disabled by default on a new project and must be turned on before the first call, which turns a missing enablement into a deploy-time 403 rather than a permissions problem. And IAM stores one policy document per resource, so grants are read-modify-write rather than additive. Get those three right at the provider layer and the resource guides underneath become straightforward.

One GCP project, four boundaries at once One GCP project, four boundaries at once: The project is the unit with 4 facets. The project is the unit Billing every API call is charged to a project IAM one policy document per resource API enablement services are off until switched on Quota limits and quota project are per project
A GCP project is not an AWS account with a different name. It is simultaneously the billing, authorisation, enablement and quota boundary, which is why every resource has to answer which project it belongs to.

The Google provider is also unusually forgiving about missing configuration, and that forgiveness is where the trouble starts. Omit project and it finds one. Omit credentials and it finds some. Omit region and most resources still create, because a great many Google APIs are global or take a zone instead. Nothing errors, so nothing signals that the program's behaviour has become a property of the machine it ran on rather than of the code.

That produces two failures worth naming up front. The first is a resource landing in an engineer's sandbox project because a stray gcloud config set project leaked into CLOUDSDK_CORE_PROJECT, the program declared no provider, and the default provider assembled itself from the environment. The second is subtler: a program that works on a laptop and fails in CI with a permission error, because the laptop ran as a human carrying roles inherited from a folder while the runner runs as a service account holding exactly what someone remembered to grant.

The third structural fact is that GCP surfaces three unrelated problems behind the same HTTP status. A disabled API returns 403 with reason accessNotConfigured. A missing role returns 403 with reason forbidden. An organization policy constraint returns 400 ... conditionNotMet. They read almost identically in a Pulumi diagnostic and they belong to three different owners — the project, the IAM administrator, and the organisation. Reading the reason string first is most of the debugging.

Prerequisites

  • Python 3.9+ with pulumi>=3.100 and a pinned pulumi-gcp in requirements.txt or pyproject.toml. The plugin version is baked into the default provider's URN, so an unpinned dependency can change state identity on an unrelated pip install -U.
  • gcloud authenticated as a principal that already succeeds at gcloud projects describe <project-id>. If that fails from a shell it will fail from Pulumi, with a longer error.
  • The service APIs your stack touches enabled on the target project — at minimum cloudresourcemanager.googleapis.com and iam.googleapis.com, because the provider itself calls both during initialisation. Enablement is eventually consistent; allow a minute before the first deployment.
  • A GCS bucket for state with versioning on and uniform bucket-level access enabled, plus roles/storage.objectAdmin scoped to that bucket for the deploying identity. Nothing wider, and not roles/storage.admin.
  • A dedicated deployment service account, plus roles/iam.serviceAccountTokenCreator on it for whoever or whatever will impersonate it. This is what lets every later example avoid key files entirely.
  • mypy and pytest in the virtualenv. The mocking approach later on needs no GCP credentials at all, which is what makes it usable as a pull-request gate rather than a nightly job.
# CLI: confirm the toolchain, the identity, and the enabled APIs before writing code
pulumi version
gcloud auth list --filter=status:ACTIVE --format='value(account)'
gcloud services list --enabled --project acme-prod --format='value(config.name)' | head
gcloud storage buckets describe gs://my-org-iac-state \
  --format='value(name,versioning.enabled,iamConfiguration.uniformBucketLevelAccess.enabled)'

Environment Bootstrapping & Python 3.9+ Typing Setup

Isolate dependencies before initializing any IaC project. Virtual environments prevent dependency drift across stack lifecycles.

Environment Bootstrapping & Python + Typing Setup Environment Bootstrapping & Python + Typing Setup: layered from Environment Bootstrapping down to CLI Callout. Environment Bootstrapping Python Typing Setup CLI Callout
Environment Bootstrapping & Python + Typing Setup: the building blocks this section assembles.

CLI Callout: Create a strict Python 3.9+ environment and scaffold the project.

python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip setuptools wheel
pulumi new gcp-python --name my-gcp-infra
# OR for CDKTF:
cdktf init --template=python --local

Install static analysis tools immediately. Runtime failures in provider configurations are costly.

pip install mypy pyright typing_extensions pydantic

Configure pyproject.toml or setup.cfg to enforce strict type checking. Enable disallow_untyped_defs and strict_optional.

Define configuration objects using dataclasses and TypedDict. This prevents silent type coercion during provider instantiation.

Pin the provider itself, not just the SDK. pulumi-gcp tracks the upstream Terraform Google provider, and a minor release can add required arguments or change a default; an unpinned dependency means two engineers on the same commit can produce different previews. There is also a choice to make once, at the start: pulumi-gcp (the classic, Terraform-bridged provider) has the widest resource coverage and is what every guide in this topic uses, while pulumi-google-native maps the Google discovery documents directly and reaches newer API surfaces sooner. Mixing them in one stack is legal but leaves you maintaining two mental models of the same resource, so pick one per project.

# CLI: pip install -e . && python -m mypy .
[project]
name = "acme-gcp-infra"
requires-python = ">=3.9"
dependencies = [
  "pulumi>=3.100.0,<4.0.0",
  "pulumi-gcp>=7.20.0,<8.0.0",
  "pydantic>=2.6",
]

[tool.mypy]
python_version = "3.9"
disallow_untyped_defs = true
strict_optional = true
warn_unused_ignores = true

The scaffold pulumi new produces is worth understanding rather than accepting. Pulumi.yaml describes the project and is shared by every stack. Pulumi.<stack>.yaml holds per-stack configuration, including gcp:project, gcp:region and any encrypted secrets, and it is the file that makes an environment reproducible. Everything a stack needs to be re-created from scratch should live in one of those two files or in code — never in a shell profile on one engineer's machine.

Core Provider Initialization Patterns

Provider instantiation dictates region scoping, credential resolution, and API version routing. Both Pulumi and CDKTF require explicit constructor arguments. A correctly scoped provider is the foundation for every resource you build on top of it, from buckets to deploying a GKE cluster with Pulumi (Python).

Core Provider Initialization Patterns Core Provider Initialization Patterns: Core Provider Initiali with 4 facets. Core Provider Initiali Provider key element API key element CDKTF key element GKE key element
Core Provider Initialization Patterns: how Provider, API, CDKTF relate in this pattern.

Never rely on implicit credential resolution in production. Always inject via environment variables or Workload Identity Federation. For advanced routing strategies, consult Pulumi Patterns & Provider Management to standardize multi-provider abstractions.

There is an important distinction hiding behind the word "provider" here. Pulumi always has a default provider for each package, built from the gcp: namespace in your stack configuration. Any resource constructed without opts=pulumi.ResourceOptions(provider=...) silently uses it. That default is convenient for a single-project stack and dangerous the moment a second project appears, because a resource that should have targeted the data project will quietly land in the application project and the preview will look entirely reasonable. Explicit providers cost one keyword argument and remove the whole class of mistake.

Be aware that the provider assignment is part of a resource's identity. Moving an existing resource from the default provider to an explicit one, or changing the project on the provider it uses, shows up in the preview as a replacement rather than an update. On a bucket that is a nuisance; on a Cloud SQL instance it is an outage. When you retrofit explicit providers onto a live stack, check the preview for replace and reach for pulumi.ResourceOptions(alias=...) or a targeted state edit rather than accepting the churn.

Typed Pulumi GCP Provider Initialization

from typing import TypedDict, Optional
from dataclasses import dataclass
import os
import pulumi
import pulumi_gcp as gcp

class GcpProviderConfig(TypedDict, total=False):
    project: str
    region: str
    zone: Optional[str]
    credentials: Optional[str]

@dataclass(frozen=True)
class ProviderContext:
    config: GcpProviderConfig

def initialize_gcp_provider(ctx: ProviderContext) -> gcp.Provider:
    """Instantiate a type-safe GCP provider with explicit credential fallback."""
    return gcp.Provider(
        "gcp-primary",
        project=ctx.config.get("project") or os.getenv("GCP_PROJECT"),
        region=ctx.config.get("region") or os.getenv("GCP_REGION"),
        zone=ctx.config.get("zone") or os.getenv("GCP_ZONE"),
        credentials=ctx.config.get("credentials") or os.getenv("GOOGLE_CREDENTIALS"),
    )

CDKTF GCP Provider with Context Injection

from typing import Optional
from constructs import Construct
from cdktf import TerraformStack
from cdktf_cdktf_provider_google.provider import GoogleProvider

class GcpStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, project: str, region: str) -> None:
        super().__init__(scope, ns)

        GoogleProvider(
            self,
            "primary",
            project=project,
            region=region,
            # credentials=None enforces OIDC/ADC resolution at runtime
        )

How the GCP Provider Resolves Credentials

Almost every "works on my machine" report on a GCP Pulumi project is a credential-resolution story. The provider does not have one authentication mechanism; it has an ordered list, and it stops at the first entry that produces a token.

Google credential resolution order Google credential resolution order: layered from Explicit credentials argument down to Failure. Explicit credentials argument gcp.Provider(credentials=...) or the gcp:credentials stack config value GOOGLE_CREDENTIALS / GOOGLE_APPLICATION_CREDENTIALS key JSON contents, or a path to a key file on disk gcloud user ADC file application_default_credentials.json written by gcloud auth application-default login Metadata server Compute Engine, GKE Workload Identity, Cloud Build - no file on disk at all Failure google: could not find default credentials
The provider walks this list top to bottom and stops at the first source that yields a token, which is why a laptop succeeds where a runner fails.

Reading that order top to bottom explains the usual symptoms. A laptop has a gcloud user ADC file, so it authenticates as the engineer and inherits their generous permissions. A GitHub runner has none of the first three, so it falls through to the metadata server, which on a non-Google runner does not exist, and the deploy stops with:

Error: google: could not find default credentials.

The same order explains a subtler failure: a runner that does have GOOGLE_APPLICATION_CREDENTIALS pointing at a stale key file will authenticate successfully as the wrong principal and fail later with a permission error on an unrelated resource, which sends you looking at IAM instead of at the environment.

Prefer impersonation over key material. The provider accepts impersonate_service_account, which authenticates with whatever ambient credential is available and then mints a short-lived token for the target service account through the IAM Credentials API. The calling identity needs roles/iam.serviceAccountTokenCreator on the target, and no private key exists anywhere in the process.

# CLI: pulumi up --stack prod
# Provider note: the ambient identity never holds the deploy permissions itself;
# it only holds the right to mint a token for the deployer service account.
from dataclasses import dataclass

import pulumi
import pulumi_gcp as gcp

@dataclass(frozen=True)
class ProjectTarget:
    project: str
    region: str
    deployer_sa: str

def impersonating_provider(name: str, target: ProjectTarget) -> gcp.Provider:
    return gcp.Provider(
        name,
        project=target.project,
        region=target.region,
        # No key file: an ambient credential mints a short-lived token for this SA.
        impersonate_service_account=target.deployer_sa,
        # Scope the token to cloud-platform so downstream resources can call any enabled API.
        scopes=["https://www.googleapis.com/auth/cloud-platform"],
    )

cfg = pulumi.Config()
primary = impersonating_provider(
    "gcp-primary",
    ProjectTarget(
        project=cfg.require("targetProject"),
        region=cfg.get("targetRegion") or "europe-west1",
        deployer_sa=cfg.require("deployerServiceAccount"),
    ),
)

If impersonation is not yet in place and a key file is unavoidable, put the key in the Pulumi secrets store rather than in an environment variable on the runner. pulumi config set --secret gcp:credentials "$(cat key.json)" encrypts it with the stack's secrets provider, so it is at rest in the state file rather than in a CI settings page — and rotating it becomes a stack operation with an audit trail. The rotation mechanics are covered in rotating Pulumi stack secrets without downtime.

State Backend Configuration & Security Boundaries

State files contain sensitive resource metadata. GCS backends provide native encryption and versioning.

State Backend Configuration & Security Boundaries State Backend Configuration & Security Boundaries: Security then State then GCS then IAM then CLI Callout Security State GCS IAM CLI Callout
State Backend Configuration & Security Boundaries: the stages run left to right — Security, State, GCS, IAM, CLI Callout.

Never store state locally in CI or shared developer environments. Enforce strict IAM boundaries on the storage bucket.

CLI Callout: Provision a hardened GCS bucket with object-level versioning and uniform bucket access.

gsutil mb -l us-central1 -b on gs://my-org-iac-state/
gsutil versioning set on gs://my-org-iac-state/
gcloud storage buckets update gs://my-org-iac-state/ \
  --uniform-bucket-level-access

Apply least-privilege IAM bindings. roles/storage.objectAdmin is sufficient for state operations. Avoid roles/storage.admin. The same hardening applies to data buckets your stacks provision — see creating and securing GCS buckets with Pulumi (Python) for uniform bucket-level access, CMEK, and scoped IAM patterns.

Map environment prefixes to stack names to prevent cross-environment state corruption. Reference Pulumi Stack Architecture when designing environment segmentation and backend routing policies.

CLI Callout: Authenticate and bind the backend.

# Pulumi
pulumi login gs://my-org-iac-state/
pulumi stack init dev

Two settings deserve deciding once, at bucket creation, because retrofitting them is painful. The first is versioning, which turns the checkpoint into a recoverable object: a corrupted or truncated state file is restored by promoting the previous generation rather than by reconstructing the stack from the cloud. Pair it with a lifecycle rule that expires noncurrent versions after a sensible window, or the bucket grows without bound on a busy stack. The second is the secrets provider, which is chosen when the stack is created and is not a stack configuration you can flip later without re-encrypting:

# CLI: create the stack with KMS-backed secrets instead of a shared passphrase
pulumi stack init prod \
  --secrets-provider="gcpkms://projects/acme-prod/locations/global/keyRings/pulumi/cryptoKeys/state"

A KMS-backed secrets provider removes PULUMI_CONFIG_PASSPHRASE from every runner and every laptop, replaces "who knows the passphrase" with an IAM question you can audit, and gives you key rotation as a Cloud KMS operation. The passphrase provider is fine for a scratch stack and a liability for a shared one.

Concurrency is the third consideration. The self-managed GCS backend serialises operations with a lock object written alongside the checkpoint, so a second pulumi up on the same stack refuses to start rather than racing. That protection only works if every operator points at the same bucket and prefix — a colleague still logged into a local file backend will happily run a parallel update against the same cloud resources with no lock at all. Make pulumi whoami --verbose part of your onboarding checklist.

For CDKTF, configure the GCS backend in cdktf.json:

{
  "language": "python",
  "app": "python main.py",
  "terraformProviders": ["google@~> 6.0"],
  "backend": {
    "gcs": {
      "bucket": "my-org-iac-state",
      "prefix": "dev"
    }
  }
}

CI/CD Pipeline Integration & OIDC Workload Identity

Static service account keys violate zero-trust principles. Implement Workload Identity Federation (WIF) for ephemeral credential generation.

CI/CD Pipeline Integration & OIDC Workload Identity CI/CD Pipeline Integration & OIDC Workload Identity: CD Pipeline then OIDC Workload then Identity Pool then GCP IAM then Map GitHub CD Pipeline OIDC Workload Identity Pool GCP IAM Map GitHub
CI/CD Pipeline Integration & OIDC Workload Identity: the stages run left to right — CD Pipeline, OIDC Workload, Identity Pool, GCP IAM, Map GitHub.

Configure an Identity Pool in GCP IAM. Map GitHub or GitLab repository JWTs to a dedicated service account.

CLI Callout: Create the OIDC pool and provider mapping.

gcloud iam workload-identity-pools create "ci-pool" \
  --location="global" --display-name="CI/CD OIDC Pool"

gcloud iam workload-identity-pools providers create-oidc "github" \
  --location="global" \
  --workload-identity-pool="ci-pool" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub"

Grant scoped IAM roles to the pool. Use roles/iam.workloadIdentityUser for token exchange. Restrict resource roles to the exact services deployed.

That mapping is the minimum, and the minimum is not safe on its own. google.subject=assertion.sub records where the token came from but does not restrict which repositories may exchange one. Add an attribute condition on the provider so tokens from outside your organisation are rejected at the exchange, and map the repository claim so the service-account binding can be narrowed to a single repository:

# CLI: tighten the pool so only one org's repositories can exchange a token
gcloud iam workload-identity-pools providers update-oidc "github" \
  --location="global" --workload-identity-pool="ci-pool" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository_owner=='acme'"

# CLI: bind exactly one repository to the deployer service account
gcloud iam service-accounts add-iam-policy-binding \
  [email protected] \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/ci-pool/attribute.repository/acme/platform-infra"

A pool without an attribute condition will exchange a token from any GitHub repository on the internet, which is a materially worse position than the static key it replaced.

This approach mirrors AWS OIDC federation patterns. Review AWS Provider Deep Dive for cross-cloud authentication parity and pipeline hardening techniques.

Configure your GitHub Actions workflow to request the OIDC token and authenticate:

# CLI: pushed to .github/workflows/deploy.yml and run by GitHub Actions
permissions:
  id-token: write        # without this the job has no OIDC token to exchange
  contents: read
steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: "projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/ci-pool/providers/github"
      service_account: "deployer@<PROJECT_ID>.iam.gserviceaccount.com"

The GCP provider automatically consumes the ephemeral credential via Application Default Credentials. The action writes a short-lived credential configuration file and exports GOOGLE_APPLICATION_CREDENTIALS pointing at it, which lands the job on the second rung of the resolution order described above — no key material, and a token that expires with the job.

Two failures here are common enough to name. Forgetting id-token: write produces a job with no token to exchange at all. A mismatch between the principalSet in the binding and the actual repository produces Unable to acquire impersonated credentials at the auth step, before Pulumi ever runs — which is the correct place for it to fail.

Project Scoping, Enabled APIs and Multiple Providers

The question "which project does this resource belong to?" has four possible answers in a Pulumi program, and they interact.

Ways to scope a GCP resource to a project Ways to scope a GCP resource to a project: comparison across Applies to, Set in, Changing it. Mechanism Applies to Set in Changing it Default provider every resource with no opts gcp:project config may replace resources Explicit provider resources given opts.provider gcp.Provider(...) replaces the resource Resource project arg one resource only constructor keyword usually replaces Separate stack a whole environment Pulumi.<stack>.yaml no cross-talk at all
Four scoping mechanisms coexist in one program; the failure mode is a resource silently adopting the default provider you forgot to override.

Everything above the last row shares one process and one state file, which is what makes an accidental default-provider adoption possible. A stack that manages an application project and a shared data project should construct two explicit providers and pass them deliberately; if the two projects have genuinely independent lifecycles, split them into separate stacks and wire them together through outputs instead, as described in handling Pulumi stack outputs and cross-stack references.

Enabled APIs are the other half of project scoping and the one that surprises people migrating from AWS. Google disables almost every service API on a new project, and the first call to a disabled service returns a 403 that names the service and the project number:

googleapi: Error 403: Cloud Run Admin API has not been used in project 123456789012
before or it is disabled. Enable it by visiting the API console then retry.

Enable APIs in code, so a fresh project is reproducible, and be careful with the destroy behaviour:

# CLI: pulumi up --stack prod
# Provider note: disable_on_destroy=False stops a stack teardown from disabling an API
# that other stacks in the same project still depend on.
from typing import Sequence

import pulumi
import pulumi_gcp as gcp

REQUIRED_APIS: Sequence[str] = (
    "run.googleapis.com",
    "artifactregistry.googleapis.com",
    "iam.googleapis.com",
    "cloudkms.googleapis.com",
)

def enable_apis(project: str, provider: gcp.Provider) -> list[gcp.projects.Service]:
    services: list[gcp.projects.Service] = []
    for api in REQUIRED_APIS:
        services.append(
            gcp.projects.Service(
                f"api-{api.split('.')[0]}",
                project=project,
                service=api,
                disable_dependent_services=False,
                disable_on_destroy=False,
                opts=pulumi.ResourceOptions(provider=provider),
            )
        )
    return services

Resources that call a newly enabled API should depend on the corresponding Service resource, because enablement is not instantaneous — a create issued a second after the enable call can still see the 403. An explicit depends_on costs nothing and removes an intermittent failure that is otherwise blamed on "flaky GCP".

One last scoping wrinkle affects local development only: the quota project. When the provider authenticates as a human via gcloud, some APIs refuse to bill the request to any project and report that a quota project is not set. gcloud auth application-default set-quota-project acme-prod fixes it once per machine. It never appears in CI, because a service-account credential already carries a project.

Where Each Resource Guide Fits

Everything on this page is setup. The guides beneath this topic each take that configured provider as a given and go deep on one resource family.

What sits on top of a configured GCP provider What sits on top of a configured GCP provider: choose among 4 options. GCP provider configured once storage Secured GCS buckets compute GKE cluster and nodepools serverless Cloud Run services access IAM bindings
Every resource guide in this topic assumes the credential routing, project scoping and state backend established on this page.

Creating and securing GCS buckets with Pulumi (Python) is the right place to start, because a bucket is the smallest resource that still exercises uniform bucket-level access, customer-managed encryption keys and a scoped IAM binding — the three controls you will apply to everything else.

Deploying a GKE cluster with Pulumi (Python) covers the largest resource in the topic: a VPC-native control plane with the default node pool removed and managed separately, plus Workload Identity so pods authenticate to Google APIs without node service-account keys.

Deploying Cloud Run services with Pulumi Python is the serverless counterpart, and it turns on one distinction worth learning early: fields under template mint a new immutable revision, fields outside it mutate the service in place, and knowing which is which is how you read a Cloud Run preview correctly.

Managing GCP IAM bindings with Pulumi Python is the one to read before you grant anything. The three IAM resource families differ by a single word in the class name and by an enormous margin in blast radius, and choosing the authoritative one by accident on a live project removes every binding it did not declare.

Testing Boundaries & Validation Workflows

Unit tests must never trigger live GCP API calls. Isolate configuration validation from deployment execution.

Testing Boundaries & Validation Workflows Testing Boundaries & Validation Workflows: unittest.mock then Testing Boundaries then Validation then GCP API then Pytest Mock unittest.mock Testing Boundaries Validation GCP API Pytest Mock
Testing Boundaries & Validation Workflows: the stages run left to right — unittest.mock, Testing Boundaries, Validation, GCP API, Pytest Mock.

Use pulumi.runtime.set_mocks() or unittest.mock to intercept provider invocations. Assert typed outputs against expected schemas.

The mock harness has two halves and both matter. new_resource is called for every resource the program constructs and returns a tuple of physical id and output dictionary; returning the inputs unchanged is usually right, and overriding a specific output is how you simulate a value the cloud would have assigned, such as a bucket's generated URL. call handles function invokes — the get_* data sources — so a program that looks up an existing network with gcp.compute.get_network needs a call implementation or the lookup returns nothing and the assertion fails for an unrelated reason. Setting preview=False makes outputs resolve to concrete values instead of remaining unknown, which is what you want in a unit test.

Pytest Mock for GCP Resource Validation

import pytest
from unittest.mock import patch, MagicMock
import pulumi
import pulumi_gcp as gcp
from typing import Any, Dict, Tuple

class MockGcpMocks(pulumi.runtime.Mocks):
    def new_resource(
        self, args: pulumi.runtime.MockResourceArgs
    ) -> Tuple[str, Dict[str, Any]]:
        return (f"{args.name}-mock-id", {**args.inputs})

    def call(self, args: pulumi.runtime.MockCallArgs) -> Dict[str, Any]:
        return {}

@pytest.fixture(autouse=True)
def set_pulumi_mocks():
    pulumi.runtime.set_mocks(MockGcpMocks(), preview=False)

@pytest.mark.asyncio
async def test_gcp_provider_initialization() -> None:
    """Validate GCP provider config without hitting the GCP control plane."""
    provider = gcp.Provider(
        "test-gcp",
        project="my-test-project",
        region="us-central1",
    )
    project = await pulumi.Output.from_input(provider.project).future()
    assert project == "my-test-project"

Enforce pulumi preview and cdktf synth as mandatory CI gates. Reject any pipeline execution that fails static validation.

Step-by-Step: Bringing Up a Keyless GCP Stack

Everything above is a mechanism. This is the order those mechanisms have to be applied in, and the ordering is not arbitrary: the identity a stack deploys through cannot be created by the stack that uses it, and the state backend has to exist before pulumi stack init can write to it.

Bring-up order for a keyless GCP stack Bring-up order for a keyless GCP stack: operator → gcloud → WIF pool → deployer SA → Pulumi CLI. operator gcloud WIF pool deployer SA Pulumi CLI create SA grant roles create pool bind repo config set impersonate 1h token preview
The first four steps happen once with gcloud and never again; only the last three run on every deployment. Attempting to create the deployment identity from the stack that uses it is the ordering mistake to avoid.

1. Create the deployment identity out of band

The service account, the roles it holds on the workload project, and its access to the state bucket are bootstrap concerns. Create them once with gcloud, or in a separate bootstrap stack that a human runs deliberately.

# CLI: the deployer identity, then the two grants it cannot function without
gcloud iam service-accounts create deployer --project acme-shared \
  --display-name "Pulumi deployer"
gcloud projects add-iam-policy-binding acme-prod \
  --member="serviceAccount:[email protected]" \
  --role="roles/compute.networkAdmin"
gcloud storage buckets add-iam-policy-binding gs://my-org-iac-state \
  --member="serviceAccount:[email protected]" \
  --role="roles/storage.objectAdmin"

2. Point the CLI at the backend and create the stack

The backend URL is a machine-level login rather than stack configuration, which is the single most common cause of a mysteriously empty "prod" stack: one engineer logged into a bucket, another still on the local filesystem, both convinced they are looking at the same environment.

# CLI: one backend URL for everyone, KMS secrets chosen at creation time
pulumi login gs://my-org-iac-state
pulumi whoami --verbose
pulumi stack init prod \
  --secrets-provider="gcpkms://projects/acme-prod/locations/global/keyRings/pulumi/cryptoKeys/state"
# State implication: the secrets provider cannot be swapped later without exporting
# the stack, re-encrypting every ciphertext, and importing it again

3. Record the environment facts as stack config

Project IDs, regions and the deployer's address are per-environment facts. Putting them in Pulumi.prod.yaml makes a change to any of them visible in a pull request, which is exactly where a change of target project should be caught.

# CLI: everything the program needs to know about where it is deploying
pulumi config set gcp:project acme-prod --stack prod
pulumi config set gcp:region europe-west1 --stack prod
pulumi config set targetProject acme-prod --stack prod
pulumi config set targetRegion europe-west1 --stack prod
pulumi config set deployerServiceAccount \
  [email protected] --stack prod

4. Wire the entry point so every resource is bound

The entry point does three things and nothing else: build the provider, enable the APIs, and hand the provider to everything downstream. Keeping it that small is what makes "which identity does this stack use" a one-file question.

# __main__.py — provider, enablement, and one bound resource
# CLI: pulumi up --stack prod --yes
from __future__ import annotations

import pulumi
import pulumi_gcp as gcp

from providers import ProjectTarget, impersonating_provider
from services import enable_apis

cfg = pulumi.Config()
target = ProjectTarget(
    project=cfg.require("targetProject"),
    region=cfg.get("targetRegion") or "europe-west1",
    deployer_sa=cfg.require("deployerServiceAccount"),
)
provider = impersonating_provider("gcp-primary", target)
apis = enable_apis(target.project, provider)

assets = gcp.storage.Bucket(
    "app-assets",
    project=target.project,
    location=target.region.upper(),
    uniform_bucket_level_access=True,
    versioning=gcp.storage.BucketVersioningArgs(enabled=True),
    # Provider note: depends_on the enablement resources, because a create issued a
    # second after the enable call can still see accessNotConfigured
    opts=pulumi.ResourceOptions(provider=provider, depends_on=apis),
)

client = gcp.organizations.get_client_config_output(
    opts=pulumi.InvokeOptions(provider=provider)
)
# State implication: exported so the project a stack targets shows up in review
pulumi.export("gcpProject", client.project)
pulumi.export("gcpRegion", client.region)
pulumi.export("assetsBucket", assets.name)

get_client_config_output also returns an access_token. Never export that one — it is a live bearer token, and a stack output is not treated as a secret unless you wrap it with pulumi.Output.secret. Project and region are the two worth surfacing.

5. Read the preview before approving it

On a fresh stack pulumi preview --diff prints a create for every resource and, more usefully, the provider each one is bound to. A resource showing a default provider is one you forgot to bind, and rebinding after creation is a state edit rather than a code change — so fix it here, before the first pulumi up.

Verification

Before trusting a new environment, walk the whole chain once by hand. Each command answers exactly one question, so a failure tells you where to look:

# CLI: verify identity, scoping, and the plan before any resource is created
gcloud auth list --filter=status:ACTIVE --format='value(account)'
pulumi stack select prod
pulumi config get gcp:project
pulumi about --json | python -c "import json,sys; print(json.load(sys.stdin)['plugins'])"
pulumi preview --diff

A healthy run prints the impersonated deployer account rather than a human one, the project you expected, a pinned gcp plugin version, and a preview whose summary line reads something like Resources: 12 to create with no replace entries. A replace on a resource you did not touch almost always means the provider assignment or the project changed underneath it — stop and read the diff rather than approving it.

The second half of verification is asking Google rather than the checkpoint. A resource can be recorded in state and still be in the wrong project, and only the API can settle that. Scope each read explicitly to the project you intended: a describe against the wrong project returns a clean 404 rather than an ambiguous answer.

# CLI: prove placement against the API, not against state
pulumi stack output gcpProject --stack prod
gcloud storage buckets describe "gs://$(pulumi stack output assetsBucket --stack prod)" \
  --project acme-prod \
  --format='value(name,location,iamConfiguration.uniformBucketLevelAccess.enabled)'
# expected: acme-prod
# expected: app-assets-<suffix>  EUROPE-WEST1  True

Then confirm nothing slipped onto a default provider. On a correctly bound stack the count is zero; any other number means a ResourceOptions was omitted and that resource was created through whatever the ambient credentials resolved to.

# CLI: every resource should name a provider you declared
pulumi stack --show-urns --stack prod | grep -c 'pulumi:providers:gcp::default'
# expected: 0

Finally, close the loop on drift. pulumi refresh --diff reads each resource back from Google and reports what changed outside the program — a console edit, an org-policy remediation bot, a gcloud command run under pressure. Use --expect-no-changes so a scheduled job fails rather than merely prints, and run it deliberately: a plain refresh writes live values into the checkpoint and can quietly adopt drift you intended to revert.

# CLI: detect out-of-band changes without modifying anything
pulumi refresh --diff --expect-no-changes --stack prod
# State implication: without --expect-no-changes this command REWRITES the checkpoint

Common Implementation Anti-Patterns

Common Implementation Anti Patterns Common Implementation Anti Patterns: alias then project then JSON then Omitting Python then IAM alias project JSON Omitting Python IAM
Common Implementation Anti Patterns: the stages run left to right — alias, project, JSON, Omitting Python, IAM.
  • Hardcoding service account JSON in source control instead of using OIDC or secret managers.
  • Omitting Python 3.9+ type hints on provider arguments, leading to silent runtime failures.
  • Configuring multiple provider instances without explicit alias or project overrides—the last provider definition wins silently.
  • Skipping pulumi preview or cdktf synth validation gates in CI pipelines.
  • Granting excessive IAM roles (e.g., roles/owner) to CI/CD service accounts instead of Workload Identity Federation with scoped roles.
  • Mixing local state and GCS backend across different stack environments without explicit PULUMI_CONFIG_PASSPHRASE handling.

Two of those deserve elaboration because their consequences are delayed rather than immediate. Granting roles/owner to a deploy identity does not fail — it works perfectly, right up until the day a compromised workflow file or a malicious dependency in the build inherits it. The corrective action is unglamorous: run a deploy with the intended narrow role in a non-production project, collect the permission errors, and grant exactly those. Google's recommender surfaces excess permissions after the fact, but the cheap moment to do this is before the role is ever attached to production.

The state-mixing anti-pattern is worse than it sounds. Two stacks that believe they own the same resources but keep separate checkpoints will each see the other's changes as drift, and the resulting alternating updates can genuinely destroy data — a database whose parameters flip on every deploy is not merely noisy. The guard is procedural rather than technical: one backend URL per organisation, stated in the README, checked with pulumi whoami --verbose before anyone runs an update.

Troubleshooting GCP Provider Errors

Where GCP provider work goes wrong Where GCP provider work goes wrong: Failure surfaces with 4 facets. Failure surfaces Credentials no ADC on the runner, or the wrong quota project API enablement service disabled on the target project IAM propagation a new service account is not yet visible Impersonation the workload identity binding is missing
Four boundaries account for nearly every red pipeline on a GCP Pulumi project, and each announces itself with a distinctive error string.

Error: google: could not find default credentials — cause: nothing in the resolution chain produced a token. On a laptop, run gcloud auth application-default login. In CI, confirm the OIDC step ran before Pulumi and that it exported GOOGLE_APPLICATION_CREDENTIALS; an auth step placed after the Pulumi step fails exactly this way.

googleapi: Error 403: <service> API has not been used in project <number> before or it is disabled — cause: the service API is off on the target project. Add a gcp.projects.Service resource for that API and make the dependent resources depends_on it. Note the project number in the message: if it is not the project you expected, your provider scoping is wrong, not your API enablement.

Permission 'iam.serviceaccounts.actAs' denied on service account — cause: the deploy identity may create the resource but may not attach the runtime service account to it. Grant roles/iam.serviceAccountUser on the runtime service account to the deployer. This is a separate grant from the resource-creation role and is missed constantly on Cloud Run and Cloud Functions deploys.

googleapi: Error 409: The bucket you tried to create already exists, conflict — cause: GCS bucket names are globally unique across all of Google Cloud. A name like acme-assets was taken years ago by someone else. Suffix bucket names with the project id or a stable random id, and never with datetime.now(), which would replace the bucket on every run.

Error 400: Service account ... does not exist immediately after creating that service account — cause: IAM is eventually consistent. The create succeeded and the subsequent binding raced it. Pass the service account resource's email output into the binding so Pulumi orders them, rather than reconstructing the address as a formatted string.

Unable to acquire impersonated credentials — cause: the workload identity binding does not match the caller. Compare the principalSet in the service-account binding with the repository, branch or environment the job actually runs from. A trailing slash or a renamed repository is enough to break the match, and the failure happens at the auth step, before the Pulumi program starts.

Key Takeaways

GCP provider configuration in Python IaC follows the same three foundations as AWS: Workload Identity Federation over static keys, GCS-backed remote state with versioning, and pulumi.runtime.set_mocks()-based unit testing. The GCP-specific addition is configuring the OIDC attribute mapping correctly—get that wrong and every CI/CD run fails at authentication before reaching your infrastructure code.

FAQ

How do I enforce Python 3.9+ typing for Pulumi and CDKTF GCP provider arguments?

Use typing.TypedDict for configuration dictionaries and pydantic for runtime validation, then run mypy in a pre-commit hook so a mismatched region or project type is caught before deployment. The provider packages ship type information generated from the upstream schema, so strict checking catches misspelled arguments at edit time.

What is the safest way to manage GCP credentials in CI/CD without static keys?

Use Workload Identity Federation with an attribute condition that pins the repository owner, and bind a single repository to the deployer service account through a principalSet member. Grant only the roles that deploy needs, and keep a separate read-only identity for the preview job.

How do I isolate GCP state files across dev, staging, and production stacks?

Give each environment its own prefix under the state bucket and its own stack name, so the checkpoints never share a path. Combine that with a KMS-backed secrets provider per environment, which makes the ability to decrypt production configuration an auditable IAM grant rather than a shared passphrase.

Can I unit test GCP provider configurations without triggering live API calls?

Yes. pulumi.runtime.set_mocks() intercepts every resource construction and function invoke, so assertions run against resolved outputs with no network access. Implement both new_resource and call, because a program that looks up an existing network or image will otherwise fail on the invoke rather than the assertion.

Why does the same Pulumi program work on my laptop but fail with a permission error in CI?

Because the two run as different principals. Locally the provider picks up your gcloud user credentials and your personal roles; in CI it picks up the federated service account, which typically holds far less. Reproduce the failure by impersonating the deploy service account locally rather than by widening its roles until the pipeline goes green.

Should I enable GCP service APIs with Pulumi or out of band?

Enable them with gcp.projects.Service so a project is reproducible from an empty state, and set disable_on_destroy=False so tearing one stack down cannot disable an API another stack in the same project still needs. Resources that call a freshly enabled API should depend on the enablement resource, since the change takes a moment to propagate.