GitHub Copilot for Google Cloud

GitHub Copilot for DevOps & InfrastructureAcademy lesson 49Cluster 4 · Lesson 11 of 13Intermediate → Advanced14 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for Google CloudGitHub Copilot for DevOps & Infrastructure11Intermediate → Advanced/github-copilot/devops/google-cloud/

This lesson produced the most instructive result in Cluster 4, and it is a negative one.

The Terraform configuration below — a Cloud Run service, two service accounts and an IAM binding — was run through Checkov, the same policy scanner that found eighteen problems in a draft AWS configuration and twenty-one in a draft Kubernetes manifest. The output was:

(nothing)

No findings. No summary. No exit code worth reading. The obvious interpretation is that the configuration is clean.

The correct interpretation is that Checkov has no policies for google_cloud_run_v2_service, google_service_account or google_cloud_run_v2_service_iam_member, so zero checks ran. Confirmed by running the identical command against a bare google_storage_bucket, which produced four findings — the Google framework works, the coverage for those resource types does not exist.

A clean scan and an empty scan are indistinguishable in a CI log. Both are a green tick.

The empty scan, in detail

This is worth spending a section on because the habit it undermines — trusting a green scanner — is nearly universal.

$ checkov -d . --framework terraform
       _               _
   ___| |__   ___  ___| | _______   __
  / __| '_ \ / _ \/ __| |/ / _ \ \ / /
 | (__| | | |  __/ (__|   < (_) \ V /
  \___|_| |_|\___|\___|_|\_\___/ \_/

By Prisma Cloud | version: 3.3.13

$ echo $?
0

Banner, no findings, exit 0. In a pipeline that is a passing step.

The control experiment:

$ cat t.tf
resource "google_storage_bucket" "b" {
  name     = "test-bucket"
  location = "EU"
}

$ checkov -d . --framework terraform
passed=0 failed=4
CKV_GCP_29, CKV_GCP_62, CKV_GCP_114, CKV_GCP_78

So the tool, the framework and the invocation are all correct. The difference is coverage.

The general form of this problem appears throughout Cluster 4 — hadolint missing the root user, ansible-lint missing the password, kubeconform passing a privileged manifest. This is the sharpest version, because the others at least reported something.

Where Copilot helps with Google Cloud

Terraform for GCP resources. The provider is large and regular, and this is the bulk of the useful output.

gcloud invocations — with the strong preference for asking for the command and its explanation, then running it yourself.

IAM bindings, with the caveat that the default is over-broad in a specific way covered below.

Explaining an existing project’s configuration. “What can this service account do?” against a list of bindings is a reading task.

Reading errors. Google Cloud’s permission errors name the missing permission precisely — Permission 'run.services.create' denied on resource … — which makes the fix mechanical once you decide the permission is appropriate.

The project is ambient

gcloud commands run against the active project, set by gcloud config set project at some point in the past and persisted.

gcloud config get-value project

That is the check, and the better habit is to not depend on it:

gcloud run services list --project my-project-id

Two Google-specific wrinkles. Project ID and project number are different identifiers, both appear in documentation, and a generated command using the wrong one fails confusingly. And project IDs are globally unique and immutable — you cannot rename one, so a typo in a generated project ID either fails or, in the worst case, addresses somebody else’s project.

Copilot promptCloud commands, safelyCopilot Chat

I need to update the ingress setting on a Cloud Run service.

Do not give me the command yet. Tell me what it changes, which IAM permissions it requires, whether it is destructive, whether it causes downtime, a read-only command to record the current setting first, and how to revert.

Then give me the command with —project stated explicitly.

Identity, and the two defaults that matter

Cloud Run’s default runtime service account is the Compute Engine default, which is granted the Editor role on the project. Editor can create, modify and delete almost everything. A Cloud Run service deployed without specifying an identity therefore runs with project-wide write access — and generated Terraform omits service_account unless asked.

# A dedicated service account with nothing attached. Cloud Run's default
# service account is the Compute Engine default, which holds the Editor role
# on the whole project.
resource "google_service_account" "run" {
  account_id   = "${var.service_name}-run"
  display_name = "Runtime identity for ${var.service_name}"
}

Creating it is two lines. Attaching it is one more. Not doing so is a project-wide privilege grant that nothing in the review will flag.

Service-account key files are the other default to avoid. A downloaded JSON key is a credential with no expiry, no rotation, and full authority of that service account. It is the Google Cloud equivalent of a long-lived AWS access key, and it is what generated CI configuration reaches for.

The alternatives, in order of preference: workload identity federation for anything outside Google Cloud, including GitHub Actions; attached service accounts for anything running inside it; and short-lived credentials via impersonation for humans. A key file is the option of last resort, and if one exists it should be in a secret manager and rotated on a schedule somebody owns.

IAM

Google Cloud IAM has a specific hazard that AWS does not: the basic roles — Owner, Editor, Viewer — which predate the granular ones and are still what suggestions reach for.

Editor is not “can edit the thing I am working on”; it is “can modify nearly everything in the project”. Viewer includes read access to data in many services. Google’s own documentation recommends against basic roles in production, and they appear in generated bindings constantly because they are short and they always work.

The other thing to check is binding scope. An IAM binding at project level applies to every resource in the project. Most services support resource-level bindings — the Cloud Run example below binds roles/run.invoker on the service rather than on the project — and generated bindings default to project level because it is the simplest form.

Two Terraform-specific traps worth knowing, because they are authoritative resources that silently remove other people’s access:

google_project_iam_policy replaces the entire project policy. If your configuration does not list a binding, that binding is deleted. This is almost never what anyone wants and it is capable of locking everyone out of a project.

google_project_iam_binding is authoritative for one role: it replaces the full member list for that role. google_project_iam_member is the additive form that adds a single member and leaves everything else alone.

Practical project: a Cloud Run service that is not public

Practical example

Cloud Run with a dedicated identity, internal ingress and a digest-pinned image

A configuration that validates locally, and a demonstration that a passing policy scan can mean nothing was checked.

Status
Tested implementation
Runtime
Terraform v1.15.9 with hashicorp/google, Checkov 3.3.13
Command
terraform fmt -check -recursive && terraform init -backend=false && terraform validate && checkov -d . --framework terraform
Result
fmt exit 0; init succeeded; validate: Success! The configuration is valid. Checkov produced an EMPTY report — zero checks ran, confirmed by comparison against a google_storage_bucket which produced four findings.
Run on
August 21, 2026

Files

examples/cluster-4/google-cloud

copilot-gcp-demo/ ├── terraform/main.tf ├── README.md └── .github/ └── instructions/ └── gcp.instructions.md

The prompt

Copilot promptGenerate the serviceCopilot Chat

Create Terraform for a Cloud Run v2 service.

Requirements:

  • A dedicated service account for the runtime identity. Do not use the default.
  • Ingress restricted to internal and load balancer traffic, not all.
  • The image pinned by digest, with a variable validation that rejects a tag.
  • CPU and memory limits, cpu_idle true, min instances 0.
  • Startup and liveness probes on /healthz.
  • deletion_protection enabled.
  • Grant roles/run.invoker to a named service account, on the service — not to allUsers, and not at project scope.

Ingress and the public binding

resource "google_cloud_run_v2_service" "api" {
  name     = var.service_name
  location = var.region

  # INGRESS_TRAFFIC_ALL puts the service on the public internet. Internal
  # means only VPC and Cloud Load Balancing traffic reaches it.
  ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  deletion_protection = true

}

# Explicitly *not* granting roles/run.invoker to allUsers. That single binding
# is what makes a Cloud Run service publicly callable, and it is the most
# common way a private service becomes public.
resource "google_cloud_run_v2_service_iam_member" "invoker" {
  project  = google_cloud_run_v2_service.api.project
  location = google_cloud_run_v2_service.api.location
  name     = google_cloud_run_v2_service.api.name
  role     = "roles/run.invoker"
  member   = "serviceAccount:${google_service_account.invoker.email}"
}

Two controls, and they are independent. ingress decides what network traffic reaches the service; the run.invoker binding decides who is authorised. A service with internal ingress and an allUsers invoker binding is not reachable from the internet — until someone puts a load balancer in front of it, at which point it is public and nobody changed the IAM.

Set both, deliberately.

Enforcing the digest in a variable

variable "image" {
  description = "Fully qualified container image, pinned by digest."
  type        = string

  validation {
    condition     = can(regex("@sha256:[0-9a-f]{64}$", var.image))
    error_message = "image must be pinned by digest, not by tag."
  }
}

Four lines that make it impossible to deploy a mutable tag. This is the pattern worth generalising: where a policy scanner has no rule, a variable validation is the local substitute — and given what this lesson found about coverage, that is not a hypothetical situation.

Observed output

$ terraform fmt -check -recursive
(exit 0)

$ terraform validate
Success! The configuration is valid.

$ checkov -d . --framework terraform
(banner only — no findings, no summary, exit 0)

Validation

The services, and their defaults

Compute Engine. Two defaults worth changing in every generated instance. The default service account is the one described above, and the default access scope is cloud-platform on some paths — a combination that gives the VM broad project authority. And can_ip_forward, OS Login and Shielded VM settings are all omitted by suggestions; OS Login in particular replaces per-instance SSH keys with IAM-managed access, which is the difference between revoking someone’s access centrally and hunting for their key on every host.

Cloud Storage. Uniform bucket-level access should be on. With it off — the default for older buckets — object ACLs work alongside IAM, so a bucket that looks private in the IAM policy can have individually public objects. Also check versioning, a lifecycle rule, and whether the bucket has public access prevention enforced rather than merely unset.

GKE. Everything in the Kubernetes lesson applies to the workloads. At the cluster level, the things generated Terraform omits are Workload Identity — which is how a Pod gets a Google Cloud identity without a key file, and is the single most important GKE security setting — a private cluster endpoint, Shielded GKE nodes, and release channel selection. A cluster without Workload Identity means the alternative is a key file mounted into a Pod.

Cloud Functions. Same identity point as Cloud Run: a dedicated service account rather than the default. And the same ingress consideration, expressed differently — a function with an HTTP trigger and an allUsers invoker binding is a public endpoint.

Networking. Google’s default VPC comes with firewall rules named default-allow-ssh and default-allow-rdp, sourced from 0.0.0.0/0. They exist so a new project works immediately. Generated configuration adds rules alongside them rather than questioning them, so a project that inherited the default network is permitting SSH from the internet unless somebody removed it. Creating a custom VPC rather than using the default is the clean answer and it is not what a suggestion produces.

Logging. Cloud Audit Logs are partly enabled by default — Admin Activity is always on and cannot be disabled, Data Access is off for most services. Turning on Data Access logs is a project-level IAM policy change that no resource declaration includes, and it is the difference between knowing who read a bucket and not.

CI/CD to Google Cloud

The CI story is where the key-file question becomes concrete, and it is worth being specific because generated pipeline configuration gets it wrong in a predictable way.

What a suggestion will produce: a GCP_SA_KEY repository secret containing the JSON of a downloaded service-account key, decoded in a step and written to disk, with GOOGLE_APPLICATION_CREDENTIALS pointing at it. It works. It also means a permanent, non-expiring credential with the full authority of that service account lives in a repository secret, is written to a runner’s filesystem, and is one cat away from any step in the workflow.

What to ask for instead: workload identity federation. GitHub Actions requests an OIDC token, exchanges it through a workload identity pool for a short-lived Google Cloud access token, and there is no stored secret at all. The pieces are a workload identity pool, a provider configured to trust GitHub’s issuer, and an IAM binding letting the pool impersonate a service account.

The control — exactly as in the AWS lesson — is the attribute condition on the provider. A pool that trusts token.actions.githubusercontent.com without constraining the repository can be used by any GitHub Actions workflow in the world. The condition needs to name your repository, and ideally the ref:

assertion.repository == 'example-org/copilot-demo' &&
assertion.ref == 'refs/heads/main'
Copilot promptAsking for federation rather than a keyCopilot Chat

Set up GitHub Actions to deploy to Cloud Run using workload identity federation. Do not use a service-account key file.

Include the attribute condition on the provider, constrained to the repository example-org/copilot-demo and the main branch.

Explain what would be possible if the attribute condition were omitted, and what the least-privilege role for a Cloud Run deployment is — not roles/editor.

The two “explain” clauses are the ones that produce a reviewable answer rather than a working one, and given how many generated pipelines reach for a key file, having read the explanation once is what stops you accepting the next one.

Google Cloud-specific risks

The default Compute Engine service account. Editor on the project, attached to anything that does not specify otherwise.

allUsers on roles/run.invoker or on a Cloud Storage bucket’s roles/storage.objectViewer. Both are one line and both make something public.

Basic roles. Owner, Editor and Viewer where a predefined role fits.

Authoritative IAM resources. _policy and _binding in Terraform, which remove access they do not list.

Downloaded service-account keys. Long-lived, non-expiring, and frequently committed.

Wrong active project. Ambient, persistent, and not visible in the command.

Firewall rules with a source of 0.0.0.0/0. Google’s default VPC ships with rules permitting SSH and RDP from anywhere, and generated configuration adds to them rather than replacing them.

Public Cloud Storage buckets. Uniform bucket-level access should be on, which disables ACLs and makes IAM the only mechanism — otherwise object-level ACLs provide a second path nobody is reviewing.

Organisation policies not considered. A generated resource may violate a policy constraint and fail at apply with a message about the policy rather than about the resource.

Organisation policies

Google Cloud’s guardrail layer is worth knowing about for the same reason Service Control Policies matter in AWS: it is the control that works when the review does not.

Organisation policy constraints are set above the project and cannot be overridden inside it. Four are directly relevant to the failure modes in this lesson:

  • iam.disableServiceAccountKeyCreation makes it impossible to download the key file that generated CI configuration keeps reaching for.
  • iam.allowedPolicyMemberDomains restricts IAM bindings to identities in your own domain, which blocks allUsers and allAuthenticatedUsers outright.
  • storage.publicAccessPrevention enforces the bucket setting rather than leaving it to each bucket.
  • compute.vmExternalIpAccess stops instances getting public addresses.

None of these appears in generated Terraform, because none is part of the resource anyone asked for. They are also the difference between “we review carefully” and “the mistake is not possible”.

There is a practical consequence for AI-assisted work specifically: a constraint produces an apply-time failure with a message about policy rather than about your configuration, which is confusing the first time. A generated resource that violates one will validate, plan, and then fail with something like Constraint constraints/compute.vmExternalIpAccess violated. Knowing the layer exists is what turns that from a mystery into an answer.

Destructive commands

Review workflow

Accepting generated Google Cloud artifacts
  1. Check the active projectHuman judgementgcloud config get-value project, and prefer --project on the command itself.
  2. terraform fmt, init -backend=false, validateLocal, no credentials, type-checked against the provider schema.
  3. Run a policy scan — and read the check countAn empty report is not a pass. This lesson is the reason that sentence is here.
  4. Grep for allUsers and allAuthenticatedUsersHuman judgementThe two members that make something public.
  5. Check every service account and roleHuman judgementDedicated identity? Predefined role rather than a basic one? Scoped to the resource?
  6. Check for _binding and _policy resourcesHuman judgementAuthoritative forms remove access they do not list.
  7. Read the plan for destroys and replacementsHuman judgement

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Best practices

  • Create a dedicated service account for every runtime; never inherit the default.
  • Prefer the additive _member IAM resources.
  • Never generate an allUsers binding without being asked for one in those words.
  • Pin images by digest, and enforce it with a variable validation where a scanner has no rule.
  • Use workload identity federation instead of key files.
  • Read the scanner’s check count, and fail the build when it is zero.
  • Put --project in every command.

Common mistakes

  • Reading an empty scan as a clean one.
  • Deploying Cloud Run without a service account and inheriting Editor.
  • Using google_project_iam_binding and removing everyone else’s access.
  • Granting a basic role because the predefined one is harder to find.
  • Committing a service-account key because it is the fastest way to make CI work.

Where to go next

GitHub Copilot for Terraform covers the tool this configuration is written in. GitHub Copilot for Kubernetes applies to GKE workloads, and GitHub Copilot for Go is the language most of Google Cloud’s own tooling is written in — including the scanners whose coverage gaps this lesson found.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources