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:
- Creates a private Git repository in a given project with a validated, path-safe name.
- Attaches one or more Pub/Sub notification configs so a push (or any Git event) publishes to a topic in a chosen
message_format(JSONorPROTOBUF), under a named publisher service account — the standard way to trigger Cloud Build from CSR. - Grants reader / writer / admin IAM to lists of principals via
google_sourcerepo_repository_iam_member, so the developers who clone, the CI account that pushes mirror commits, and the platform admins are codified next to the repo. - Survives the common “repo already exists” race (CSR auto-creates a repo on first push) via
create_ignore_already_exists, so an import isn’t forced. - Emits the repository id, name, clone URL, and size, so downstream Cloud Build triggers and
gcloud source repos clonecalls have no copy-paste.
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:
- You want private Git hosting inside GCP with no external SCM, repos codified, reviewed, and reproduced identically across
dev/stg/prodprojects. - You drive Cloud Build from Git events and need each push to publish a Pub/Sub message your build trigger subscribes to — expressed as code, not clicked in the console.
- You run a mirror or migration target — a CSR repo that mirrors an upstream GitHub/Bitbucket repo, or a landing zone for code moving into GCP — and want the IAM around it managed in the same pipeline.
- You want repo-scoped clone/push/admin permissions managed as lists of service accounts and groups, promoted through the same review flow as the repo itself.
- You need a lightweight, free-tier-friendly Git remote for Terraform state-adjacent automation, internal tooling, or a Config Sync source, without standing up a separate Git server.
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 config — live/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 config — live/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
- Keep
writer_memberstiny and prefer service accounts. Push access to a CSR repo is how code (and therefore what Cloud Build runs) changes. Typically only your mirror/sync or release service account belongs inwriter_members; humans clone viareader_members. The module’s validation already blocksallUsers/allAuthenticatedUsersfrom the writer and admin lists — never widen them for convenience. - Trigger Cloud Build through Pub/Sub, with a dedicated publisher SA. Use
pubsub_configsrather than legacy polling triggers, and setservice_account_emailto a purpose-builtcsr-publisheraccount that holds onlyroles/pubsub.publisheron the topic — not the default compute service account. Create the topic and grant the binding before the repo so the first push publishes successfully. - Set
create_ignore_already_exists = truefor push-first repos. CSR auto-creates a repo on a developer’s firstgit push, which races a Terraform apply and causes an “already exists” error. Adopting the existing repo keeps the pipeline idempotent and avoids a manualterraform import. - Mind the free tier and treat CSR as a mirror, not your primary SCM. Billing kicks in past 5 project-users and 50 GB of storage+egress per month; large binary histories or hundreds of human collaborators get expensive and lack PR review. Keep code review on your main SCM and use CSR as a build-trigger source or migration target. Watch the
sizeoutput to catch repos bloating from committed artifacts. - Name repos deliberately and use slash-grouping. Give each repo a purpose-clear
nameand exploit the allowed/to namespace by team or domain (platform/config-sync,payments/checkout-api); the name appears verbatim in the clone URL and every IAM binding, so a clear convention pays off across a fleet. - Manage repo IAM at the repo, and keep
admin_membersto owners. Grant the narrowest of reader/writer/admin per repo via this module rather than broad project-levelroles/source.*, and reserveroles/source.adminfor a small platform/owners group, since admins can rewrite the repo’s IAM and notification configs.