IaC GCP

Terraform Module: GCP Service Account — workload identities without leaked keys

Quick take — Provision a GCP service account with Terraform: project-scoped IAM bindings, Workload Identity Federation, optional impersonation, and key-free auth. A reusable hashicorp/google ~> 5.0 module. 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 "service_account" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-service-account?ref=v1.0.0"

  project_id = "..."  # GCP project ID in which to create the service account.
  account_id = "..."  # Unique ID left of the `@`; 6-30 chars, lowercase letter…
}

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

What this module is

A Google Cloud service account (SA) is a non-human identity that a workload — a Cloud Run service, a GKE pod, a Compute Engine VM, a CI pipeline — uses to authenticate to Google APIs and to be granted IAM roles. It is identified by an email of the form name@project-id.iam.gserviceaccount.com and a stable numeric unique_id.

On its own, google_service_account only creates the identity. In production you almost always need three more things wired up alongside it: project-level IAM role bindings so the SA can actually do something, Workload Identity Federation / impersonation bindings so other principals (a GitHub Actions OIDC token, a Kubernetes service account, a human group) can act as the SA without a downloadable key, and a deliberate decision about whether a static JSON key is created at all. This module bundles those concerns so every service account in your estate is created the same way: least-privilege, key-free by default, and consistently named.

Wrapping it in a module means a team requests “an SA for the orders service with roles/pubsub.publisher, impersonatable by the orders CI pipeline” in ~10 lines, instead of hand-writing a google_service_account, several google_project_iam_member blocks, and a google_service_account_iam_member for the federation binding — and getting one of them subtly wrong.

When to use it

Reach for something else when you only need a human to access GCP (use Google Groups + IAM, not an SA), or when the platform already injects an identity you don’t manage (e.g. the GKE node default — though even there a dedicated SA is the better practice).

Module structure

terraform-module-gcp-service-account/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Build a stable, deterministic key for each project-level role binding
  # so adding/removing a role never churns unrelated bindings.
  project_iam_bindings = {
    for role in var.project_roles :
    role => role
  }
}

resource "google_service_account" "this" {
  project      = var.project_id
  account_id   = var.account_id
  display_name = var.display_name
  description  = var.description
  disabled     = var.disabled
}

# Grant the service account project-level roles (least privilege).
resource "google_project_iam_member" "roles" {
  for_each = local.project_iam_bindings

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.this.email}"
}

# Allow listed principals to IMPERSONATE this SA (generate access/ID tokens).
# Used for Workload Identity Federation and human/CI impersonation — key-free.
resource "google_service_account_iam_member" "token_creators" {
  for_each = toset(var.token_creator_members)

  service_account_id = google_service_account.this.name
  role               = "roles/iam.serviceAccountTokenCreator"
  member             = each.value
}

# Allow listed principals to attach/run-as this SA on a resource
# (e.g. a GKE Workload Identity binding via member workloadIdentityUser,
# or letting a deploy SA set this SA on a Cloud Run revision).
resource "google_service_account_iam_member" "users" {
  for_each = toset(var.service_account_user_members)

  service_account_id = google_service_account.this.name
  role               = "roles/iam.serviceAccountUser"
  member             = each.value
}

# Optional static JSON key — disabled by default. Prefer WIF/impersonation.
resource "google_service_account_key" "this" {
  count = var.create_key ? 1 : 0

  service_account_id = google_service_account.this.name
  public_key_type    = "TYPE_X509_PEM_FILE"
}

variables.tf

variable "project_id" {
  description = "GCP project ID in which to create the service account."
  type        = string
}

variable "account_id" {
  description = "The unique ID (left of the @) for the SA. 6-30 chars, lowercase letters, digits and hyphens; must start with a letter."
  type        = string

  validation {
    condition     = can(regex("^[a-z]([a-z0-9-]{4,28}[a-z0-9])$", var.account_id))
    error_message = "account_id must be 6-30 chars, start with a lowercase letter, contain only lowercase letters, digits and hyphens, and not end with a hyphen."
  }
}

variable "display_name" {
  description = "Human-friendly display name shown in the console."
  type        = string
  default     = null
}

variable "description" {
  description = "Free-text description of the SA's purpose (recommended for auditability)."
  type        = string
  default     = null
}

variable "disabled" {
  description = "If true, the service account is created in a disabled state and cannot authenticate."
  type        = bool
  default     = false
}

variable "project_roles" {
  description = "List of project-level IAM roles to grant the SA (e.g. [\"roles/pubsub.publisher\"]). Each must be a roles/* string."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for r in var.project_roles : can(regex("^(roles/|projects/[^/]+/roles/)", r))])
    error_message = "Each entry in project_roles must be a predefined (roles/...) or custom (projects/<id>/roles/...) role."
  }
}

variable "token_creator_members" {
  description = "IAM members granted roles/iam.serviceAccountTokenCreator on this SA (impersonation / Workload Identity Federation). e.g. [\"principalSet://iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/gh/attribute.repository/org/repo\"]."
  type        = list(string)
  default     = []
}

variable "service_account_user_members" {
  description = "IAM members granted roles/iam.serviceAccountUser on this SA (attach/run-as, e.g. GKE workloadIdentityUser or a deploy SA setting this SA on Cloud Run)."
  type        = list(string)
  default     = []
}

variable "create_key" {
  description = "If true, create a static JSON service account key. Disabled by default — prefer Workload Identity Federation or impersonation. Org policy iam.disableServiceAccountKeyCreation may block this."
  type        = bool
  default     = false
}

outputs.tf

output "id" {
  description = "Fully qualified SA resource id: projects/{project}/serviceAccounts/{email}."
  value       = google_service_account.this.id
}

output "name" {
  description = "The resource name (projects/{project}/serviceAccounts/{unique_id}) used by IAM resources."
  value       = google_service_account.this.name
}

output "email" {
  description = "The SA email — used as serviceAccount:<email> in IAM members and as the runtime identity."
  value       = google_service_account.this.email
}

output "member" {
  description = "Pre-formatted IAM member string: serviceAccount:<email>. Handy for granting this SA roles on other resources."
  value       = "serviceAccount:${google_service_account.this.email}"
}

output "unique_id" {
  description = "Stable numeric unique ID of the SA (survives delete/recreate gaps in audit logs)."
  value       = google_service_account.this.unique_id
}

output "key_private_key" {
  description = "Base64-encoded private key JSON, only when create_key = true. Sensitive — avoid; prefer WIF."
  value       = var.create_key ? google_service_account_key.this[0].private_key : null
  sensitive   = true
}

How to use it

A Cloud Run worker for the orders service that publishes to Pub/Sub, deployed by a GitHub Actions pipeline via Workload Identity Federation — no JSON key anywhere:

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

  project_id   = "kloudvin-prod"
  account_id   = "orders-worker"
  display_name = "Orders Worker (Cloud Run)"
  description  = "Runtime identity for the orders Cloud Run service; publishes order events."

  # Least-privilege runtime permissions
  project_roles = [
    "roles/pubsub.publisher",
    "roles/cloudtrace.agent",
  ]

  # Let the GitHub Actions OIDC identity for this repo impersonate the SA
  # so the pipeline can mint short-lived tokens — no downloadable key.
  token_creator_members = [
    "principalSet://iam.googleapis.com/projects/843217650912/locations/global/workloadIdentityPools/github-pool/attribute.repository/kloudvin/orders-service",
  ]

  # create_key intentionally omitted -> defaults to false (key-free)
}

# Downstream: run the Cloud Run service AS this service account using the email output.
resource "google_cloud_run_v2_service" "orders" {
  name     = "orders-worker"
  location = "asia-south1"
  project  = "kloudvin-prod"

  template {
    service_account = module.service_account.email

    containers {
      image = "asia-south1-docker.pkg.dev/kloudvin-prod/apps/orders-worker:latest"
    }
  }
}

# Downstream: grant THIS SA publish rights on a specific topic using the member output.
resource "google_pubsub_topic_iam_member" "orders_publish" {
  project = "kloudvin-prod"
  topic   = "order-events"
  role    = "roles/pubsub.publisher"
  member  = module.service_account.member
}

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/service_account/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  account_id = "..."
}

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

cd live/prod/service_account && 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 in which to create the service account.
account_id string Yes Unique ID left of the @; 6-30 chars, lowercase letter start, validated by regex.
display_name string null No Human-friendly display name shown in the console.
description string null No Free-text purpose of the SA, recommended for auditability.
disabled bool false No Create the SA disabled so it cannot authenticate.
project_roles list(string) [] No Project-level roles/* to grant the SA (validated as predefined or custom roles).
token_creator_members list(string) [] No Members granted roles/iam.serviceAccountTokenCreator (impersonation / WIF).
service_account_user_members list(string) [] No Members granted roles/iam.serviceAccountUser (attach/run-as, e.g. GKE workloadIdentityUser).
create_key bool false No Create a static JSON key. Off by default; prefer WIF/impersonation.

Outputs

Name Description
id Fully qualified SA resource id: projects/{project}/serviceAccounts/{email}.
name Resource name projects/{project}/serviceAccounts/{unique_id} used by IAM resources.
email The SA email, used as the runtime identity and as serviceAccount:<email> members.
member Pre-formatted serviceAccount:<email> string for granting the SA roles elsewhere.
unique_id Stable numeric unique ID of the SA.
key_private_key Base64 JSON private key, only when create_key = true. Sensitive.

Enterprise scenario

A retail platform runs ~40 microservices across three GCP projects (dev/staging/prod), each deployed by its own GitHub repository. The platform team adopts a policy of one service account per service, zero static keys, enforced by the org policy iam.disableServiceAccountKeyCreation. Each service’s Terraform calls this module with its account_id, the exact project_roles it needs, and a token_creator_members entry scoped to attribute.repository/<repo> in the company’s Workload Identity pool — so only that repo’s CI can impersonate that SA. Onboarding a new service becomes a reviewable 12-line PR, and security can audit least-privilege grants across the fleet by grepping module inputs in one repo.

Best practices

TerraformGCPService AccountModuleIaC
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