IaC GCP

Terraform Module: GCP Workflows — Reusable serverless orchestration with a least-privilege service account baked in

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:

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:

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 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/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

TerraformGCPWorkflowsModuleIaC
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