IaC GCP

Terraform Module: GCP Cloud Functions (2nd gen) — event-driven compute on Cloud Run with sane defaults

Quick take — A reusable Terraform module for google_cloudfunctions2_function: build & runtime config, Eventarc triggers, dedicated runtime service account, and least-privilege invoker IAM — pinned to hashicorp/google ~> 5.0. 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 "cloud_functions" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-functions?ref=v1.0.0"

  project_id    = "..."  # GCP project ID that hosts the function.
  location      = "..."  # Region for the function (e.g. us-central1).
  name          = "..."  # Function name; lowercase alphanumeric/hyphen, starts wi…
  runtime       = "..."  # Runtime, e.g. nodejs20, python312, go122, java21.
  entry_point   = "..."  # Handler name within the source to execute.
  source_bucket = "..."  # GCS bucket holding the zipped source object.
  source_object = "..."  # GCS object path of the zipped source.
}

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

What this module is

Cloud Functions (2nd gen) is Google Cloud’s rebuilt functions-as-a-service offering. Unlike 1st gen, a 2nd gen function is a Cloud Run service under the hood plus an Eventarc trigger — so you inherit Cloud Run’s concurrency (up to 1,000 concurrent requests per instance), larger instances (up to 16 GiB / 4 vCPU), minimum-instance warm pools, request timeouts up to 60 minutes for event-driven functions, and traffic-splitting revisions. The single google_cloudfunctions2_function resource stitches together three things that are easy to misconfigure by hand: the build (source bucket object, runtime, entry point), the service (scaling, concurrency, memory, service account, VPC/ingress), and the optional event trigger (Eventarc event type, Pub/Sub or Cloud Storage source, retry policy).

Wrapping it in a module matters because the failure modes are repetitive and operational, not creative. Every team forgets to give the function its own runtime service account (so it silently runs as the default compute SA with Editor), forgets that the build step needs roles/cloudfunctions.builder and a source object that actually exists, sets available_memory as a bare number instead of "256M", or leaves ingress_settings wide open. This module makes the runtime SA, the source-object wiring, the trigger’s own SA, and least-privilege cloudfunctions2_function_iam_member invoker grants first-class, validated inputs so each deployment is reproducible and auditable.

When to use it

Module structure

terraform-module-gcp-cloud-functions/
├── versions.tf      # provider pin (hashicorp/google ~> 5.0)
├── main.tf          # runtime SA, the function, trigger SA roles, invoker IAM
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id/name + service URI, runtime SA email, build/service config
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # 2nd-gen functions run on Cloud Run; build + invoke both need a Cloud Build
  # backed source object that already exists in GCS.
  create_runtime_sa = var.runtime_service_account_email == null

  runtime_sa_email = local.create_runtime_sa ? (
    google_service_account.runtime[0].email
  ) : var.runtime_service_account_email

  # Eventarc needs its own SA with permission to receive events and (for the
  # event flow) act as a token creator. Default to the runtime SA unless overridden.
  trigger_sa_email = var.event_trigger == null ? null : coalesce(
    var.event_trigger.service_account_email,
    local.runtime_sa_email,
  )
}

# ---------------------------------------------------------------------------
# Dedicated runtime service account (created unless caller supplies one)
# ---------------------------------------------------------------------------
resource "google_service_account" "runtime" {
  count = local.create_runtime_sa ? 1 : 0

  project      = var.project_id
  account_id   = "${substr(var.name, 0, 26)}-run"
  display_name = "Runtime SA for Cloud Function ${var.name}"
  description  = "Least-privilege runtime identity for 2nd-gen function ${var.name}"
}

# ---------------------------------------------------------------------------
# Project-level roles the function's runtime SA needs to do its job
# (e.g. roles/secretmanager.secretAccessor, roles/datastore.user, ...)
# ---------------------------------------------------------------------------
resource "google_project_iam_member" "runtime_roles" {
  for_each = toset(var.runtime_service_account_roles)

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${local.runtime_sa_email}"
}

# ---------------------------------------------------------------------------
# Roles required by the Eventarc trigger SA (only when a trigger is configured)
# ---------------------------------------------------------------------------
resource "google_project_iam_member" "trigger_event_receiver" {
  count = var.event_trigger == null ? 0 : 1

  project = var.project_id
  role    = "roles/eventarc.eventReceiver"
  member  = "serviceAccount:${local.trigger_sa_email}"
}

# ---------------------------------------------------------------------------
# The 2nd-gen Cloud Function
# ---------------------------------------------------------------------------
resource "google_cloudfunctions2_function" "this" {
  project     = var.project_id
  name        = var.name
  location    = var.location
  description = var.description
  labels      = var.labels

  build_config {
    runtime               = var.runtime
    entry_point           = var.entry_point
    service_account       = var.build_service_account
    environment_variables = var.build_environment_variables

    source {
      storage_source {
        bucket     = var.source_bucket
        object     = var.source_object
        generation = var.source_generation
      }
    }

    dynamic "automatic_update_policy" {
      for_each = var.runtime_update_policy == "automatic" ? [1] : []
      content {}
    }

    dynamic "on_deploy_update_policy" {
      for_each = var.runtime_update_policy == "on_deploy" ? [1] : []
      content {}
    }
  }

  service_config {
    available_memory   = var.available_memory
    available_cpu      = var.available_cpu
    timeout_seconds    = var.timeout_seconds
    max_instance_count = var.max_instance_count
    min_instance_count = var.min_instance_count

    # Concurrency > 1 is the headline 2nd-gen feature; requires >= 1 vCPU.
    max_instance_request_concurrency = var.max_instance_request_concurrency

    ingress_settings               = var.ingress_settings
    all_traffic_on_latest_revision = true

    service_account_email = local.runtime_sa_email
    environment_variables = var.runtime_environment_variables
    vpc_connector         = var.vpc_connector
    vpc_connector_egress_settings = (
      var.vpc_connector == null ? null : var.vpc_connector_egress_settings
    )

    dynamic "secret_environment_variables" {
      for_each = var.secret_environment_variables
      content {
        key        = secret_environment_variables.value.key
        project_id = coalesce(secret_environment_variables.value.project_id, var.project_id)
        secret     = secret_environment_variables.value.secret
        version    = secret_environment_variables.value.version
      }
    }
  }

  dynamic "event_trigger" {
    for_each = var.event_trigger == null ? [] : [var.event_trigger]
    content {
      trigger_region        = coalesce(event_trigger.value.trigger_region, var.location)
      event_type            = event_trigger.value.event_type
      pubsub_topic          = event_trigger.value.pubsub_topic
      retry_policy          = event_trigger.value.retry_policy
      service_account_email = local.trigger_sa_email

      dynamic "event_filters" {
        for_each = event_trigger.value.event_filters
        content {
          attribute = event_filters.value.attribute
          value     = event_filters.value.value
          operator  = event_filters.value.operator
        }
      }
    }
  }

  lifecycle {
    precondition {
      condition     = var.max_instance_request_concurrency == 1 || var.available_cpu != null
      error_message = "max_instance_request_concurrency > 1 requires available_cpu to be set (>= 1 vCPU)."
    }
  }
}

# ---------------------------------------------------------------------------
# Invoker IAM on the underlying Cloud Run service (2nd gen).
# For HTTP functions, members listed here may invoke the function URL.
# ---------------------------------------------------------------------------
resource "google_cloudfunctions2_function_iam_member" "invoker" {
  for_each = toset(var.invoker_members)

  project        = var.project_id
  location       = var.location
  cloud_function = google_cloudfunctions2_function.this.name
  role           = "roles/cloudfunctions.invoker"
  member         = each.value
}
# variables.tf

variable "project_id" {
  description = "GCP project ID that hosts the function."
  type        = string
}

variable "location" {
  description = "Region for the function (e.g. us-central1, europe-west1)."
  type        = string
}

variable "name" {
  description = "Function name. Lowercase letters, digits and hyphens; must start with a letter."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,61}[a-z0-9]$", var.name))
    error_message = "name must be 2-63 chars, lowercase alphanumeric/hyphen, start with a letter, not end with a hyphen."
  }
}

variable "description" {
  description = "Human-readable description of the function."
  type        = string
  default     = null
}

variable "labels" {
  description = "Labels applied to the function (e.g. team, env, cost-center)."
  type        = map(string)
  default     = {}
}

# ---- Build config -------------------------------------------------------

variable "runtime" {
  description = "Execution runtime, e.g. nodejs20, python312, go122, java21, dotnet8, ruby33."
  type        = string

  validation {
    condition     = can(regex("^(nodejs|python|go|java|dotnet|ruby|php)[0-9]+$", var.runtime))
    error_message = "runtime must look like nodejs20 / python312 / go122 / java21 / dotnet8 / ruby33."
  }
}

variable "entry_point" {
  description = "Name of the function/handler within the source to execute."
  type        = string
}

variable "source_bucket" {
  description = "GCS bucket holding the zipped source object."
  type        = string
}

variable "source_object" {
  description = "GCS object (path) of the zipped source, e.g. functions/orders/v3.zip."
  type        = string
}

variable "source_generation" {
  description = "Optional GCS object generation to pin an exact source version (immutable deploys)."
  type        = number
  default     = null
}

variable "build_service_account" {
  description = "Optional service account (full resource name) used by Cloud Build for the build step. Defaults to the project default Cloud Build SA when null."
  type        = string
  default     = null
}

variable "build_environment_variables" {
  description = "Environment variables available only at build time."
  type        = map(string)
  default     = {}
}

variable "runtime_update_policy" {
  description = "How the base image/runtime is updated: 'automatic' (security patches auto-applied) or 'on_deploy' (only on redeploy)."
  type        = string
  default     = "automatic"

  validation {
    condition     = contains(["automatic", "on_deploy"], var.runtime_update_policy)
    error_message = "runtime_update_policy must be 'automatic' or 'on_deploy'."
  }
}

# ---- Service (Cloud Run) config ----------------------------------------

variable "available_memory" {
  description = "Memory per instance as a quantity string, e.g. 256M, 512M, 1Gi, 4Gi (up to 16Gi)."
  type        = string
  default     = "256M"

  validation {
    condition     = can(regex("^[0-9]+(M|Mi|G|Gi)$", var.available_memory))
    error_message = "available_memory must be a quantity like 256M, 512M, 1Gi or 4Gi."
  }
}

variable "available_cpu" {
  description = "vCPU per instance as a string, e.g. '0.583', '1', '2', '4'. Required (>= 1) when concurrency > 1. Null lets GCP derive it from memory."
  type        = string
  default     = null
}

variable "timeout_seconds" {
  description = "Request timeout. Up to 3600 for event-driven, up to 3600 for HTTP on 2nd gen."
  type        = number
  default     = 60

  validation {
    condition     = var.timeout_seconds >= 1 && var.timeout_seconds <= 3600
    error_message = "timeout_seconds must be between 1 and 3600."
  }
}

variable "min_instance_count" {
  description = "Minimum (always-warm) instances. > 0 removes cold starts but bills idle instances."
  type        = number
  default     = 0

  validation {
    condition     = var.min_instance_count >= 0
    error_message = "min_instance_count must be >= 0."
  }
}

variable "max_instance_count" {
  description = "Maximum instances the function may scale to."
  type        = number
  default     = 100

  validation {
    condition     = var.max_instance_count >= 1
    error_message = "max_instance_count must be >= 1."
  }
}

variable "max_instance_request_concurrency" {
  description = "Concurrent requests per instance (1-1000). The defining 2nd-gen feature; requires available_cpu >= 1 when > 1."
  type        = number
  default     = 1

  validation {
    condition     = var.max_instance_request_concurrency >= 1 && var.max_instance_request_concurrency <= 1000
    error_message = "max_instance_request_concurrency must be between 1 and 1000."
  }
}

variable "ingress_settings" {
  description = "Ingress control: ALLOW_ALL, ALLOW_INTERNAL_ONLY, or ALLOW_INTERNAL_AND_GCLB."
  type        = string
  default     = "ALLOW_ALL"

  validation {
    condition     = contains(["ALLOW_ALL", "ALLOW_INTERNAL_ONLY", "ALLOW_INTERNAL_AND_GCLB"], var.ingress_settings)
    error_message = "ingress_settings must be ALLOW_ALL, ALLOW_INTERNAL_ONLY, or ALLOW_INTERNAL_AND_GCLB."
  }
}

variable "runtime_environment_variables" {
  description = "Environment variables available at runtime."
  type        = map(string)
  default     = {}
}

variable "secret_environment_variables" {
  description = "Secret Manager values mounted as env vars."
  type = list(object({
    key        = string
    secret     = string
    version    = string
    project_id = optional(string)
  }))
  default = []
}

variable "vpc_connector" {
  description = "Optional Serverless VPC Access connector (full resource name) for private egress."
  type        = string
  default     = null
}

variable "vpc_connector_egress_settings" {
  description = "VPC egress: PRIVATE_RANGES_ONLY or ALL_TRAFFIC. Ignored when vpc_connector is null."
  type        = string
  default     = "PRIVATE_RANGES_ONLY"

  validation {
    condition     = contains(["PRIVATE_RANGES_ONLY", "ALL_TRAFFIC"], var.vpc_connector_egress_settings)
    error_message = "vpc_connector_egress_settings must be PRIVATE_RANGES_ONLY or ALL_TRAFFIC."
  }
}

# ---- Runtime identity & invoker IAM ------------------------------------

variable "runtime_service_account_email" {
  description = "Existing runtime SA email. If null, the module creates a dedicated least-privilege SA."
  type        = string
  default     = null
}

variable "runtime_service_account_roles" {
  description = "Project roles to grant the runtime SA (e.g. roles/secretmanager.secretAccessor, roles/datastore.user)."
  type        = list(string)
  default     = []
}

variable "invoker_members" {
  description = "IAM members granted roles/cloudfunctions.invoker on the function (e.g. allUsers, serviceAccount:..., group:...)."
  type        = list(string)
  default     = []
}

# ---- Event trigger (Eventarc) ------------------------------------------

variable "event_trigger" {
  description = <<-EOT
    Optional Eventarc trigger. Set to null for an HTTP-only function. Example (Pub/Sub):
    {
      event_type   = "google.cloud.pubsub.topic.v1.messagePublished"
      pubsub_topic = "projects/p/topics/orders"
      retry_policy = "RETRY_POLICY_RETRY"
    }
    Example (GCS object finalized):
    {
      event_type    = "google.cloud.storage.object.v1.finalized"
      retry_policy  = "RETRY_POLICY_RETRY"
      event_filters = [{ attribute = "bucket", value = "my-uploads-bucket" }]
    }
  EOT
  type = object({
    event_type            = string
    pubsub_topic          = optional(string)
    retry_policy          = optional(string, "RETRY_POLICY_RETRY")
    trigger_region        = optional(string)
    service_account_email = optional(string)
    event_filters = optional(list(object({
      attribute = string
      value     = string
      operator  = optional(string)
    })), [])
  })
  default = null

  validation {
    condition = var.event_trigger == null ? true : contains(
      ["RETRY_POLICY_RETRY", "RETRY_POLICY_DO_NOT_RETRY"],
      var.event_trigger.retry_policy
    )
    error_message = "event_trigger.retry_policy must be RETRY_POLICY_RETRY or RETRY_POLICY_DO_NOT_RETRY."
  }
}
# outputs.tf

output "id" {
  description = "Fully-qualified function ID (projects/.../locations/.../functions/...)."
  value       = google_cloudfunctions2_function.this.id
}

output "name" {
  description = "Short function name."
  value       = google_cloudfunctions2_function.this.name
}

output "uri" {
  description = "HTTPS URI of the underlying Cloud Run service (use to invoke an HTTP function)."
  value       = google_cloudfunctions2_function.this.service_config[0].uri
}

output "service_name" {
  description = "Name of the backing Cloud Run service (for Cloud Run-level operations/monitoring)."
  value       = google_cloudfunctions2_function.this.service_config[0].service
}

output "runtime_service_account_email" {
  description = "Email of the runtime service account the function executes as."
  value       = local.runtime_sa_email
}

output "state" {
  description = "Current lifecycle state of the function (ACTIVE, FAILED, DEPLOYING, ...)."
  value       = google_cloudfunctions2_function.this.state
}

output "event_trigger_id" {
  description = "Eventarc trigger resource name, or null for HTTP-only functions."
  value       = try(google_cloudfunctions2_function.this.event_trigger[0].trigger, null)
}

How to use it

# A GCS-triggered image-thumbnailing function that reads secrets and writes to Firestore.

resource "google_storage_bucket" "uploads" {
  project                     = "kv-media-prod"
  name                        = "kv-media-prod-uploads"
  location                    = "US"
  uniform_bucket_level_access = true
}

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

  project_id  = "kv-media-prod"
  location    = "us-central1"
  name        = "media-thumbnailer"
  description = "Generates thumbnails when an object is finalized in the uploads bucket"
  labels      = { team = "media", env = "prod", "cost-center" = "cc-4471" }

  runtime     = "python312"
  entry_point = "on_object_finalized"

  source_bucket     = "kv-media-prod-artifacts"
  source_object     = "functions/thumbnailer/v3.zip"
  source_generation = 1736500000123456

  # 2nd-gen scaling: concurrency requires CPU; keep one warm instance to avoid cold starts.
  available_memory                 = "1Gi"
  available_cpu                    = "1"
  max_instance_request_concurrency = 20
  min_instance_count               = 1
  max_instance_count               = 50
  timeout_seconds                  = 300
  ingress_settings                 = "ALLOW_INTERNAL_AND_GCLB"

  runtime_environment_variables = {
    THUMBNAIL_BUCKET = "kv-media-prod-thumbnails"
    LOG_LEVEL        = "INFO"
  }

  secret_environment_variables = [
    {
      key     = "SIGNING_KEY"
      secret  = "media-signing-key"
      version = "latest"
    }
  ]

  # Dedicated runtime SA gets exactly the roles it needs.
  runtime_service_account_roles = [
    "roles/secretmanager.secretAccessor",
    "roles/datastore.user",
    "roles/storage.objectAdmin",
  ]

  event_trigger = {
    event_type   = "google.cloud.storage.object.v1.finalized"
    retry_policy = "RETRY_POLICY_RETRY"
    event_filters = [
      { attribute = "bucket", value = google_storage_bucket.uploads.name }
    ]
  }
}

# Downstream: grant the function's runtime SA write access to the thumbnails bucket
# using the module output, and surface the service name for a Cloud Run alert policy.
resource "google_storage_bucket_iam_member" "thumbnailer_writer" {
  bucket = "kv-media-prod-thumbnails"
  role   = "roles/storage.objectCreator"
  member = "serviceAccount:${module.cloud_functions_2nd_gen_thumbnailer.runtime_service_account_email}"
}

output "thumbnailer_run_service" {
  value = module.cloud_functions_2nd_gen_thumbnailer.service_name
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  location = "..."
  name = "..."
  runtime = "..."
  entry_point = "..."
  source_bucket = "..."
  source_object = "..."
}

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

cd live/prod/cloud_functions && 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 that hosts the function.
location string yes Region for the function (e.g. us-central1).
name string yes Function name; lowercase alphanumeric/hyphen, starts with a letter.
description string null no Human-readable description.
labels map(string) {} no Labels (team, env, cost-center, …).
runtime string yes Runtime, e.g. nodejs20, python312, go122, java21.
entry_point string yes Handler name within the source to execute.
source_bucket string yes GCS bucket holding the zipped source object.
source_object string yes GCS object path of the zipped source.
source_generation number null no Pin an exact GCS object generation for immutable deploys.
build_service_account string null no Cloud Build SA (full resource name) for the build step.
build_environment_variables map(string) {} no Build-time environment variables.
runtime_update_policy string “automatic” no Base-image update policy: automatic or on_deploy.
available_memory string “256M” no Memory quantity per instance (256M…16Gi).
available_cpu string null no vCPU per instance; required (>=1) when concurrency > 1.
timeout_seconds number 60 no Request timeout, 1–3600.
min_instance_count number 0 no Always-warm instances (kills cold starts; bills idle).
max_instance_count number 100 no Maximum instances.
max_instance_request_concurrency number 1 no Concurrent requests per instance, 1–1000 (2nd-gen feature).
ingress_settings string “ALLOW_ALL” no ALLOW_ALL / ALLOW_INTERNAL_ONLY / ALLOW_INTERNAL_AND_GCLB.
runtime_environment_variables map(string) {} no Runtime environment variables.
secret_environment_variables list(object) [] no Secret Manager values mounted as env vars.
vpc_connector string null no Serverless VPC Access connector for private egress.
vpc_connector_egress_settings string “PRIVATE_RANGES_ONLY” no VPC egress mode; ignored when no connector.
runtime_service_account_email string null no Existing runtime SA; if null a dedicated SA is created.
runtime_service_account_roles list(string) [] no Project roles granted to the runtime SA.
invoker_members list(string) [] no Members granted roles/cloudfunctions.invoker.
event_trigger object null no Eventarc trigger config; null for HTTP-only functions.

Outputs

Name Description
id Fully-qualified function ID (projects/…/locations/…/functions/…).
name Short function name.
uri HTTPS URI of the backing Cloud Run service (invoke an HTTP function).
service_name Name of the backing Cloud Run service for monitoring/ops.
runtime_service_account_email Email of the runtime SA the function executes as.
state Lifecycle state (ACTIVE, FAILED, DEPLOYING, …).
event_trigger_id Eventarc trigger resource name, or null for HTTP-only functions.

Enterprise scenario

A payments platform processes settlement files dropped hourly into a landing bucket by partner banks. They instantiate this module once per environment with a GCS object.v1.finalized trigger, retry_policy = RETRY_POLICY_RETRY, min_instance_count = 1 to avoid cold-start latency on the first file, and max_instance_request_concurrency = 1 so each settlement file is parsed in isolation. The dedicated runtime SA is granted only roles/secretmanager.secretAccessor (for the HSM signing key) and roles/pubsub.publisher (to emit a settlement.parsed event), and ingress_settings = ALLOW_INTERNAL_ONLY keeps the function off the public internet — giving auditors a clean, least-privilege blast radius per environment.

Best practices

TerraformGCPCloud Functions (2nd gen)ModuleIaC
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