You are reading dev: the documentation of main. The last release, 0.1.0, predates this site.
Prerequisites
Tick these before tofu apply runs the GCP foundations, alone or through
the whole root opentofu/clusters/gcp.
Neither creates any of it. The commands use these variables:
Terminal window
PROJECT_ID=my-project-dev
BILLING_ACCOUNT=000000-000000-000000
REGION=europe-west1
STATE_BUCKET=my-project-dev-tofu-state
PRINCIPAL=user:you@example.com# or serviceAccount:…, principalSet://… for CI
One project per environment, its ID matching
^[a-z][a-z0-9-]{4,28}[a-z0-9]$, and one socle cluster per project: a
workload’s principal names the project, its namespace and its
ServiceAccount, never the cluster, so two socle clusters in one project
would be the same principal for every module, and what Crossplane grants
one would serve the other.
A billing account linked, and a budget with alert thresholds.
Org policies that permit private GKE nodes, Cloud NAT and external NAT
addresses. No service account is created: every workload, Crossplane
included, is a Workload Identity Federation principal of its own
(GCP-14).
The APIs enabled:
API
Needed for
cloudresourcemanager
project IAM bindings: the apply’s, and those Crossplane makes for modules
serviceusage
enabling the others
compute
VPC, subnetworks, router, Cloud NAT, the Gateways’ load balancers
container
the Autopilot cluster
iam
Crossplane’s bucket role and the DNS records role
sts
every workload’s federated token: a pod’s Kubernetes token exchanged for a Google one
iamcredentials
a CI identity impersonating the service account that runs OpenTofu
logging
GKE’s system and workload logs (logging_components)
monitoring
GKE’s system metrics; what KEDA’s pubsub scaler reads
dns
the certificate’s DNS authorizations, external-dns
certificatemanager
the shared Gateways’ Google-managed certificates (gateway_certificate)
storage
the state bucket, and the buckets modules own (Velero’s)
secretmanager
the external-secrets module
artifactregistry
Argo CD Image Updater reading kube.argocd_image_updater.repositories
pubsub
only with enable_upgrade_notifications (on by default)
bigquery
only with billing_export_dataset_id
Terminal window
gcloudprojectscreate"$PROJECT_ID"# when it does not exist
Workload Identity builds a principal from project, namespace and service
account: two clusters in one project share principals.
Enable only what the configuration uses: drop pubsub without upgrade
notifications, bigquery without an export dataset, secretmanager
without external-secrets, certificatemanager without
gateway_certificate, artifactregistry without Image Updater’s
repositories.
Autopilot bills Pod requests; without a budget a runaway workload is
silent.
A deleted project ID cannot be reused: archive the state first.
Never disable an API a resource still depends on: the next plan cannot
read it.
A GCS bucket in the cluster’s region: uniform access, public access
prevention, versioning, soft delete raised to 30 days, a lifecycle rule on
noncurrent versions only. The GCS backend locks natively.
OpenTofu 1.13 or later: below it, tofu init of the public OCI modules fails with a 403 (Distribution).
gcloud, authenticated as above.
gke-gcloud-auth-plugin (gcloud components install gke-gcloud-auth-plugin, or the package
google-cloud-cli-gke-gcloud-auth-plugin): the bootstrap cannot reach the
cluster without it.
kubectl.
cosign, to verify the modules package: OpenTofu does not verify OCI
signatures (distribution).
With gcp.crossplane set, allowed_roles names every role a catalog
module’s principal may be granted, at most nine (the DNS records role is
the tenth). Crossplane grants nothing else, and a module whose role is
missing never gets its binding Ready:
Module
Role in allowed_roles
external_dns
none: the socle’s own socleDnsRecords_<cluster> role, always granted
external_secrets
roles/secretmanager.secretAccessor
keda (services = ["pubsub"])
roles/monitoring.viewer
velero
roles/storage.objectAdmin
argocd_image_updater (repositories)
roles/artifactregistry.reader
Under the hood
owner, editor, viewer, iam.*, resourcemanager.* and every
predefined admin or owner role are refused at plan: each carries a
setIamPolicy permission Crossplane could grant to itself. The one
exception is roles/storage.objectAdmin, harmless on a bucket with
uniform bucket-level access.
The bound is a modifiedGrantsByRole condition on Crossplane’s own grant
of Project IAM Admin
(Google).
Regional CPUs and persistent disk SSD: Autopilot nodes draw on them.
A fresh project is not enough for the whole catalog. Measured on the
sandbox, a project created in October 2026 on a new billing account: 12
CPUS_ALL_REGIONS (ask for about 32) and 250 GB of SSD_TOTAL_GB in the
region (ask for about 1000). The SSD quota is the one that binds: 250 GB
is two nodes, too few for every module on (2026-10-16,
sizing); without the increase, turn
on only the modules you need. Ask before the first apply: on a fresh
billing account Google refused automatic increases (“cannot grant at this
moment”) and sent the CPU request to review; the Console (IAM & Admin →
Quotas & System Limits) or a paid billing account then does it.
In-use external IP addresses: Cloud NAT allocates its own.
One proxy-only subnetwork per region and VPC: a second cluster there
sets create_proxy_only_subnet = false.
Every Autopilot node boots on a 100 GB pd-balanced disk, the only boot
disk type Autopilot accepts, and pd-balanced counts against
SSD_TOTAL_GB. So do the claims on the default StorageClass,
standard-rwo: 250 GB is two nodes and a few claims.
Autopilot caps a node at 32 pods, and GKE’s own DaemonSets and system
pods take 13 of them on an e2-standard-2 node and 25 on the
ek-standard-8 system node. Two nodes leave about 26 places for Flux and
the catalog, which runs about 41 pods with every module on. The 12 vCPUs
were not the limit (sandbox, 2026-10-16).
What the catalog itself requests, every module on:
sizing.