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
- A new workload (Cloud Run, GKE Workload Identity, Compute Engine, Dataflow) needs a dedicated runtime identity instead of sharing the default compute SA.
- A CI/CD system (GitHub Actions, GitLab, Azure DevOps) needs to deploy to GCP via Workload Identity Federation and must impersonate a deploy SA without a stored key.
- You want one principal to impersonate another (
roles/iam.serviceAccountTokenCreator) for short-lived, auditable credentials. - You are enforcing “one service, one service account” with consistent naming and least-privilege IAM, and want drift caught in
terraform plan.
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 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/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
- Default to key-free. Leave
create_key = falseand use Workload Identity Federation (CI) orworkloadIdentityUser(GKE) and impersonation. Static JSON keys don’t expire, are the #1 GCP leak vector, and should be enforced off with theiam.disableServiceAccountKeyCreationorg policy. - Least privilege, scoped down. Grant only the
project_rolesactually used, and prefer binding on the resource (e.g. a single Pub/Sub topic via thememberoutput) over a broad project role. Avoidroles/ownerandroles/editoron any service account. - Scope impersonation tightly. In
token_creator_members, bind to a specificprincipalSet://...attribute.repository/org/repo(or a single group), neverallAuthenticatedUsersor a whole pool —serviceAccountTokenCreatoris effectively “become this identity.” - One SA per workload, named for it. Use a descriptive
account_idlikeorders-workerand always setdescription; the regex validation keeps names within GCP’s 6-30 char limit and consistent across the estate. - Don’t reuse
account_ids. Deleting and recreating an SA with the same email creates a newunique_id; existing IAM bindings referencing the old identity silently stop working. Track theunique_idoutput and treat SA names as permanent. - Disable before delete. Set
disabled = trueto break-glass a compromised or deprecated SA and watch for breakage before destroying it — deletion is hard to reverse and orphans dependent resources.