---
title: "Terraform Modules"
slug: gcp-terraform-modules
section: GCP Infrastructure
status: stable
description: "`modules/` in `alliance-gcp-infra` holds 24 reusable Terraform modules, one per resource type (Cloud Run service, Cloud SQL instance, storage bucket, Artifact Registry repository, and so on)."
last-updated: 2026-10-07
---

# Terraform Modules

## What the modules are

`modules/` in `alliance-gcp-infra` holds 24 reusable Terraform modules, one per resource type (Cloud Run service, Cloud SQL instance, storage bucket, Artifact Registry repository, and so on). The repo root `README.md` states the rule: every environment under `environments/` uses the same module source and differs only in `terraform.tfvars` and the module call arguments. There is no environment-specific module code. Callers reference modules by relative path, for example `source = "../../modules/service-account"`.

This page covers how to read and extend the modules. The generated tables below list the catalogue and the usage matrix. For how changes reach each project, see `/docs/gcp-terraform-workflows`. For resource naming, see `/docs/gcp-naming-conventions`. Release flow and the dev-first policy are in `/docs/git-branching-release-model` and `/docs/dev-only-deployment-policy`.

## Module catalogue

| Module | Purpose | Variables | Outputs | Resources it creates |
| --- | --- | --- | --- | --- |
| `api-key` | Single `google_apikeys_key`. `prevent_destroy = true`. Neither Terraform nor `gcloud` | 6 (3 required) | id | google_apikeys_key |
| `artifact-registry` | Single Artifact Registry repository. Defaults to a Docker STANDARD_REPOSITORY | 10 (3 required) | id, repository_name | google_artifact_registry_repository |
| `cloud-deploy-pipeline` |  | 5 (4 required) | pipeline_name, target_names | google_clouddeploy_delivery_pipeline, google_clouddeploy_target |
| `cloud-run-job` | Single Cloud Run Job (`google_cloud_run_v2_job` — the Jobs API has no classic-knative | 18 (5 required) | id | google_cloud_run_v2_job |
| `cloud-run-service` | Uses `google_cloud_run_service` (the classic Knative-API resource), not | 29 (3 required) | url, name | google_cloud_run_service, google_cloud_run_service_iam_member, google_cloud_run_v2_service, google_cloud_run_v2_service_iam_member |
| `cloud-sql-postgres` | Manages a Cloud SQL for PostgreSQL instance. **Existing instances are imported, never | 24 (4 required) | connection_name, private_ip_address, self_link | google_sql_database_instance |
| `cloudbuild-github-connection` | 2nd-gen Cloud Build GitHub App connection + its linked repositories. `prevent_destroy = true` | 7 (6 required) | connection_id, repository_ids | google_cloudbuildv2_connection, google_cloudbuildv2_repository |
| `cloudbuild-trigger` | Single 2nd-gen (`repository_event_config`) Cloud Build trigger. `prevent_destroy = true` — | 11 (7 required) | id | google_cloudbuild_trigger |
| `domain-mapping` | Single `google_cloud_run_domain_mapping`. `prevent_destroy = true` — QA's one live mapping | 4 (4 required) | id | google_cloud_run_domain_mapping |
| `firestore-database` |  | 8 (2 required) | database_id, database_resource_name | google_firestore_database, google_firestore_field, google_project_iam_member, google_project_iam_audit_config |
| `firewall-rule` | Single `google_compute_firewall` rule. `prevent_destroy = true`. | 9 (4 required) | id | google_compute_firewall |
| `gke-autopilot-cluster` | Deliberately minimal — see the comment in `main.tf`. This module exists to bring the | 8 (2 required) | name, endpoint | google_container_cluster |
| `gke-standard-cluster` | Standard (non-Autopilot) counterpart of `modules/gke-autopilot-cluster`, created for | 7 (2 required) | name, endpoint | google_container_cluster |
| `load-balancer-https` | Standalone HTTPS load balancer stack (static IP, managed cert, serverless NEG, backend | 21 (13 required) | ip_address | google_compute_global_address, google_compute_managed_ssl_certificate, google_compute_region_network_endpoint_group, google_compute_backend_service, google_compute_url_map, google_compute_target_https_proxy, google_compute_target_http_proxy, google_compute_global_forwarding_rule |
| `load-balancer-https-multihost` | Global HTTPS load balancer stack (static IP, managed cert, N host-routed backends, URL map + | 16 (12 required) | ip_address, backend_service_ids, neg_ids | google_compute_global_address, google_compute_managed_ssl_certificate, google_compute_region_network_endpoint_group, google_compute_backend_service, google_compute_url_map, google_compute_target_https_proxy, google_compute_target_http_proxy, google_compute_global_forwarding_rule |
| `network` | Import-only. Every resource here has `prevent_destroy = true` — this module manages the | 8 (3 required) | network_id, network_self_link, subnet_ids, vpc_connector_ids | google_compute_network, google_compute_subnetwork, google_compute_global_address, google_service_networking_connection, google_vpc_access_connector |
| `project-services` | Bulk `google_project_service` — one resource per enabled API, `for_each` over a set. | 2 (2 required) | service_ids | google_project_service |
| `pubsub-topic` | Single Pub/Sub topic + optional push subscriptions. `prevent_destroy = true` on both. | 3 (2 required) | topic_id, subscription_ids | google_pubsub_topic, google_pubsub_subscription |
| `redis-instance` | Single Memorystore for Redis instance, `PRIVATE_SERVICE_ACCESS` connect mode. | 9 (6 required) | id, host | google_redis_instance |
| `secret-manager-secret` | Manages a Secret Manager secret **container and IAM bindings only** — never the secret | 7 (2 required) | secret_id, id | google_secret_manager_secret, google_secret_manager_secret_iam_member |
| `secret-manager-secret-bulk` | Bulk-import module for the remaining Secret Manager secret **containers** in a GCP | 4 (1 required) | secret_ids, user_managed_secret_ids | google_secret_manager_secret, google_secret_manager_secret_iam_member |
| `service-account` | Single `google_service_account` + optional project-level IAM role grants | 8 (2 required) | email, account_id, name, unique_id | google_service_account, google_project_iam_member |
| `storage-bucket` | Single GCS bucket. `prevent_destroy = true` — real app-data buckets hold user-uploaded | 10 (3 required) | url, name | google_storage_bucket |
| `workload-identity-pool` | Single `google_iam_workload_identity_pool`. `prevent_destroy = true` — these back live | 5 (2 required) | id, name, provider_ids | google_iam_workload_identity_pool, google_iam_workload_identity_pool_provider |

## Module use per environment

Number of module calls per environment.

| Module | `development` | `mcp` | `platform-services` | `production` | `qa` | `vertex` |
| --- | --- | --- | --- | --- | --- | --- |
| `api-key` | 1 | 0 | 0 | 0 | 0 | 0 |
| `artifact-registry` | 11 | 4 | 0 | 9 | 7 | 0 |
| `cloud-deploy-pipeline` | 1 | 0 | 0 | 0 | 0 | 0 |
| `cloud-run-job` | 2 | 0 | 0 | 0 | 0 | 0 |
| `cloud-run-service` | 0 | 2 | 1 | 4 | 2 | 0 |
| `cloud-sql-postgres` | 8 | 1 | 1 | 3 | 3 | 0 |
| `cloudbuild-github-connection` | 1 | 0 | 0 | 1 | 1 | 0 |
| `cloudbuild-trigger` | 8 | 0 | 0 | 1 | 1 | 0 |
| `domain-mapping` | 0 | 0 | 1 | 0 | 1 | 0 |
| `firestore-database` | 4 | 0 | 0 | 0 | 0 | 0 |
| `firewall-rule` | 3 | 0 | 0 | 2 | 2 | 0 |
| `gke-autopilot-cluster` | 1 | 0 | 0 | 1 | 1 | 0 |
| `gke-standard-cluster` | 0 | 0 | 0 | 0 | 0 | 0 |
| `load-balancer-https` | 0 | 0 | 0 | 1 | 1 | 0 |
| `load-balancer-https-multihost` | 0 | 1 | 0 | 1 | 0 | 0 |
| `network` | 2 | 0 | 0 | 2 | 2 | 0 |
| `project-services` | 1 | 0 | 0 | 1 | 1 | 1 |
| `pubsub-topic` | 0 | 0 | 0 | 0 | 0 | 1 |
| `redis-instance` | 2 | 0 | 0 | 1 | 1 | 0 |
| `secret-manager-secret` | 13 | 0 | 0 | 94 | 91 | 3 |
| `secret-manager-secret-bulk` | 1 | 17 | 1 | 0 | 0 | 0 |
| `service-account` | 25 | 33 | 1 | 21 | 17 | 10 |
| `storage-bucket` | 14 | 0 | 0 | 4 | 4 | 0 |
| `workload-identity-pool` | 5 | 7 | 0 | 3 | 3 | 0 |

## How to read the generated tables

- The catalogue lists each module directory once. It is the source of truth for what exists. Do not rely on the older "module inventory" in skill text or the root `README.md`.
- The usage matrix shows, per environment (`development`, `qa`, `production`, `mcp`, `platform-services`, `vertex`), which modules are instantiated. A module with no usage in an environment means that environment has no resource of that type under Terraform, or declares it as a direct resource.
- A module used in only one environment is usually a recent addition or a specialised one. Check its README before reusing it elsewhere.
- Usage counts reflect module call blocks in the `.tf` files. They are not counts of live resources, because `for_each` and `count` multiply them.

## Module structure and README rules

Each module normally has `main.tf`, `variables.tf`, `outputs.tf`, `versions.tf` and a `README.md`. At the time of writing `cloud-deploy-pipeline` and `firestore-database` have no README, so read their `variables.tf` directly.

The module READMEs record decisions that are not obvious from the code. Typical content:

- The exact `terraform import` address and ID order for the resource.
- Which live settings the defaults were chosen to match, so an import shows no diff.
- Findings from importing QA (what was imported, what was deliberately excluded, what was flagged but not fixed).

When you change a module's behaviour, update its README in the same PR. Record the reason for the change, not only what changed.

## Module or direct resource

Add a module when the same resource shape is needed in more than one environment or more than once in an environment. Use a direct resource in the environment folder when it is genuinely one-off. Examples in the repo are the IAM grants, per-agent Firestore and service-account files, and `drift-reconciliation-*.tf` files under `environments/development` and `environments/qa`. The gateway Cloud Run service is also an inline resource in `environments/mcp/main.tf`. It was pulled out of the `cloud-run-service` module because it needs a per-instance `ignore_changes` list.

## Extending a module without breaking others

The convention is additive and visible in the variables:

1. Add a new optional variable. Default it to `null` (or an empty list or map) where the feature should be off, for example `image` in `cloud-run-service` and `private_network` in `cloud-sql-postgres`. Otherwise default it to the value every existing caller already has live.
2. Gate the new resource blocks on that variable, so existing callers produce an identical plan.
3. Do not reach for `ignore_changes` to hide a mismatch. The `cloud-sql-postgres` README says why: it also hides real future drift. Add a variable that can express the live value instead. `backup_enabled`, `ipv4_enabled` and `enable_private_path_for_google_cloud_services` were added this way for `alliance-mcp-gateway-db`.
4. `ignore_changes` must be a static list. A past attempt to build it with `concat` and a conditional broke `terraform plan` repo-wide from 2026-07-31 to 2026-08-02 (comment in `modules/cloud-run-service/main.tf`).
5. Plan every environment that calls the module, not just the one you are changing. A zero-diff plan on the other callers is the acceptance test.

## Known module gotchas

- `cloud-run-service`: one module, two resources. `api_version` defaults to `v1` (`google_cloud_run_service`). `v2` is opt-in and is the only path that supports more than one container per revision (sidecars). The VPC connector and Cloud SQL attachment variables apply to both API versions. The v1 resource is indexed (`this[0]`), and a `moved` block covers older state. A new import must target the indexed address. The import ID order is `<location>/<project_id>/<service_name>`, and the wrong order gives "Cannot import non-existent remote object". `min_instances = 0` omits the minScale annotation on purpose.
- `cloud-run-service` image tracking: `image` is a managed attribute that is not in `ignore_changes`, and it must carry an `@sha256` digest, never `:latest`. An image deployed outside Terraform therefore shows up as a plan diff. Some QA services (for example `cloudbuild-teams-notifier`) pull their image from the dev project's Artifact Registry. The module README documents this as a known cross-project dependency that was preserved on import and not fixed.
- `cloud-sql-postgres`: `edition` defaults to `ENTERPRISE` because GCP's own default of `ENTERPRISE_PLUS` rejects `db-custom-*` tiers. `ssl_mode` defaults to `TRUSTED_CLIENT_CERTIFICATE_REQUIRED`. A consumer that connects over plain TCP without the Auth Proxy needs `ENCRYPTED_ONLY` (seen with the QA Keycloak database). `prevent_destroy` is on, and the instance name is immutable, so a rename is a data migration. `deletion_protection` (Terraform-side) and `deletion_protection_enabled` (API-side) are separate variables.
- `storage-bucket`: `prevent_destroy = true`. The import ID is `<project>/<bucket_name>`. GCP-created `<project>_cloudbuild` staging buckets were deliberately not imported. Importing a bucket brings its live lifecycle rules into the plan, so read the diff.
- `artifact-registry`: besides standard Docker repositories it supports remote (pull-through) repositories for Docker upstreams (`remote_upstream_uri`) and npm upstreams other than npmjs (`remote_common_upstream_uri` with `format = "NPM"`). A worked example is `environments/development/sevis-npm-artifact-registry.tf`, which uses a native `import {}` block. QA has two repositories named `applicants-portal` in different regions, and the us-east1 one carries `goog-terraform-provisioned`, `team` and `environment` labels that point to a Terraform run outside this repo (per the module README).

## Known gaps

- The root `README.md` of the repo is partly stale (it still says development and production are not under Terraform). Module READMEs are written per import PR and may describe a state older than the code. Trust the `.tf` files.
- Whether the foreign Terraform state that labelled the us-east1 `applicants-portal` repository still exists could not be confirmed from the repo.
- Which modules are intentionally single-use, as opposed to not yet rolled out elsewhere, is not documented. Use the usage matrix and ask the repo owner before assuming.
