Creates and manages a single Google Cloud IAM service account (
google_service_account) — never a key. Targetshashicorp/google ~> 7.0, Terraform>= 1.12.0.
- 🔑 Creates one
google_service_account— the IAM identity GCP workloads and CI/CD pipelines assume to call Google Cloud APIs. - 🚫 Never creates a
google_service_account_key— no downloadable key material is generated by this module, ever. - 🏷️ Exposes the identity in every consumable form (
id,email,name,unique_id,member) so sibling IAM-binding and compute modules can wire it in without re-deriving any string. - 🧱 Standalone by design — a service account has no natural
for_each-managed child collection, so this module stays a single keystone resource with no composite shape.
💡 Why it matters: a service account is the identity every other workload-facing module in this library eventually grants access to or runs as. Getting its creation right — and keeping key material out of the picture entirely — is a foundational control, not an implementation detail.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
graph LR
SA["terraform-google-service-account"]:::thisModule
IAMB["terraform-google-project-iam-bindings"]:::keystoneSibling
GKE["terraform-google-gke-cluster"]:::sibling
CI["terraform-google-compute-instance"]:::sibling
IT["terraform-google-instance-template"]:::sibling
CR["terraform-google-cloud-run-service"]:::sibling
PS["terraform-google-project-services"]:::external
PS -- "enables iam.googleapis.com" --> SA
SA -- "email consumed as member" --> IAMB
SA -- "email / id consumed by service_account field (future)" --> GKE
SA -- "email / id consumed by service_account field (future)" --> CI
SA -- "email / id consumed by service_account field (future)" --> IT
SA -- "email consumed by service_account.email (future)" --> CR
classDef thisModule fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:1px;
classDef keystoneSibling fill:#174EA6,color:#ffffff,stroke:#174EA6,stroke-width:1px;
classDef sibling fill:#E8EAED,color:#202124,stroke:#9AA0A6,stroke-width:1px;
classDef external fill:#E8EAED,color:#202124,stroke:#9AA0A6,stroke-width:1px,stroke-dasharray: 3 3;
terraform-google-project-services (external, dashed) must have already enabled iam.googleapis.com
before this module applies. terraform-google-project-iam-bindings is this module's most direct
consumer today, taking the member output straight into its own for_each binding map. The
compute/GKE/Cloud Run siblings are documented future consumers of email/id (their
service_account fields) — this library does not yet ship those modules wired to this one, but the
identity contract is stable today.
graph LR
subgraph Inputs
A["var.account_id"]
B["var.display_name"]
C["var.description"]
D["var.disabled"]
E["var.create_ignore_already_exists"]
F["var.timeouts"]
end
R["google_service_account.this"]:::thisModule
A --> R
B --> R
C --> R
D --> R
E --> R
F --> R
R --> O1["output: id"]
R --> O2["output: email"]
R --> O3["output: name"]
R --> O4["output: unique_id"]
R --> O5["output: member"]
classDef thisModule fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:1px;
Resource inventory: exactly one resource, google_service_account.this. No nested/repeating
configuration blocks (the live schema has none for this resource) and no child resources — no
for_each, no composite shape.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/google provider |
~> 7.0 |
| Provider block | None — the caller configures google (project, region/zone, auth) |
Schema notes that bite (verified against hashicorp/google v7.39.0 via terraform-registry):
account_idis force-new — changing it destroys and recreates the service account. There is no in-place rename.account_idmust be 6–30 characters, start with a lowercase letter, and match[a-z]([-a-z0-9]*[a-z0-9])(RFC1035-style) — a hard GCP API constraint, enforced here at plan time viavalidation {}.disabledhas no effect during creation — GCP always creates the service account enabled; disabling it requires a subsequentapplywithdisabled = true.descriptionmust be ≤ 256 UTF-8 bytes; this module'svalidation {}checks character count as a plan-time proxy (Terraform has no native byte-length function) — non-ASCII text can hit the real byte limit before 256 characters.- This resource has no
self_linkand nolabelsargument — both are common on most GCP resources but absent from the IAM Service Account API. See Architecture Notes. timeoutssupports onlycreate(default 5 minutes) — noupdate/deletetimeout exists in the schema.
roles/iam.serviceAccountAdminon the target project — create, update, and delete service accounts. No broader project-level role is required by this module alone.
iam.googleapis.comenabled on the target project (viaterraform-google-project-services, applied before this module).
terraform-google-service-account/
├── providers.tf # required_providers (hashicorp/google ~> 7.0) + required_version — no provider {} block
├── variables.tf # account_id, display_name, description, disabled, create_ignore_already_exists, timeouts
├── main.tf # google_service_account.this — the sole resource
├── outputs.tf # id, email, name, unique_id, member
├── README.md # this file
├── SCOPE.md # lightweight cross-module contract
└── examples/ # runnable example matching the Quick Start below
module "runtime_service_account" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "app-runtime"
display_name = "Application runtime identity"
}The caller's root module configures the google provider (project, region/zone, and
authentication via ADC, Workload Identity Federation, or a service account key supplied
out-of-band) — this module accepts none of those as variables.
Consumes
| Input | Type | Source module |
|---|---|---|
| (none — foundational IAM module, no cross-module inputs) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
projects/{{project}}/serviceAccounts/{{email}} |
Any module needing the Terraform-internal reference |
email |
Service account e-mail address | IAM binding modules; compute/GKE/Cloud Run service_account fields (future) |
name |
Fully-qualified service account name | Diagnostic/reference use |
unique_id |
System-assigned unique id | IAM condition expressions, audit correlation |
member |
serviceAccount:{email} identity string |
terraform-google-project-iam-bindings member field |
This resource has no self_link — none is emitted.
1 · Minimal call
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "app-runtime"
}💡 The empty-beyond-
account_idcall is the safe, complete path: no key is ever created, and every other field takes its documented default.
2 · Display name and description
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "billing-export"
display_name = "Billing Export Job"
description = "Runs the nightly BigQuery billing export; owned by the FinOps team."
}ℹ️
display_nameanddescriptioncan both be updated in place — neither is force-new.
3 · Creating a disabled service account
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "legacy-batch-job"
disabled = true
}
⚠️ Per the live provider docs,disabledhas no effect during creation — GCP always creates the account enabled. Settingdisabled = trueon the initialapplydisables it on the very nextapply(Terraform re-applies the desired state), not atomically at creation. Do not assume the account is ever created in a disabled state on the first apply.
4 · Adopting an existing service account by email
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "shared-ci-runner"
create_ignore_already_exists = true
}
⚠️ create_ignore_already_exists = trueavoids a create-time conflict error if a service account with the same email already exists, but it does not import the account's full existing state into this module's Terraform state. Review the provider docs before relying on this for adoption.
5 · Custom create timeout
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "slow-org-policy-project"
timeouts = {
create = "10m"
}
}ℹ️ Only
createis configurable — the live schema exposes noupdate/deletetimeout for this resource.
6 · Identity for a GKE workload (future consumer)
module "gke_workload_sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "gke-orders-api"
display_name = "GKE Orders API workload identity"
}
# terraform-google-gke-cluster (or a Kubernetes-side IAM binding) would consume:
# module.gke_workload_sa.email
# as the Workload Identity-bound service account — no key material involved.💡 This is the intended pattern for GKE Workload Identity: the pod's Kubernetes service account is bound to this identity's
7 · Identity for a Cloud Run service (future consumer)
module "cloud_run_sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "cloud-run-api"
display_name = "Cloud Run API runtime identity"
}
# terraform-google-cloud-run-service would consume module.cloud_run_sa.email as its
# google_cloud_run_v2_service.template.service_account value.8 · Identity for a CI/CD pipeline via Workload Identity Federation
module "cicd_sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "github-actions-deploy"
display_name = "GitHub Actions deployment identity"
description = "Impersonated via Workload Identity Federation; no key material issued."
}🔒 Per this module suite's authentication model, this is the standard for CI/CD: GitHub Actions impersonates this service account through Workload Identity Federation. This module creates only the identity — the WIF pool/provider binding is configured separately, outside this module's scope.
9 · Multiple service accounts via root-module for_each
locals {
service_accounts = {
"orders-api" = "Orders API runtime identity"
"inventory-api" = "Inventory API runtime identity"
}
}
module "workload_sas" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
for_each = local.service_accounts
account_id = each.key
display_name = each.value
}ℹ️ This module itself has no internal
for_each(it is standalone, single-keystone) — a caller needing several service accounts wraps the module call in afor_eachat the root/composition level, as shown here.
10 · account_id at the minimum length boundary
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "sa-abc" # 6 characters — the minimum GCP allows
}
⚠️ account_idmust be 6-30 characters. A value shorter than 6 characters fails thevalidation {}block at plan time, before any API call is made.
11 · account_id at the maximum length boundary
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "app-runtime-prod-us-east1-01" # 28 characters — within the 30-char limit
}12 · Long description near the byte-limit boundary
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "audit-log-exporter"
description = "Exports audit logs from Cloud Logging to a long-term retention bucket for compliance review; owned by the Security Engineering team; rotated per the records retention schedule."
}
⚠️ Keep non-ASCII characters in mind: this module'svalidation {}checks character count (≤ 256), which is a proxy for GCP's actual 256-byte limit. Plain ASCII text (as above) has an identical character and byte count; accented or non-Latin text does not.
13 · Directly consuming `member` in a resource-scoped IAM binding
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "bucket-reader"
}
resource "google_storage_bucket_iam_member" "reader" {
bucket = "my-existing-bucket"
role = "roles/storage.objectViewer"
member = module.sa.member
}💡 A caller can consume
memberdirectly against anygoogle_*_iam_memberresource without going throughterraform-google-project-iam-bindings— the aggregation module is a convenience for project-scoped bindings, not the only valid consumption path.
14 · Referencing `email` for a Workload Identity Federation impersonation target
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "wif-deploy-target"
display_name = "WIF impersonation target for deploy pipeline"
}
# A separate WIF pool/provider module (not in this library's current catalog) would bind:
# principalSet://iam.googleapis.com/<pool>/attribute.repository/<org>/<repo>
# to module.sa.email via a google_service_account_iam_member with
# roles/iam.workloadIdentityUser — configured outside this module's scope.🔒 This module deliberately stops at creating the identity. Binding external principals to it (via
google_service_account_iam_member) is out of scope here — see SCOPE.md's "Out of scope / consumed by id" section.
15 · 🏗️ End-to-end composition
module "project_services" {
source = "git::https://github.com/microsoftexpert/terraform-google-project-services.git?ref=v1.0.0"
# Enables iam.googleapis.com (and any other APIs the composition needs) —
# applied before every other module in this composition.
}
module "orders_api_sa" {
source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
account_id = "orders-api"
display_name = "Orders API runtime identity"
description = "Runtime identity for the Orders API Cloud Run service."
depends_on = [module.project_services]
}
module "orders_api_iam" {
source = "git::https://github.com/microsoftexpert/terraform-google-project-iam-bindings.git?ref=v1.0.0"
bindings = {
"orders-api-pubsub-publisher" = {
role = "roles/pubsub.publisher"
member = "serviceAccount:${module.orders_api_sa.email}"
}
"orders-api-secret-accessor" = {
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${module.orders_api_sa.email}"
}
}
}💡 This wires
terraform-google-project-services→terraform-google-service-account→terraform-google-project-iam-bindingsin dependency order: APIs enabled first, then the identity created, then IAM roles granted additively viamember = "serviceAccount:${module.orders_api_sa.email}"(equivalently,module.orders_api_sa.member— both produce the identical string).
⚠️ Because service account creation is eventually consistent, and IAM grants can take up to ~60 seconds to propagate, a resource created immediately after in the sameapplythat depends on this service account having its granted roles already in effect may need a secondapplyto succeed cleanly on first-time bring-up. See Troubleshooting.
| Variable | Type | Required | Default | Notes |
|---|---|---|---|---|
account_id |
string |
Yes | — | Force-new; 6-30 chars, RFC1035-style |
display_name |
string |
No | null |
Updatable in place |
description |
string |
No | null |
≤ 256 UTF-8 bytes (character-count proxy enforced) |
disabled |
bool |
No | false |
No effect during creation |
create_ignore_already_exists |
bool |
No | false |
Skips create-time conflict, not a full import |
timeouts |
object({ create = optional(string) }) |
No | null |
Only create — schema has no update/delete timeout |
Full variable schemas
variable "account_id" {
type = string
# 6-30 chars, ^[a-z][-a-z0-9]{4,28}[a-z0-9]$ enforced via validation {}
}
variable "display_name" {
type = string
default = null
}
variable "description" {
type = string
default = null
# <= 256 characters enforced via validation {} (proxy for GCP's 256 UTF-8 byte limit)
}
variable "disabled" {
type = bool
default = false
}
variable "create_ignore_already_exists" {
type = bool
default = false
}
variable "timeouts" {
type = object({
create = optional(string)
})
default = null
}No labels variable exists on this module — see Architecture Notes for why.
| Output | Description |
|---|---|
id |
projects/{{project}}/serviceAccounts/{{email}} |
email |
Service account e-mail address |
name |
Fully-qualified service account name |
unique_id |
System-assigned unique id |
member |
serviceAccount:{email} identity string |
ℹ️ No
self_linkrow: this resource does not export one. Unlike most GCP resources covered by this library, the IAM Service Account API has no self_link concept —idis the only Terraform-internal reference form.
None of these outputs are secret-bearing; no sensitive = true is applied to any of them.
account_idis force-new. Changing it destroys and recreates the service account. Per the live provider docs, recreating a service account does not restore any IAM roles it previously held — those must be reapplied (typically automatic if the samefor_eachkey still grants them interraform-google-project-iam-bindings, but any binding managed outside Terraform is lost for good).- Eventual consistency on creation. GCP's IAM Service Account API is eventually consistent.
Granting IAM roles to, or otherwise referencing, a service account created in the same
applycan transiently fail even when the Terraform graph correctly orders creation before the dependent resource. This is an operational characteristic of the API, not a defect in this module or its callers. - No
labelsvariable — deliberate schema-driven omission. The livegoogle_service_accountschema (verified against the live provider schema,hashicorp/googlev7.39.0) has nolabelsargument. GCP's IAM Service Account API simply does not support resource labels, unlike the large majority of GCP resources this library targets. Per this module suite's own post-module checklist, a module documents this as an exception rather than fabricating a variable with no effect on the underlying resource — adding a decorativelabelsinput here would violate the house rule thatmain.tfis a total, honest renderer of what the resource actually accepts. - No
self_linkoutput — same reasoning. The resource simply does not export one. timeoutsmodels onlycreate. The schema has noupdate/deletetimeout for this resource; the variable intentionally does not offer fields the provider would silently ignore.
| Concern | Secure default | Opt-out (explicit) |
|---|---|---|
| Service account key material | This module never creates a google_service_account_key — no key resource, no variable that could enable one |
N/A — use Workload Identity Federation or short-lived, impersonated credentials instead |
| Service account enabled/disabled state | disabled = false (enabled) by default |
Caller sets disabled = true explicitly (effective on the apply after creation) |
| IAM role grants | Not managed by this module at all — grant roles additively via terraform-google-project-iam-bindings or a resource-scoped _iam_member, never an authoritative _iam_policy |
N/A — authoritative IAM policy is intentionally excluded from this library |
| Existing-resource adoption | create_ignore_already_exists = false by default (fails loudly on a naming collision rather than silently attaching to an unknown existing account) |
Caller sets create_ignore_already_exists = true explicitly |
cd terraform-google-service-account
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module source to ?ref=v1.0.0 — never a branch. This library is plan-only from an
authoring session; a human applies from CI with valid Workload Identity Federation or ADC
credentials.
terraform validate confirms internal type and reference consistency (e.g. account_id's
validation {} block, correct resource/output wiring). terraform fmt -check confirms canonical
formatting. Neither can catch GCP API-level rejections — quota limits, org-policy constraints (e.g.
constraints/iam.disableServiceAccountKeyCreation, which this module's own design already respects
by never creating a key), or the eventual-consistency behavior noted in Architecture Notes. Only a
real terraform plan/apply against a live project, with valid credentials, exercises those paths
— that step belongs to the consuming CI pipeline, not this authoring session.
$ terraform output
email = "orders-api@casey-prod-workloads.iam.gserviceaccount.com"
id = "projects/casey-prod-workloads/serviceAccounts/orders-api@casey-prod-workloads.iam.gserviceaccount.com"
member = "serviceAccount:orders-api@casey-prod-workloads.iam.gserviceaccount.com"
name = "projects/casey-prod-workloads/serviceAccounts/orders-api@casey-prod-workloads.iam.gserviceaccount.com"
unique_id = "114522374981726543210"
| Symptom | Cause | Fix |
|---|---|---|
plan fails with an account_id validation error |
Value is shorter than 6 characters, longer than 30, contains uppercase/underscore/invalid characters, or doesn't start with a lowercase letter | Adjust account_id to match ^[a-z]([-a-z0-9]*[a-z0-9])$, 6-30 characters total |
IAM binding apply immediately after creating this service account fails with "service account not found" or a permission-denied error |
Service account creation is eventually consistent; the identity isn't yet resolvable to the IAM API | Re-run apply (usually resolves within seconds to low minutes); avoid tight coupling of creation and first IAM grant in a single automated pipeline step without a retry |
Recreating this module (after an account_id change) leaves the workload without previously-granted IAM roles |
account_id is force-new; GCP does not carry forward IAM grants to a new service account resource, even with the same account_id |
Re-apply the IAM binding module (terraform-google-project-iam-bindings or equivalent) after the service account recreation completes |
disabled = true on the initial apply doesn't appear to take effect |
The field has no effect during creation per the provider docs | Run a second apply — the disable takes effect once the account already exists |
create_ignore_already_exists = true didn't bring an existing account's full configuration into state |
This flag only avoids a create-time conflict; it does not perform a state import | Use terraform import (or an import {} block) to bring the existing resource under management if full state parity is required |
No labels or self_link in variables.tf/outputs.tf — looks incomplete compared to other modules |
Deliberate: the live google_service_account schema has neither |
Confirmed against the live provider schema; see Architecture Notes |
google_service_accountprovider resource referenceterraform-google-project-iam-bindings(primary consumer of this module'smember/emailoutputs)terraform-google-project-services(must enableiam.googleapis.combefore this module applies)- This module's
SCOPE.md

