Quick take — Wrap google_workflows_workflow in a reusable Terraform module for hashicorp/google ~> 5.0 — dedicated service account, KMS-CMEK encryption, call-logging, and revision-safe deploys for GCP Workflows. 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 "workflows" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-workflows?ref=v1.0.0"
project_id = "..." # GCP project ID for the workflow and its service account.
region = "..." # Region for the workflow (validated, e.g. `asia-south1`).
name = "..." # Workflow name (1-64 chars, lowercase/digits/hyphens).
source_contents = "..." # Full YAML/JSON definition (non-empty, ≤ 128 KB).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Google Cloud Workflows is a fully managed, serverless orchestration engine. You declare a sequence of steps in YAML (or JSON) — HTTP calls, connector invocations to other Google services, conditional branches, retries, parallel for loops — and Workflows executes them with built-in state management, no servers, and per-step billing. It is the glue that ties Cloud Run, Cloud Functions, Pub/Sub, BigQuery, and external APIs into a single durable, observable process.
The raw google_workflows_workflow resource is deceptively simple — a name and a source_contents blob. In production, though, every workflow needs the same supporting cast wired up correctly and identically every time:
- A dedicated, least-privilege service account as the workflow’s execution identity (the default Compute Engine SA is wildly over-permissioned and a common audit finding).
- Call logging configured so step-level invocations land in Cloud Logging for debugging.
- Optional customer-managed encryption (CMEK) for source code and execution state in regulated environments.
- Consistent labels, naming, and a stable region.
This module captures that opinionated baseline so a team can stand up a correctly-secured workflow in five lines instead of copy-pasting fifty and forgetting the SA every other time.
When to use it
Reach for this module when:
- You are orchestrating multi-step, event-driven, or batch processes across Google services (e.g. “Pub/Sub message arrives → call Cloud Run → write to BigQuery → notify Slack”) and want declarative retries and error handling without running an Airflow cluster.
- You need durable, long-running coordination (Workflows executions can run up to a year) where a Cloud Function’s timeout is too tight.
- You want each workflow to run under its own scoped identity so a compromised or buggy step cannot reach beyond its blast radius.
- You are standardising a module library across many teams and want every workflow deployed with logging, labels, and encryption handled the same way.
Skip it if you need sub-second, high-throughput stream processing (use Dataflow) or a simple single function trigger (a plain Cloud Function or Eventarc target is lighter).
Module structure
terraform-module-gcp-workflows/
├── versions.tf # provider + Terraform version pins
├── main.tf # service account + IAM + google_workflows_workflow
├── variables.tf # var-driven inputs with validation
└── outputs.tf # workflow id/name/revision + SA email
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# When the caller does not bring its own identity, we create a dedicated one.
create_sa = var.service_account_email == null
# Account ID must be 6-30 chars, lowercase letters/digits/hyphens. Derive a
# safe one from the workflow name and truncate to stay within the limit.
generated_account_id = substr(
"wf-${replace(lower(var.name), "_", "-")}",
0,
30
)
service_account_email = local.create_sa ? google_service_account.workflow[0].email : var.service_account_email
}
# Dedicated execution identity for the workflow (least privilege).
resource "google_service_account" "workflow" {
count = local.create_sa ? 1 : 0
project = var.project_id
account_id = local.generated_account_id
display_name = "SA for Workflow ${var.name}"
description = "Execution identity for the ${var.name} Cloud Workflow. Managed by Terraform."
}
# Grant the workflow's identity any project-level roles the steps require
# (e.g. roles/run.invoker, roles/bigquery.jobUser). Only applied to the SA we own.
resource "google_project_iam_member" "workflow_roles" {
for_each = local.create_sa ? toset(var.service_account_roles) : toset([])
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.workflow[0].email}"
}
resource "google_workflows_workflow" "this" {
project = var.project_id
region = var.region
name = var.name
description = var.description
service_account = local.service_account_email
# Source is the YAML/JSON definition. Read from a file in the caller and pass
# it through, or template it inline — either way it lands here verbatim.
source_contents = var.source_contents
# Controls how step executions are written to Cloud Logging.
# LOG_ALL_CALLS | LOG_ERRORS_ONLY | LOG_NONE
call_log_level = var.call_log_level
# Optional CMEK: encrypt source + execution data with a customer key.
crypto_key_name = var.crypto_key_name
# Optional execution history retention (Workflows "advanced" history).
user_env_vars = var.user_env_vars
labels = var.labels
# A new revision is created whenever source_contents or service_account
# changes; this prevents a transient deploy from deleting/recreating.
lifecycle {
create_before_destroy = true
}
}
variables.tf
variable "project_id" {
type = string
description = "The GCP project ID where the workflow and its service account are created."
}
variable "region" {
type = string
description = "Region for the workflow (e.g. us-central1, europe-west1). Workflows is a regional resource."
validation {
condition = can(regex("^[a-z]+-[a-z]+[0-9]$", var.region))
error_message = "region must be a valid GCP region such as us-central1 or asia-south1."
}
}
variable "name" {
type = string
description = "Name of the workflow. Used verbatim and to derive the service account id."
validation {
condition = can(regex("^[a-z]([-a-z0-9]{0,62}[a-z0-9])?$", var.name))
error_message = "name must be 1-64 chars, start with a lowercase letter, and contain only lowercase letters, digits, and hyphens."
}
}
variable "description" {
type = string
description = "Human-readable description of what the workflow does."
default = "Managed by Terraform."
}
variable "source_contents" {
type = string
description = "The full YAML or JSON workflow definition. Typically file(\"${path.module}/workflow.yaml\")."
validation {
condition = length(var.source_contents) > 0 && length(var.source_contents) <= 131072
error_message = "source_contents must be non-empty and within the 128 KB workflow source limit."
}
}
variable "service_account_email" {
type = string
description = "Existing service account email to run the workflow as. If null, a dedicated least-privilege SA is created."
default = null
}
variable "service_account_roles" {
type = list(string)
description = "Project-level IAM roles to grant the created SA (ignored when service_account_email is supplied)."
default = []
validation {
condition = alltrue([for r in var.service_account_roles : can(regex("^(roles/|projects/)", r))])
error_message = "Each role must be a fully-qualified role name like roles/run.invoker or a custom projects/<id>/roles/<role>."
}
}
variable "call_log_level" {
type = string
description = "How step calls are logged to Cloud Logging."
default = "LOG_ERRORS_ONLY"
validation {
condition = contains(["LOG_ALL_CALLS", "LOG_ERRORS_ONLY", "LOG_NONE"], var.call_log_level)
error_message = "call_log_level must be one of LOG_ALL_CALLS, LOG_ERRORS_ONLY, or LOG_NONE."
}
}
variable "crypto_key_name" {
type = string
description = "Optional Cloud KMS CryptoKey resource ID for CMEK (projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k>). Null uses Google-managed keys."
default = null
}
variable "user_env_vars" {
type = map(string)
description = "User-defined environment variables available to the workflow at runtime via sys.get_env."
default = {}
}
variable "labels" {
type = map(string)
description = "Labels applied to the workflow for cost allocation and ownership."
default = {}
}
outputs.tf
output "id" {
description = "The fully-qualified workflow resource ID (projects/<p>/locations/<region>/workflows/<name>)."
value = google_workflows_workflow.this.id
}
output "name" {
description = "The short name of the workflow."
value = google_workflows_workflow.this.name
}
output "revision_id" {
description = "The current revision ID, which changes on every source/SA update — useful for cache-busting executors."
value = google_workflows_workflow.this.revision_id
}
output "state" {
description = "Current state of the workflow (e.g. ACTIVE)."
value = google_workflows_workflow.this.state
}
output "service_account_email" {
description = "The execution identity the workflow runs as (created or supplied)."
value = local.service_account_email
}
How to use it
A typical consumer keeps the workflow YAML next to its root config and passes it in, then lets the module mint a scoped SA with exactly the roles the steps need.
module "workflows" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-workflows?ref=v1.0.0"
project_id = "kloudvin-data-prod"
region = "asia-south1"
name = "order-fulfilment"
description = "Validates an order, charges payment via Cloud Run, then writes to BigQuery."
source_contents = file("${path.module}/order-fulfilment.yaml")
# Module creates a dedicated SA and grants only what the steps call.
service_account_roles = [
"roles/run.invoker", # call the payment Cloud Run service
"roles/bigquery.jobUser", # run the warehouse insert job
"roles/logging.logWriter",
]
call_log_level = "LOG_ALL_CALLS"
crypto_key_name = "projects/kloudvin-data-prod/locations/asia-south1/keyRings/wf-ring/cryptoKeys/wf-key"
labels = {
team = "commerce"
environment = "prod"
cost-center = "cc-4412"
}
}
# Downstream: trigger the workflow from an Eventarc-backed Cloud Run target,
# referencing the module's output ID so the trigger always points at this workflow.
resource "google_eventarc_trigger" "on_order_created" {
name = "order-created-to-workflow"
project = "kloudvin-data-prod"
location = "asia-south1"
matching_criteria {
attribute = "type"
value = "google.cloud.pubsub.topic.v1.messagePublished"
}
destination {
workflow = module.workflows.id
}
service_account = module.workflows.service_account_email
}
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/workflows/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-workflows?ref=v1.0.0"
}
inputs = {
project_id = "..."
region = "..."
name = "..."
source_contents = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/workflows && 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 for the workflow and its service account. |
region |
string |
— | Yes | Region for the workflow (validated, e.g. asia-south1). |
name |
string |
— | Yes | Workflow name (1-64 chars, lowercase/digits/hyphens). |
description |
string |
"Managed by Terraform." |
No | Human-readable description. |
source_contents |
string |
— | Yes | Full YAML/JSON definition (non-empty, ≤ 128 KB). |
service_account_email |
string |
null |
No | Existing SA to run as; null creates a dedicated least-privilege SA. |
service_account_roles |
list(string) |
[] |
No | Project roles granted to the created SA (ignored if SA supplied). |
call_log_level |
string |
"LOG_ERRORS_ONLY" |
No | LOG_ALL_CALLS, LOG_ERRORS_ONLY, or LOG_NONE. |
crypto_key_name |
string |
null |
No | KMS CryptoKey ID for CMEK; null uses Google-managed keys. |
user_env_vars |
map(string) |
{} |
No | Runtime env vars exposed via sys.get_env. |
labels |
map(string) |
{} |
No | Labels for cost allocation and ownership. |
Outputs
| Name | Description |
|---|---|
id |
Fully-qualified workflow resource ID (projects/<p>/locations/<region>/workflows/<name>). |
name |
Short name of the workflow. |
revision_id |
Current revision ID; changes on every source/SA update. |
state |
Current workflow state (e.g. ACTIVE). |
service_account_email |
Execution identity the workflow runs as (created or supplied). |
Enterprise scenario
A retail platform team at a multi-brand commerce company uses this module to deploy an order-fulfilment workflow per region (asia-south1, europe-west1, us-central1) from a single reusable definition. Each instance gets its own dedicated service account scoped to roles/run.invoker and roles/bigquery.jobUser only, CMEK encryption via the regional KMS key for PCI-relevant order data, and LOG_ALL_CALLS so the on-call engineer can replay any failed step in Cloud Logging. Because the module exposes id, the same Terraform stack wires an Eventarc Pub/Sub trigger straight to each regional workflow — so adding a new region is one module block, not a fresh hand-rolled SA, IAM grant, and trigger.
Best practices
- Never let a workflow inherit the default Compute Engine SA. Leave
service_account_email = nullso the module mints a dedicated identity, and grant the narrowestservice_account_rolesthe steps actually call (roles/run.invokerfor one Cloud Run service, notroles/editor). - Tune
call_log_levelby environment to control cost. UseLOG_ALL_CALLSin dev/staging for full traceability, but considerLOG_ERRORS_ONLYin high-volume production paths — every logged call is a billable Cloud Logging entry that adds up across millions of executions. - Pin the source, not just the module. Keep the workflow YAML in version control and pass it via
file(...); a changedsource_contentsproduces a new immutable revision (surfaced by therevision_idoutput), giving you a clean rollback story without recreating the resource. - Enable CMEK in regulated workloads. Supply
crypto_key_nameso both the workflow source and execution state are encrypted with your KMS key; ensure the Workflows service agent hasroles/cloudkms.cryptoKeyEncrypterDecrypteron that key or the deploy will fail. - Standardise labels for cost allocation. Always set
team,environment, andcost-centerlabels — Workflows billing is per-step and per-execution, and labels are how you attribute that spend back to a product line. - Name by domain and region, not by implementation. Prefer
order-fulfilmentoverwf1orpubsub-handler; the name is permanent, drives the derived SA id, and shows up in every log line and IAM audit.