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
- You need to trigger a Cloud Run service or Cloud Function (2nd gen) on a schedule and want the invocation authenticated with an OIDC token rather than left public.
- You want to fan work into Pub/Sub on a cadence — kicking off a batch pipeline, a data export, or a cache warm — and have downstream subscribers do the heavy lifting.
- You are replacing scattered VM crontabs or Kubernetes CronJobs with a managed, observable, IaC-defined scheduler that emits Cloud Logging entries and Monitoring metrics per run.
- You manage many similar jobs (per-tenant report generation, per-region housekeeping) and want them defined as a consistent, version-pinned module rather than copy-pasted resource blocks.
- You need schedules that are timezone-aware (e.g. “09:00 Asia/Kolkata” that survives DST elsewhere) and auditable through Terraform state and plan diffs.
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 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/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 (15s–1800s) 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
- Authenticate every HTTP target. Never point a job at an unauthenticated Cloud Run URL. Use a dedicated per-job (or per-purpose) service account with
oidc_service_account_email, grant it onlyroles/run.invokeron the specific service, and set the OIDCaudienceto the service’s base URL — for Cloud Run a wrong audience produces a silent 401. - Pin the time zone explicitly and prefer IANA names. Leaving
time_zoneat UTC is fine for housekeeping but dangerous for business schedules; useAsia/Kolkata, not a fixed offset, so DST regions stay correct. The cron fields are interpreted in that zone, not the server’s. - Set
retry_configdeliberately, not heroically. Cloud Scheduler defaults to zero retries. For idempotent targets, 3 retries with boundedmax_backoff_durationabsorbs transient 5xx; for non-idempotent ones, keepretry_count = 0and handle replays downstream. Pair retries with a realisticattempt_deadlineso a slow target does not stack overlapping runs. - Mind cost and quota at scale. The first 3 jobs per billing account are free; beyond that you pay per job per month, so prefer one parameterized job that fans out via Pub/Sub over hundreds of near-identical HTTP jobs when the cadence is shared.
- Use a consistent, descriptive naming convention. Encode purpose and cadence (
invoice-rollup-nightly,cache-warm-15m) so the job list is self-documenting; the module’snamevalidation keeps names within GCP’s character rules. - Roll out changes via
paused = truefirst. When introducing a new schedule, create it paused, verify the target and IAM wiring in a non-peak window, then flippausedto false — this avoids a misconfigured job firing into production on its first interval.