GCP for AWS Engineers — Mental Model Shift

Everything you know from AWS transfers — but several core abstractions work differently. This guide focuses on the paradigm shifts, not the feature lists.

For a flat service-by-service mapping table and "when to choose which," see the companion doc gcp-vs-aws.md. This file is the conceptual/mental-model view; that one is the lookup table.

0/0 checks

The Mental Model Shifts

AWS mental model GCP mental model
VPC is regional VPC is global — one VPC, subnets per region
Public/private subnets No such distinction — it's about whether a VM has an external IP
Security Groups on ENI Firewall rules on the network, filtered by tag or service account
NACLs at subnet No NACLs in GCP — stateful firewall rules only
Regions are siloed Regions share one VPC natively
IAM explicit Deny wins GCP Allow bindings are additive only — but a separate IAM Deny policy always overrides any Allow
Reserved Instances for discounts Sustained use discounts auto-apply — no commitment
Account = isolation boundary Project = isolation boundary

A team spins up VMs in us-central1 and europe-west1 inside the same GCP VPC. On AWS, reaching across two regions like this would mean two separate regional VPCs joined by peering or a Transit Gateway. Does the GCP setup need anything equivalent before the VMs can talk over private IPs?


1. Resource Hierarchy

In AWS, accounts group under AWS Organizations. In GCP, the same idea exists but the names — and a couple of the semantics — don't map one-to-one:

Management Account — the root of the org, owns consolidated billing for every account underneath.
Organizational Unit (OU) — groups member accounts so an SCP can target a whole group at once.
Member Account — resources live here; this is the actual billing and isolation boundary.
Resources live inside a member account.
SCPs — deny-based guardrails attached at the org or OU level.
Organization (domain.com) — the root of the hierarchy, tied to your Cloud Identity / Workspace domain.
Folder — groups projects, and folders can nest inside folders; roughly maps to an OU.
Project — resources live here; this is the actual billing and isolation boundary in GCP.
Resources live inside a project.
Org policies — constraint-based guardrails (e.g. "only allow VMs in these regions"), inherited down the tree.

Project is the fundamental billing and IAM boundary in GCP. Every resource belongs to a project. A project has:

  • A globally-unique project ID (you choose: my-company-prod-backend)
  • An auto-generated project number (123456789)
  • A billing account attached to it
graph TD
    classDef org fill:#4285f4,stroke:#2a56c6,color:#fff
    classDef folderProd fill:#e67e22,stroke:#d35400,color:#fff
    classDef folderDev fill:#3498db,stroke:#2980b9,color:#fff
    classDef projectProd fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef projectDev fill:#3498db,stroke:#2980b9,color:#fff
    classDef resource fill:#7f8c8d,stroke:#616a6b,color:#fff

    subgraph ORGLEVEL["Organization level — one per Cloud Identity domain"]
        ORG["Organization: company.com<br/>root of the resource hierarchy<br/>org policies bind here"]:::org
    end

    subgraph FOLDERLEVEL["Folder level — groups projects, nestable"]
        FOLDER_P["Folder: Production<br/>(roughly an AWS OU)"]:::folderProd
        FOLDER_D["Folder: Development"]:::folderDev
    end

    subgraph PROJECTLEVEL["Project level — the real billing + IAM boundary"]
        PROJ1["Project: prod-backend<br/>ID: my-company-prod-backend<br/>Number: 123456789"]:::projectProd
        PROJ2["Project: prod-data"]:::projectProd
        PROJ3["Project: dev-backend"]:::projectDev
    end

    subgraph RESOURCELEVEL["Resources — inherit every ancestor's bindings"]
        RES1["GCE, GKE, GCS<br/>in prod-backend"]:::resource
        RES2["BigQuery, Cloud SQL<br/>in prod-data"]:::resource
    end

    ORG -->|"IAM binding + org policy<br/>inherited downward"| FOLDER_P
    ORG --> FOLDER_D
    FOLDER_P --> PROJ1
    FOLDER_P --> PROJ2
    FOLDER_D --> PROJ3
    PROJ1 --> RES1
    PROJ2 --> RES2

IAM policies inherit downward — a binding at Org level applies to all folders, projects, and resources under it. Unlike AWS (where SCPs are deny-based), GCP org policies are constraint-based (e.g., "only allow VMs in these regions").

To make that inheritance concrete, here's how GCP actually resolves a resource's effective IAM policy — the same walk-up-the-tree logic applies whether the resource is a GCS bucket, a Compute Engine VM, or a BigQuery dataset:

1. Start at the resource. GCP collects any IAM bindings set directly on the resource itself — say, a specific GCS bucket. These apply no matter what else is going on higher up.
2. Walk up to the Project. Every binding on the resource's project is added to the set. Nothing at the resource level can override or exclude a project-level binding.
3. Walk up through each Folder. If the project sits inside nested folders, every binding on every ancestor folder — from the immediate parent up to the top-level folder — is added too.
4. Walk up to the Organization. Bindings set at the Org root are added last, and they reach every folder, project, and resource underneath — there's no OU-style opt-out for a branch of the tree.
5. Union, never subtract. The resource's effective permissions are the union of every binding collected along the way. Because GCP IAM is additive-only, nothing encountered at a lower level can revoke or narrow a binding granted higher up.
# Create a project under an org
gcloud projects create my-company-prod-backend \
  --name="Production Backend" \
  --organization=123456789

# Set active project for gcloud
gcloud config set project my-company-prod-backend

A binding granting roles/viewer is set at the Organization level. Three levels down (Org → Folder → Folder → Project), a project has no bindings of its own for that user. Can the user still view resources in that project?


2. IAM — Additive Allow, Plus a Separate Deny

GCP IAM = member + role → resource. Three rule types:

Role type Example When to use
Primitive roles/owner, roles/editor Never in prod. Avoid.
Predefined roles/storage.objectViewer Standard choice. Google-managed granularity.
Custom compute.instances.get only Least-privilege in security-sensitive environments.

The Critical Difference from AWS IAM

AWS: explicit Deny overrides Allow. You can grant * then deny specific actions.

GCP: standard IAM Allow bindings are additive. If one binding grants read and another grants write, the user has both, and no Allow binding can revoke a permission granted by another Allow binding at a higher level. GCP does have a separate mechanism for a hard override — IAM Deny policies — which always win over any Allow regardless of where it's granted; see security-compliance.md for how they actually work.

Grant broadly, then carve out an exception:
Allow: *
Deny: iam:DeleteRole
The explicit Deny wins no matter which policy granted the Allow, or at what level it was attached.
That exact pattern doesn't exist inside a standard IAM Allow binding — every Allow binding, from every source, at every level, only ever adds permissions, and one Allow can't revoke what another granted higher up. GCP does have a separate, purpose-built override for this — IAM Deny policies, which always win over any Allow regardless of where it's granted (see security-compliance.md) — but Allow bindings themselves stay additive-only. Default to the GCP approach: grant only what's needed, and reach for a Deny policy only when you need a guardrail no future Allow can undo.

You grant a service account roles/editor on a project, then try to lock it down by adding a binding that denies iam.serviceAccounts.delete — the way you'd bolt an explicit Deny onto an AWS IAM policy. Does this narrow the service account's access in GCP?

Policy Binding Example

{
  "bindings": [
    {
      "role": "roles/storage.objectViewer",
      "members": [
        "serviceAccount:my-app@my-project.iam.gserviceaccount.com",
        "user:eng@company.com"
      ]
    },
    {
      "role": "roles/bigquery.dataViewer",
      "members": ["group:data-team@company.com"]
    }
  ]
}
# Grant a predefined role on a project
gcloud projects add-iam-policy-binding my-project \
  --member="serviceAccount:my-app@my-project.iam.gserviceaccount.com" \
  --role="roles/storage.objectViewer"

# Grant on a specific resource (bucket)
gcloud storage buckets add-iam-policy-binding gs://my-bucket \
  --member="serviceAccount:my-app@my-project.iam.gserviceaccount.com" \
  --role="roles/storage.objectAdmin"

Service Accounts = EC2 Instance Profiles

A GCE VM or GKE pod runs as a service account. The service account is both an identity (for IAM bindings) and a credential source (for GCP SDKs). Never use service account key files on GCE/GKE — use the metadata server.

# Create SA
gcloud iam service-accounts create my-app-sa \
  --display-name="My App"

# Attach to a GCE VM at creation
gcloud compute instances create my-vm \
  --service-account=my-app-sa@project.iam.gserviceaccount.com \
  --scopes=cloud-platform

Workload Identity = GCP's IRSA

GKE pods authenticate to GCP APIs without any key files via Workload Identity. A Kubernetes ServiceAccount is bound to a GCP Service Account.

graph LR
    classDef k8s fill:#326ce5,stroke:#1a4fb4,color:#fff
    classDef gcp fill:#4285f4,stroke:#2a56c6,color:#fff
    classDef api fill:#2ecc71,stroke:#27ae60,color:#fff

    POD["Pod"]:::k8s -->|"runs as"| KSA["K8s ServiceAccount<br/>my-k8s-sa"]:::k8s
    KSA -->|"bound to<br/>(Workload Identity)"| GSA["GCP Service Account<br/>my-app-sa@my-project.iam.gserviceaccount.com"]:::gcp
    GSA -->|"granted"| ROLES["IAM roles<br/>e.g. roles/storage.objectAdmin"]:::gcp
    ROLES --> APIS["GCP APIs<br/>Cloud Storage, BigQuery, ..."]:::api
1. Create the GCP service account.
gcloud iam service-accounts create my-app-sa --project=my-project
2. Grant it permissions. Same kind of IAM binding you'd use anywhere else — this SA can now read/write the bucket.
gcloud storage buckets add-iam-policy-binding gs://my-bucket \
  --member="serviceAccount:my-app-sa@my-project.iam.gserviceaccount.com" \
  --role="roles/storage.objectAdmin"
3. Bind the K8s ServiceAccount to the GCP ServiceAccount — the key step. This is what makes Workload Identity work: it lets one specific Kubernetes SA impersonate the GCP SA, and nothing else.
gcloud iam service-accounts add-iam-policy-binding \
  my-app-sa@my-project.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:my-project.svc.id.goog[default/my-k8s-sa]"
4. Annotate the K8s ServiceAccount. This tells GKE which GCP SA a pod using my-k8s-sa should authenticate as.
kubectl annotate serviceaccount my-k8s-sa \
  iam.gke.io/gcp-service-account=my-app-sa@my-project.iam.gserviceaccount.com

Pods using my-k8s-sa now automatically get GCP credentials. No secret files, no env vars.


3. Networking — The Biggest Paradigm Shift

Global VPC

graph TD
    classDef awsvpc fill:#e67e22,stroke:#ba6018,color:#fff
    classDef awssub fill:#f5b041,stroke:#ba6018,color:#000
    classDef gcpvpc fill:#4285f4,stroke:#2a56c6,color:#fff
    classDef gcpsub fill:#8ab4f8,stroke:#2a56c6,color:#000

    subgraph AWS["AWS — regions are siloed"]
        VPC1["VPC: us-east-1<br/>10.0.0.0/16"]:::awsvpc
        SUB1A["us-east-1a subnet (public)<br/>10.0.1.0/24"]:::awssub
        SUB1B["us-east-1b subnet (private)<br/>10.0.2.0/24"]:::awssub
        VPC2["VPC: eu-west-1<br/>10.1.0.0/16"]:::awsvpc
        SUB2["eu-west-1a subnet<br/>10.1.1.0/24"]:::awssub
        VPC1 --> SUB1A
        VPC1 --> SUB1B
        VPC2 --> SUB2
        VPC1 -.->|"needs Transit Gateway<br/>or VPC peering to talk"| VPC2
    end

    subgraph GCP["GCP — one VPC spans every region"]
        VPCG["VPC: my-vpc (global)"]:::gcpvpc
        SUBG1["us-central1 subnet<br/>10.0.1.0/24"]:::gcpsub
        SUBG2["europe-west1 subnet<br/>10.1.0.0/24"]:::gcpsub
        SUBG3["asia-east1 subnet<br/>10.2.0.0/24"]:::gcpsub
        VPCG --> SUBG1
        VPCG --> SUBG2
        VPCG --> SUBG3
        SUBG1 -->|"private IPs over<br/>Google's backbone<br/>no peering needed"| SUBG2
        SUBG2 <-.-> SUBG3
    end

A VM in us-central1 reaches a VM in europe-west1 using private IPs — traffic stays on Google's backbone, no peering to configure.

# Always use custom mode VPC (auto mode has fixed CIDRs you can't control)
gcloud compute networks create my-vpc --subnet-mode=custom

# Add subnets per region as you expand
gcloud compute networks subnets create us-subnet \
  --network=my-vpc --region=us-central1 --range=10.0.1.0/24

gcloud compute networks subnets create eu-subnet \
  --network=my-vpc --region=europe-west1 --range=10.1.0.0/24

Firewall Rules — Not SGs

AWS Security Groups attach to ENIs. GCP firewall rules are network-wide, applied to VMs by tag or service account:

# Tag-based (convenient but less secure — anyone with VM edit can change tags)
gcloud compute firewall-rules create allow-frontend-to-backend \
  --network=my-vpc \
  --direction=INGRESS \
  --action=ALLOW \
  --target-tags=backend \
  --source-tags=frontend \
  --rules=tcp:8080

# SA-based (more secure — tied to VM identity, not mutable metadata)
gcloud compute firewall-rules create allow-frontend-to-backend-sa \
  --network=my-vpc \
  --direction=INGRESS \
  --action=ALLOW \
  --target-service-accounts=backend-sa@project.iam.gserviceaccount.com \
  --source-service-accounts=frontend-sa@project.iam.gserviceaccount.com \
  --rules=tcp:8080
GCP Firewall Rule AWS Security Group
Scope Network-wide, filtered by tag/SA Attached to specific ENI
Allow/Deny Both Allow only
Priority Explicit numeric (lower = higher priority) No priority, union of allows
Stateful Yes Yes
Subnet filter No (no NACLs in GCP) NACLs at subnet boundary

No "Public Subnet" Concept

In AWS, a public subnet routes to IGW. In GCP, there's no such routing concept:

  • "Public" VM = has an external IP assigned
  • "Private" VM = no external IP, uses Cloud NAT for outbound
# Cloud NAT = GCP's NAT Gateway
gcloud compute routers create my-router \
  --network=my-vpc --region=us-central1

gcloud compute routers nats create my-nat \
  --router=my-router \
  --region=us-central1 \
  --nat-all-subnet-ip-ranges \
  --auto-allocate-nat-external-ips

Cloud NAT is distributed — unlike AWS NAT Gateway (one per AZ), a single Cloud NAT is regional, auto-scales, and you never need to provision multiple for HA.

Multi-VPC Patterns

Goal GCP AWS
Connect two isolated VPCs VPC Peering (no transitive routing) VPC Peering (same)
Central network hub for many teams Shared VPC Transit Gateway
On-prem connectivity Cloud VPN + Cloud Router (BGP) Site-to-Site VPN + TGW
Private access to Google APIs Private Google Access (subnet flag) VPC Gateway Endpoint
Private access to specific service Private Service Connect Interface Endpoint (PrivateLink)

Shared VPC: One host project owns the VPC and subnets. Service projects (teams) deploy workloads into the host's subnets. Centralized networking, decentralized compute. Better than TGW for multi-team same-cloud scenarios.

Your teammate says GCP firewall rules are basically AWS Security Groups with different CLI syntax. What capability do GCP firewall rules have that AWS Security Groups don't?


4. GCP vs AWS — Pricing Quirks

Feature AWS GCP
VM discount for long-running Reserved Instances (1-3yr commit) Sustained use: automatic, no commitment
Data warehouse idle cost Redshift: pay per cluster-hour (always on) BigQuery: $0 idle, pay per query ($5/TB)
Cross-zone egress (same region) Charged Free
K8s control plane EKS: $0.10/hr ($73/mo) always GKE: $0.10/hr ($73/mo) per cluster; one free zonal cluster per billing account
Preemptible / Spot discount Up to 90% Up to 91% (legacy Preemptible had a 24hr cap; Spot VMs, the successor, have no time limit)

To get GCP's discount for long-running VMs, do you need to pre-purchase a 1-3 year commitment the way you would with an AWS Reserved Instance?


5. CLI Quick Reference

# ── Auth ──────────────────────────────────────────────────────
aws configure                         → gcloud auth login
aws sts get-caller-identity           → gcloud auth list / gcloud config list

# ── Project (= AWS Account) ───────────────────────────────────
aws sts get-caller-identity           → gcloud config get-value project
                                        gcloud projects list

# ── Compute ───────────────────────────────────────────────────
aws ec2 describe-instances            → gcloud compute instances list
aws ec2 run-instances                 → gcloud compute instances create
aws ec2 terminate-instances           → gcloud compute instances delete
aws ec2 describe-images               → gcloud compute images list

# ── Storage ───────────────────────────────────────────────────
aws s3 ls                             → gsutil ls
aws s3 cp src s3://bucket/path        → gsutil cp src gs://bucket/path
aws s3 sync ./dir s3://bucket/        → gsutil rsync -r ./dir gs://bucket/

# ── Containers / K8s ──────────────────────────────────────────
aws eks update-kubeconfig             → gcloud container clusters get-credentials my-cluster --region us-central1
aws ecr get-login-password | docker login → gcloud auth configure-docker

# ── IAM ───────────────────────────────────────────────────────
aws iam list-roles                    → gcloud iam service-accounts list
aws sts assume-role                   → (transparent via Workload Identity)

# ── Logs ──────────────────────────────────────────────────────
aws logs filter-log-events            → gcloud logging read 'resource.type="k8s_container"'

# ── Secrets ───────────────────────────────────────────────────
aws secretsmanager get-secret-value   → gcloud secrets versions access latest --secret=my-secret

# ── Databases ─────────────────────────────────────────────────
aws rds describe-db-instances         → gcloud sql instances list

Learning Path for AWS Engineers

Week 1 — Core Concepts
  from-aws.md (this file)     mental model shift, IAM, networking
  gcp/README.md               VPC deep-dive, firewall rules, Cloud NAT

Week 2 — Compute & Containers
  gcp/compute.md              GCE vs EC2, custom VMs, Spot VMs
  gcp/gke.md                  GKE modes, Workload Identity, NEG

Week 3 — Storage & Data
  gcp/storage.md              GCS, Persistent Disk, Filestore
  gcp/databases.md            Cloud SQL, Spanner, Firestore, Bigtable
  gcp/bigquery.md             serverless analytics deep-dive

Week 4 — Application Services
  gcp/serverless.md           Cloud Run, Cloud Functions
  gcp/messaging.md            Pub/Sub, Cloud Tasks, Eventarc
  gcp/observability.md        Cloud Monitoring, Logging, Trace

Week 5 — Comparison & Scenarios
  gcp/gcp-vs-aws.md           when to choose which
  gcp/scenarios.md            7 real debugging scenarios

The two concepts that take the longest to internalize coming from AWS:

  1. Global VPC — stop thinking about cross-region connectivity as something you configure
  2. Additive IAM — design least-privilege from the start for standard Allow bindings; reach for a real IAM Deny policy (see security-compliance.md) only when you need a hard override no Allow can undo