IaC GCP

Terraform Module: GCP Cloud Source Repositories — One Reusable Private Git Repo Pattern with Pub/Sub Triggers and IAM

Quick take — Build a production-ready Terraform module for GCP Cloud Source Repositories using google_sourcerepo_repository — private Git repos, Pub/Sub push notifications for CI triggers, reader/writer/admin IAM bindings and clean outputs from one var-driven interface. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "source_repositories" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-source-repositories?ref=v1.0.0"

  project_id = "..."  # GCP project ID that owns the repository.
  name       = "..."  # Repository name; alphanumeric start, `._-/` allowed (sl…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Cloud Source Repositories (CSR) is Google Cloud’s fully-managed, private Git hosting service. Each repository is a project-scoped Git remote you can git push to over HTTPS or SSH, mirror from GitHub/Bitbucket, browse in the console, and — crucially — wire to Pub/Sub so every push emits a message that Cloud Build (or any subscriber) can turn into a build trigger. It carries no separate compute cost: you pay nothing for the repo itself within the free tier (up to 5 project-users and 50 GB storage/egress per month), with modest per-user and per-GB charges beyond that. The unit of management is the repository — a named Git remote addressed as https://source.developers.google.com/p/PROJECT/r/REPO, where every repo carries its own IAM policy and an optional list of Pub/Sub notification configs.

Creating one repo by hand is trivial; running them consistently across a fleet is not. The same questions come back every time: which project, who can git push versus only clone, and does a push fire a Pub/Sub event that kicks off a build. Wrapping google_sourcerepo_repository in a module gives you one opinionated interface that:

The result: every repo in the org is created the same way, the Pub/Sub-to-Cloud-Build wiring is not forgotten, and “who can push” is a reviewed list rather than a console click.

When to use it

Reach for this module when:

Skip it (or pair it with something else) if you need rich pull-request review, code owners, or a large collaborative developer experience — CSR is intentionally minimal, and most teams keep their primary SCM on GitHub/GitLab and use CSR as a mirror or a build-trigger source rather than the day-to-day code-review surface. Note also that Google has placed CSR in maintenance for new customers; existing projects continue to work, and this module manages those existing repos and their triggers cleanly.

Module structure

terraform-module-gcp-source-repositories/
├── versions.tf       # provider + Terraform version pins
├── main.tf           # google_sourcerepo_repository + Pub/Sub configs + IAM members
├── variables.tf      # var-driven inputs with validations
└── outputs.tf        # repo id/name + clone URL + size

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Canonical HTTPS clone URL CSR exposes for a repo, e.g.
  # https://source.developers.google.com/p/kloudvin-prod/r/checkout-api
  clone_url = "https://source.developers.google.com/p/${var.project_id}/r/${var.name}"

  # De-duplicated principal sets, each expanded to one IAM member resource.
  reader_members = { for m in distinct(var.reader_members) : m => m }
  writer_members = { for m in distinct(var.writer_members) : m => m }
  admin_members  = { for m in distinct(var.admin_members) : m => m }

  # Pub/Sub notification configs keyed by topic for a stable for_each.
  pubsub_configs = { for c in var.pubsub_configs : c.topic => c }
}

resource "google_sourcerepo_repository" "this" {
  project = var.project_id
  name    = var.name

  # CSR silently auto-creates a repo on first `git push`. When true, an apply
  # that races that auto-create adopts the existing repo instead of erroring,
  # so you never have to `terraform import` a repo a developer beat you to.
  create_ignore_already_exists = var.create_ignore_already_exists

  # One pubsub_configs block per topic. A push to the repo publishes a message
  # to the topic; Cloud Build (or any subscriber) consumes it to trigger a build.
  dynamic "pubsub_configs" {
    for_each = local.pubsub_configs
    content {
      topic                 = pubsub_configs.value.topic
      message_format        = pubsub_configs.value.message_format
      service_account_email = pubsub_configs.value.service_account_email
    }
  }
}

# Read-only access (clone/browse). Maps to roles/source.reader on the repo.
resource "google_sourcerepo_repository_iam_member" "reader" {
  for_each = local.reader_members

  project    = google_sourcerepo_repository.this.project
  repository = google_sourcerepo_repository.this.name
  role       = "roles/source.reader"
  member     = each.value
}

# Read-write access (push). Maps to roles/source.writer on the repo.
resource "google_sourcerepo_repository_iam_member" "writer" {
  for_each = local.writer_members

  project    = google_sourcerepo_repository.this.project
  repository = google_sourcerepo_repository.this.name
  role       = "roles/source.writer"
  member     = each.value
}

# Admin (manage repo settings + IAM). Maps to roles/source.admin on the repo.
resource "google_sourcerepo_repository_iam_member" "admin" {
  for_each = local.admin_members

  project    = google_sourcerepo_repository.this.project
  repository = google_sourcerepo_repository.this.name
  role       = "roles/source.admin"
  member     = each.value
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns the Cloud Source Repository."
  type        = string
}

variable "name" {
  description = "Repository name. May include slashes for grouping (e.g. \"team-a/checkout-api\"); each segment must be path-safe. Used verbatim in the clone URL."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$", var.name)) && !can(regex("//|\\.\\.", var.name))
    error_message = "name must start with an alphanumeric, contain only letters/digits/._-/ (slashes allowed for grouping), be at most 128 chars, and contain no empty segments (//) or '..'."
  }
}

variable "create_ignore_already_exists" {
  description = "If true, an apply that finds the repo already present (e.g. auto-created on a developer's first push) adopts it rather than failing. Recommended for repos that may be pushed to before Terraform runs."
  type        = bool
  default     = true
}

variable "pubsub_configs" {
  description = <<-EOT
    Pub/Sub notification configs. Each entry makes a Git push publish a message
    to a topic, which Cloud Build (or any subscriber) consumes to trigger a build:
      - topic:                 full topic resource ID
                               (projects/PROJECT/topics/TOPIC). The repo must exist
                               and the CSR service agent must be able to publish.
      - message_format:        "JSON" or "PROTOBUF".
      - service_account_email: identity CSR publishes as; must hold
                               roles/pubsub.publisher on the topic. Use a dedicated
                               service account, not the default compute SA.
    Example:
      [
        {
          topic                 = "projects/kloudvin-prod/topics/csr-checkout-api"
          message_format        = "JSON"
          service_account_email = "csr-publisher@kloudvin-prod.iam.gserviceaccount.com"
        }
      ]
  EOT
  type = list(object({
    topic                 = string
    message_format        = optional(string, "JSON")
    service_account_email = string
  }))
  default = []

  validation {
    condition = alltrue([
      for c in var.pubsub_configs : contains(["JSON", "PROTOBUF"], c.message_format)
    ])
    error_message = "Each pubsub_configs.message_format must be either \"JSON\" or \"PROTOBUF\"."
  }

  validation {
    condition = alltrue([
      for c in var.pubsub_configs : can(regex("^projects/.+/topics/.+$", c.topic))
    ])
    error_message = "Each pubsub_configs.topic must be a full topic resource ID: projects/PROJECT/topics/TOPIC."
  }

  validation {
    condition     = length(distinct([for c in var.pubsub_configs : c.topic])) == length(var.pubsub_configs)
    error_message = "pubsub_configs topics must be unique; a repo cannot have two notification configs for the same topic."
  }
}

variable "reader_members" {
  description = "Principals granted roles/source.reader (clone/browse) on this repo, e.g. [\"group:developers@kloudvin.com\", \"serviceAccount:config-sync@kloudvin-prod.iam.gserviceaccount.com\"]."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.reader_members : can(regex("^(user|group|serviceAccount|domain|allUsers|allAuthenticatedUsers):?", m))])
    error_message = "Each reader member must be a valid IAM principal (user:, group:, serviceAccount:, domain:, allUsers, allAuthenticatedUsers)."
  }
}

variable "writer_members" {
  description = "Principals granted roles/source.writer (push) on this repo, e.g. [\"serviceAccount:mirror-sync@kloudvin-prod.iam.gserviceaccount.com\"]. Keep this list tight — wildcards are rejected."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.writer_members : can(regex("^(user|group|serviceAccount|domain):", m))])
    error_message = "Each writer member must be user:, group:, serviceAccount:, or domain: — allUsers/allAuthenticatedUsers are not allowed for push."
  }
}

variable "admin_members" {
  description = "Principals granted roles/source.admin (manage repo + its IAM) on this repo. Reserve for a small platform/owners group, e.g. [\"group:platform-admins@kloudvin.com\"]."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.admin_members : can(regex("^(user|group|serviceAccount):", m))])
    error_message = "Each admin member must be user:, group:, or serviceAccount: — wildcards and domain: are not allowed for admin."
  }
}

outputs.tf

output "id" {
  description = "Fully-qualified resource id of the repository (projects/PROJECT/repos/NAME)."
  value       = google_sourcerepo_repository.this.id
}

output "name" {
  description = "Repository name (the name input), used as the repository field in IAM and gcloud commands."
  value       = google_sourcerepo_repository.this.name
}

output "url" {
  description = "HTTPS clone URL reported by CSR, e.g. https://source.developers.google.com/p/kloudvin-prod/r/checkout-api."
  value       = google_sourcerepo_repository.this.url
}

output "clone_url" {
  description = "Computed HTTPS clone URL (mirrors url); convenient when wiring Cloud Build triggers or gcloud source repos clone."
  value       = local.clone_url
}

output "size" {
  description = "Disk usage of the repository in bytes, as reported by the API."
  value       = google_sourcerepo_repository.this.size
}

output "pubsub_topics" {
  description = "List of Pub/Sub topic resource IDs this repo publishes push events to."
  value       = [for c in var.pubsub_configs : c.topic]
}

output "repository_gcp_resource" {
  description = "The full google_sourcerepo_repository object for advanced composition."
  value       = google_sourcerepo_repository.this
}

How to use it

A private repository for a checkout service, mirrored from GitHub by a sync service account (writer), cloneable by the developer group (reader), administered by the platform group (admin), and emitting a Pub/Sub event on every push so Cloud Build can trigger a CI run:

module "cloud_source_repositories" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-source-repositories?ref=v1.0.0"

  project_id = "kloudvin-prod"
  name       = "checkout-api"

  # A developer may push before this module runs; adopt rather than fail.
  create_ignore_already_exists = true

  pubsub_configs = [
    {
      topic                 = "projects/kloudvin-prod/topics/csr-checkout-api"
      message_format        = "JSON"
      service_account_email = "csr-publisher@kloudvin-prod.iam.gserviceaccount.com"
    }
  ]

  writer_members = [
    "serviceAccount:mirror-sync@kloudvin-prod.iam.gserviceaccount.com",
  ]
  reader_members = [
    "group:developers@kloudvin.com",
  ]
  admin_members = [
    "group:platform-admins@kloudvin.com",
  ]
}

A downstream resource that consumes an output — a Cloud Build trigger that listens on the same Pub/Sub topic the repo publishes to, fired by the push event and building the repository identified by the module’s name:

# Trigger a build whenever the repo publishes a push event to its topic.
resource "google_cloudbuild_trigger" "checkout_api_ci" {
  project  = "kloudvin-prod"
  name     = "checkout-api-ci"
  location = "asia-south1"

  pubsub_config {
    topic = "projects/kloudvin-prod/topics/csr-checkout-api"
  }

  # Build from the repo this module created (by name, not copy-pasted).
  source_to_build {
    repository = module.cloud_source_repositories.id
    ref        = "refs/heads/main"
    repo_type  = "CLOUD_SOURCE_REPOSITORIES"
  }

  git_file_source {
    path      = "cloudbuild.yaml"
    repository = module.cloud_source_repositories.id
    revision  = "refs/heads/main"
    repo_type = "CLOUD_SOURCE_REPOSITORIES"
  }
}

# Hand the clone URL to docs / onboarding without copy-paste.
output "checkout_clone_url" {
  description = "Run: gcloud source repos clone <name> --project=<project>, or git clone this URL."
  value       = module.cloud_source_repositories.clone_url
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/source_repositories/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-source-repositories?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/source_repositories && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string Yes GCP project ID that owns the repository.
name string Yes Repository name; alphanumeric start, ._-/ allowed (slashes group), max 128 chars, no // or ...
create_ignore_already_exists bool true No Adopt an already-present repo (e.g. auto-created on first push) instead of failing the apply.
pubsub_configs list(object({topic, message_format, service_account_email})) [] No Push-notification configs; each publishes Git events to a topic for Cloud Build triggers.
reader_members list(string) [] No Principals granted roles/source.reader (clone/browse).
writer_members list(string) [] No Principals granted roles/source.writer (push); wildcards rejected.
admin_members list(string) [] No Principals granted roles/source.admin (manage repo + IAM); reserve for a small group.

Outputs

Name Description
id Fully-qualified resource id (projects/PROJECT/repos/NAME).
name Repository name (the name input); used in IAM and gcloud commands.
url HTTPS clone URL reported by CSR.
clone_url Computed HTTPS clone URL (mirrors url); convenient for triggers and clones.
size Disk usage of the repository in bytes.
pubsub_topics List of Pub/Sub topic resource IDs the repo publishes push events to.
repository_gcp_resource The full google_sourcerepo_repository object for advanced composition.

Enterprise scenario

KloudVin keeps its canonical code on GitHub but mirrors a set of deployment repos into Cloud Source Repositories so that builds and Config Sync run entirely inside GCP with no egress to an external SCM. In each environment project the platform team instantiates this module per service: a mirror-sync service account holds roles/source.writer to push mirrored commits, the developers group gets roles/source.reader to clone and browse, and platform-admins get roles/source.admin. Every repo carries a pubsub_configs entry publishing to a per-repo topic as a dedicated csr-publisher service account, and a matching google_cloudbuild_trigger subscribes to that topic — so a mirrored push to prod deterministically fires the right Cloud Build pipeline, and push rights to production deployment code are an auditable, PR-reviewed list rather than a console grant.

Best practices

TerraformGCPCloud Source RepositoriesModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading