IaC GCP

Terraform Module: GCP Cloud Scheduler — cron-driven jobs without the per-job boilerplate

Quick take — A production-ready Terraform module for google_cloud_scheduler_job: HTTP, Pub/Sub, and App Engine targets, OIDC/OAuth auth, retry config, and timezone-safe cron, 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_scheduler" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-scheduler?ref=v1.0.0"

  name     = "..."  # Job name, unique within project + region. Must start wi…
  region   = "..."  # App Engine region for the job (e.g. `asia-south1`).
  schedule = "..."  # 5-field unix-cron expression, e.g. `15 2 * * *`.
}

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

What this module is

Cloud Scheduler is GCP’s fully managed cron service. You hand it a schedule (in unix-cron syntax), a target, and a time zone, and it fires that target on the dot — invoking an HTTPS endpoint, publishing a Pub/Sub message, or hitting an App Engine handler. It is the GCP-native answer to “run this thing every night at 2 AM” without standing up a VM with a crontab, and it integrates with retries, deadlines, and IAM-based auth so the invocation is both reliable and secure.

The raw google_cloud_scheduler_job resource has three mutually exclusive target blocks (http_target, pubsub_target, app_engine_http_target), each with their own nested auth and payload sub-blocks. Wiring one up correctly — picking the right target, attaching an OIDC token for a Cloud Run / Cloud Functions invocation, base64-handling the Pub/Sub payload, and setting a sane retry_config — is fiddly, and getting it subtly wrong (wrong audience, missing service account, naive timezone) produces jobs that silently fail at 2 AM when nobody is watching. This module collapses that into a small set of typed, validated variables so every scheduled job in your estate is created the same way, with the same retry and security posture, and a single place to fix when the pattern changes.

When to use it

If your trigger is event-driven rather than time-driven (a file lands in GCS, a row changes in Firestore), use Eventarc or a Pub/Sub push subscription instead — Cloud Scheduler is specifically for wall-clock schedules.

Module structure

terraform-module-gcp-cloud-scheduler/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_cloud_scheduler_job, all three target types
├── variables.tf     # typed, validated inputs
└── outputs.tf       # id, name, schedule, target metadata

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Exactly one target type is selected; enforced by variable validation below.
  use_http       = var.http_target != null
  use_pubsub     = var.pubsub_target != null
  use_app_engine = var.app_engine_target != null
}

resource "google_cloud_scheduler_job" "this" {
  name        = var.name
  project     = var.project_id
  region      = var.region
  description = var.description

  schedule         = var.schedule
  time_zone        = var.time_zone
  attempt_deadline = var.attempt_deadline
  paused           = var.paused

  # ---- HTTP target (Cloud Run / Cloud Functions / any HTTPS endpoint) ----
  dynamic "http_target" {
    for_each = local.use_http ? [var.http_target] : []
    content {
      uri         = http_target.value.uri
      http_method = http_target.value.http_method
      headers     = http_target.value.headers
      body = http_target.value.body != null ? base64encode(http_target.value.body) : null

      # OIDC: for Cloud Run / Cloud Functions / IAP-protected endpoints.
      dynamic "oidc_token" {
        for_each = http_target.value.oidc_service_account_email != null ? [1] : []
        content {
          service_account_email = http_target.value.oidc_service_account_email
          # Defaults to the bare URI (scheme + host) when no audience given.
          audience = http_target.value.oidc_audience
        }
      }

      # OAuth: for calling *.googleapis.com Google APIs directly.
      dynamic "oauth_token" {
        for_each = http_target.value.oauth_service_account_email != null ? [1] : []
        content {
          service_account_email = http_target.value.oauth_service_account_email
          scope                 = http_target.value.oauth_scope
        }
      }
    }
  }

  # ---- Pub/Sub target ----
  dynamic "pubsub_target" {
    for_each = local.use_pubsub ? [var.pubsub_target] : []
    content {
      topic_name = pubsub_target.value.topic_name
      data = pubsub_target.value.data != null ? base64encode(pubsub_target.value.data) : null
      attributes = pubsub_target.value.attributes
    }
  }

  # ---- App Engine HTTP target ----
  dynamic "app_engine_http_target" {
    for_each = local.use_app_engine ? [var.app_engine_target] : []
    content {
      relative_uri = app_engine_http_target.value.relative_uri
      http_method  = app_engine_http_target.value.http_method
      headers      = app_engine_http_target.value.headers
      body = app_engine_http_target.value.body != null ? base64encode(app_engine_http_target.value.body) : null

      dynamic "app_engine_routing" {
        for_each = app_engine_http_target.value.routing != null ? [app_engine_http_target.value.routing] : []
        content {
          service  = app_engine_routing.value.service
          version  = app_engine_routing.value.version
          instance = app_engine_routing.value.instance
        }
      }
    }
  }

  # ---- Retry behaviour ----
  dynamic "retry_config" {
    for_each = var.retry_config != null ? [var.retry_config] : []
    content {
      retry_count          = retry_config.value.retry_count
      max_retry_duration   = retry_config.value.max_retry_duration
      min_backoff_duration = retry_config.value.min_backoff_duration
      max_backoff_duration = retry_config.value.max_backoff_duration
      max_doublings        = retry_config.value.max_doublings
    }
  }
}

variables.tf

variable "name" {
  description = "Name of the Cloud Scheduler job (unique within the project + region)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z][a-zA-Z0-9-_]{0,499}$", var.name))
    error_message = "name must start with a letter and contain only letters, numbers, hyphens, or underscores."
  }
}

variable "project_id" {
  description = "Project ID that owns the scheduler job. Defaults to the provider project when null."
  type        = string
  default     = null
}

variable "region" {
  description = "Region for the job. Must be an App Engine region (e.g. us-central1, europe-west1, asia-south1)."
  type        = string
}

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

variable "schedule" {
  description = "Unix-cron schedule string, e.g. '0 2 * * *' for daily at 02:00."
  type        = string

  validation {
    # Five whitespace-separated fields (minute hour day-of-month month day-of-week).
    condition     = length(split(" ", trimspace(var.schedule))) == 5
    error_message = "schedule must be a 5-field unix-cron expression, e.g. '*/15 * * * *'."
  }
}

variable "time_zone" {
  description = "IANA time zone name used to interpret the schedule, e.g. 'Asia/Kolkata' or 'Etc/UTC'."
  type        = string
  default     = "Etc/UTC"
}

variable "attempt_deadline" {
  description = "Deadline for the job attempt as a duration string (15s to 1800s). HTTP/App Engine only."
  type        = string
  default     = "180s"

  validation {
    condition     = can(regex("^[0-9]+s$", var.attempt_deadline))
    error_message = "attempt_deadline must be a seconds duration string like '180s'."
  }
}

variable "paused" {
  description = "If true, the job is created in PAUSED state and will not fire until enabled."
  type        = bool
  default     = false
}

variable "http_target" {
  description = "HTTP target configuration. Set exactly one of http_target, pubsub_target, app_engine_target."
  type = object({
    uri         = string
    http_method = optional(string, "POST")
    headers     = optional(map(string))
    body        = optional(string) # plain string; module base64-encodes it
    # OIDC token (Cloud Run / Cloud Functions / IAP). Mutually exclusive with OAuth.
    oidc_service_account_email = optional(string)
    oidc_audience              = optional(string)
    # OAuth token (calling *.googleapis.com). Mutually exclusive with OIDC.
    oauth_service_account_email = optional(string)
    oauth_scope                 = optional(string, "https://www.googleapis.com/auth/cloud-platform")
  })
  default = null

  validation {
    condition = var.http_target == null ? true : !(
      var.http_target.oidc_service_account_email != null &&
      var.http_target.oauth_service_account_email != null
    )
    error_message = "Set either oidc_service_account_email or oauth_service_account_email on http_target, not both."
  }

  validation {
    condition     = var.http_target == null ? true : startswith(var.http_target.uri, "https://") || startswith(var.http_target.uri, "http://")
    error_message = "http_target.uri must be a fully-qualified http(s) URL."
  }
}

variable "pubsub_target" {
  description = "Pub/Sub target configuration. Set exactly one of http_target, pubsub_target, app_engine_target."
  type = object({
    topic_name = string # projects/<project>/topics/<topic>
    data       = optional(string) # plain string; module base64-encodes it
    attributes = optional(map(string))
  })
  default = null

  validation {
    condition     = var.pubsub_target == null ? true : can(regex("^projects/[^/]+/topics/[^/]+$", var.pubsub_target.topic_name))
    error_message = "pubsub_target.topic_name must be of the form projects/<project>/topics/<topic>."
  }
}

variable "app_engine_target" {
  description = "App Engine HTTP target. Set exactly one of http_target, pubsub_target, app_engine_target."
  type = object({
    relative_uri = string
    http_method  = optional(string, "POST")
    headers      = optional(map(string))
    body         = optional(string)
    routing = optional(object({
      service  = optional(string)
      version  = optional(string)
      instance = optional(string)
    }))
  })
  default = null

  validation {
    condition     = var.app_engine_target == null ? true : startswith(var.app_engine_target.relative_uri, "/")
    error_message = "app_engine_target.relative_uri must begin with '/'."
  }
}

variable "retry_config" {
  description = "Retry behaviour for failed attempts. Null applies provider defaults (no retries)."
  type = object({
    retry_count          = optional(number, 0)
    max_retry_duration   = optional(string, "0s")
    min_backoff_duration = optional(string, "5s")
    max_backoff_duration = optional(string, "3600s")
    max_doublings        = optional(number, 5)
  })
  default = null

  validation {
    condition     = var.retry_config == null ? true : var.retry_config.retry_count >= 0 && var.retry_config.retry_count <= 5
    error_message = "retry_config.retry_count must be between 0 and 5."
  }
}

# Enforce that exactly one target block is provided.
variable "_target_guard" {
  description = "Internal: do not set. Guards single-target selection."
  type        = bool
  default     = true

  validation {
    condition = (
      (var.http_target != null ? 1 : 0) +
      (var.pubsub_target != null ? 1 : 0) +
      (var.app_engine_target != null ? 1 : 0)
    ) == 1
    error_message = "Provide exactly one of http_target, pubsub_target, or app_engine_target."
  }
}

outputs.tf

output "id" {
  description = "Fully-qualified Cloud Scheduler job ID (projects/<p>/locations/<r>/jobs/<name>)."
  value       = google_cloud_scheduler_job.this.id
}

output "name" {
  description = "Short name of the scheduler job."
  value       = google_cloud_scheduler_job.this.name
}

output "region" {
  description = "Region in which the job runs."
  value       = google_cloud_scheduler_job.this.region
}

output "schedule" {
  description = "The unix-cron schedule the job runs on."
  value       = google_cloud_scheduler_job.this.schedule
}

output "time_zone" {
  description = "IANA time zone used to interpret the schedule."
  value       = google_cloud_scheduler_job.this.time_zone
}

output "state" {
  description = "Current state of the job (ENABLED, PAUSED, DISABLED, UPDATE_FAILED)."
  value       = google_cloud_scheduler_job.this.state
}

output "target_type" {
  description = "Which target this job uses: http, pubsub, or app_engine."
  value = (
    var.http_target != null ? "http" :
    var.pubsub_target != null ? "pubsub" : "app_engine"
  )
}

How to use it

This example schedules a nightly invoice-rollup job that calls a private Cloud Run service. The scheduler authenticates with an OIDC token minted for a dedicated service account, which must hold roles/run.invoker on the target service.

resource "google_service_account" "scheduler" {
  account_id   = "sched-invoice-rollup"
  display_name = "Cloud Scheduler — invoice rollup invoker"
  project      = var.project_id
}

# Let the scheduler SA invoke the private Cloud Run service.
resource "google_cloud_run_v2_service_iam_member" "invoke" {
  name     = google_cloud_run_v2_service.invoice_rollup.name
  location = google_cloud_run_v2_service.invoice_rollup.location
  project  = var.project_id
  role     = "roles/run.invoker"
  member   = "serviceAccount:${google_service_account.scheduler.email}"
}

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

  name        = "invoice-rollup-nightly"
  project_id  = var.project_id
  region      = "asia-south1"
  description = "Triggers the invoice rollup Cloud Run service every night at 02:15 IST."

  schedule         = "15 2 * * *"
  time_zone        = "Asia/Kolkata"
  attempt_deadline = "320s"

  http_target = {
    uri         = "${google_cloud_run_v2_service.invoice_rollup.uri}/run"
    http_method = "POST"
    headers     = { "Content-Type" = "application/json" }
    body        = jsonencode({ mode = "nightly", region = "asia-south1" })

    oidc_service_account_email = google_service_account.scheduler.email
    # Cloud Run requires the audience to be the base service URL.
    oidc_audience = google_cloud_run_v2_service.invoice_rollup.uri
  }

  retry_config = {
    retry_count          = 3
    min_backoff_duration = "30s"
    max_backoff_duration = "600s"
    max_doublings        = 4
  }

  depends_on = [google_cloud_run_v2_service_iam_member.invoke]
}

# Downstream reference: alert if the nightly job ever flips out of ENABLED.
resource "google_monitoring_alert_policy" "scheduler_disabled" {
  project      = var.project_id
  display_name = "Scheduler ${module.cloud_scheduler.name} not enabled"
  combiner     = "OR"

  conditions {
    display_name = "Job state changed"
    condition_matched_log {
      filter = <<-EOT
        resource.type="cloud_scheduler_job"
        resource.labels.job_id="${module.cloud_scheduler.name}"
        severity>=ERROR
      EOT
    }
  }

  notification_channels = [var.ops_notification_channel]
}

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_scheduler/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-scheduler?ref=v1.0.0"
}

inputs = {
  name = "..."
  region = "..."
  schedule = "..."
}

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

cd live/prod/cloud_scheduler && 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
name string Yes Job name, unique within project + region. Must start with a letter.
project_id string null No Owning project ID; falls back to the provider project.
region string Yes App Engine region for the job (e.g. asia-south1).
description string null No Human-readable description of the job.
schedule string Yes 5-field unix-cron expression, e.g. 15 2 * * *.
time_zone string "Etc/UTC" No IANA time zone used to interpret schedule.
attempt_deadline string "180s" No Attempt deadline (15s1800s) for HTTP/App Engine targets.
paused bool false No Create the job in PAUSED state if true.
http_target object null Conditional HTTP target with URI, method, headers, body, and OIDC/OAuth auth.
pubsub_target object null Conditional Pub/Sub target with topic, data, and attributes.
app_engine_target object null Conditional App Engine target with relative URI, method, and routing.
retry_config object null No Retry count and backoff bounds for failed attempts.

Exactly one of http_target, pubsub_target, or app_engine_target must be set; the module fails the plan otherwise.

Outputs

Name Description
id Fully-qualified job ID (projects/<p>/locations/<r>/jobs/<name>).
name Short name of the scheduler job.
region Region in which the job runs.
schedule The unix-cron schedule the job runs on.
time_zone IANA time zone used to interpret the schedule.
state Current job state (ENABLED, PAUSED, DISABLED, UPDATE_FAILED).
target_type Selected target: http, pubsub, or app_engine.

Enterprise scenario

A SaaS billing platform runs in asia-south1 and must close each tenant’s books at 02:15 local time. The platform team instantiates this module once per environment from a for_each over a tenants map, each invocation pointing at the same private Cloud Run “rollup” service but passing a different tenant ID in the request body and authenticating with a tightly-scoped OIDC service account that only holds roles/run.invoker. Because every job is version-pinned to v1.0.0 and carries identical retry_config (3 attempts, capped backoff), a single module bump rolls out a hardened retry policy across all ~400 tenant jobs in one reviewed plan, and the paired Monitoring alert means an accidentally-paused or failing close surfaces to PagerDuty before finance notices a missing report.

Best practices

TerraformGCPCloud SchedulerModuleIaC
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