Home / Serverless

Event-Driven VM Auto-Scheduling on GCP — Start & Stop VMs with Cloud Scheduler, Pub/Sub & Cloud Functions | 2026

Event-Driven VM Auto-Scheduling on GCP — Start & Stop VMs with Cloud Scheduler, Pub/Sub & Cloud Functions | 2026

Use Cloud Scheduler to publish start/stop messages to Pub/Sub, then let Cloud Functions (2nd gen) filter Compute Engine VMs by the label `autoscheduling=true` and control their state. This event-driven pattern is serverless, cost-effective, and follows least-privilege IAM.

By Mateusz Chmielewski · Jul 31, 2026 · 16 min read

What Is Event-Driven VM Auto-Scheduling?

Event-driven VM auto-scheduling is a serverless pattern that starts and stops Compute Engine instances based on a time schedule. A Cloud Scheduler job publishes a message to a Pub/Sub topic; a Cloud Functions subscriber reads the message, lists VMs in a project or folder, filters them by labels, and calls the Compute Engine API to change their state.

Think of it like a programmable thermostat for your cloud servers. Instead of heating an empty house all day, the system turns VMs on before the team arrives and off after they leave — automatically and only for the rooms (projects) you choose.

ConceptExplanationWhen to use
Cloud SchedulerManaged cron service that triggers events on a recurring schedule.When you need reliable, minute-accurate scheduling without running a VM.
Pub/SubAsynchronous messaging bus that decouples the scheduler from the executor.When you want durable, retryable delivery between Cloud Scheduler and Cloud Functions.
Cloud Functions (2nd gen)Event-driven compute platform built on Cloud Run, with concurrency and longer timeouts.When you need serverless execution with Pub/Sub triggers and Terraform control.
Compute Engine LabelsKey-value metadata attached to VMs; filterable via the Compute Engine API.When you need a declarative signal for which resources a policy should affect.

Why Use an Event-Driven Scheduler Instead of Manual Scripts?

Development, staging, and demo environments often run 24/7 even though teams only need them during business hours. Manual scripts on a bastion host are fragile, opaque, and hard to secure. Instance schedules in Compute Engine are regional and lack cross-project or label-aware policies.

Combining Cloud Scheduler, Pub/Sub, and 2nd Gen Cloud Functions creates a completely serverless control plane. Scheduler fires cron ticks to Pub/Sub, which triggers a lightweight Python Cloud Function that calls the Compute Engine API to start or stop instances based on labels. For low-cost serverless applications, pair this with [Low-Cost 3-Tier Firebase Security Hardening](/tutorial/firebase-3-tier-app-low-cost-security-hardening), and manage credentials securely with [Secret Manager Automatic Rotation](/tutorial/gcp-secret-manager-terraform-module-automatic-rotation).

FeaturethisServicealtAaltB
Trigger modelEvent-driven via Pub/SubCron on a VM (Compute Engine)Compute Engine resource policies
Target scopeCross-project, label-filteredSingle VM or project onlySingle project and region
IAM & auditDedicated SA, Cloud LoggingShared SSH keys, limited logsBuilt-in audit, less granular
CostPay per invocation (~free for small fleets)Always-on VM costFree, but less flexible
DeploymentTerraform / CI-CDManual script managementConsole or gcloud

Prerequisites

  • GCP project with billing enabled
  • gcloud CLI authenticated with Owner or IAM Admin role
  • Terraform v1.5.0+ installed
  • Enable APIs: cloudfunctions.googleapis.com, cloudscheduler.googleapis.com, pubsub.googleapis.com, compute.googleapis.com, logging.googleapis.com

Step-by-Step Guide

Step 1: Enable Required GCP APIs

Before deploying resources, enable the APIs that Cloud Scheduler, Pub/Sub, Cloud Functions, and Compute Engine require. Without the APIs enabled, Terraform apply will fail with service-unavailable errors.

gcloud services enable cloudfunctions.googleapis.com \
  cloudscheduler.googleapis.com \
  pubsub.googleapis.com \
  compute.googleapis.com \
  logging.googleapis.com \
  --project=$PROJECT_ID

Step 2: Create a Dedicated Service Account

Create a least-privilege service account that Cloud Functions will use to list and control VMs. Avoid using the default Compute Engine service account. A dedicated account limits blast radius and makes audit logs readable. The principle of least privilege means the function only gets `compute.instances.start` and `compute.instances.stop`, not full Editor.

resource "google_service_account" "vm_scheduler" {
  account_id   = "vm-scheduler"
  display_name = "VM Scheduler Function"
  project      = var.project_id
}

resource "google_project_iam_custom_role" "vm_scheduler" {
  role_id     = "vmScheduler"
  title       = "VM Scheduler"
  description = "Minimal permissions to list and control labeled Compute Engine VMs."
  permissions = [
    "compute.instances.list",
    "compute.instances.start",
    "compute.instances.stop",
    "compute.zones.list",
  ]
}

resource "google_project_iam_member" "vm_scheduler" {
  project = var.project_id
  role    = google_project_iam_custom_role.vm_scheduler.id
  member  = "serviceAccount:${google_service_account.vm_scheduler.email}"
}

Step 3: Create Pub/Sub Topics for Start and Stop Events

Create two Pub/Sub topics: one for start schedules and one for stop schedules. Separation prevents a single misconfigured payload from doing the wrong action. Separate topics provide clear semantics and allow independent IAM, monitoring, and dead-letter policies for start vs. stop.

resource "google_pubsub_topic" "start_vms" {
  name    = "start-vms"
  project = var.project_id
  message_retention_duration = "86600s"
}

resource "google_pubsub_topic" "stop_vms" {
  name    = "stop-vms"
  project = var.project_id
  message_retention_duration = "86600s"
}

Step 4: Deploy the Cloud Function

Deploy a single Python Cloud Functions (2nd gen) function that subscribes to both topics and acts based on the message payload. Cloud Functions 2nd gen gives you longer timeouts, concurrency, and Cloud Run under the hood — better for fan-out operations across many VMs.

resource "google_cloudfunctions2_function" "vm_scheduler" {
  name     = "vm-scheduler"
  location = var.region
  project  = var.project_id

  build_config {
    runtime     = "python312"
    entry_point = "scheduler_handler"
    source {
      storage_source {
        bucket = google_storage_bucket.source.name
        object = google_storage_bucket_object.source.name
      }
    }
  }

  service_config {
    max_instance_count    = 5
    available_memory      = "256M"
    timeout_seconds       = 300
    service_account_email = google_service_account.vm_scheduler.email
    ingress_settings      = "ALLOW_INTERNAL_ONLY"
  }

  event_trigger {
    trigger_region        = var.region
    event_type            = "google.cloud.pubsub.topic.v1.messagePublished"
    pubsub_topic          = google_pubsub_topic.start_vms.id
    retry_policy          = "RETRY_POLICY_RETRY"
  }
}

resource "google_cloudfunctions2_function" "vm_scheduler_stop" {
  name     = "vm-scheduler-stop"
  location = var.region
  project  = var.project_id

  build_config {
    runtime     = "python312"
    entry_point = "scheduler_handler"
    source {
      storage_source {
        bucket = google_storage_bucket.source.name
        object = google_storage_bucket_object.source.name
      }
    }
  }

  service_config {
    max_instance_count    = 5
    available_memory      = "256M"
    timeout_seconds       = 300
    service_account_email = google_service_account.vm_scheduler.email
    ingress_settings      = "ALLOW_INTERNAL_ONLY"
  }

  event_trigger {
    trigger_region        = var.region
    event_type            = "google.cloud.pubsub.topic.v1.messagePublished"
    pubsub_topic          = google_pubsub_topic.stop_vms.id
    retry_policy          = "RETRY_POLICY_RETRY"
  }
}

Step 5: Write the Python Handler

Implement the function that reads the Pub/Sub message, determines the desired action, lists VMs, filters by label, and calls start or stop. Idempotent, defensive code ensures the function is safe to retry and only touches VMs explicitly opted in via the `autoscheduling=true` label.

import base64
import json
import logging
import os
from googleapiclient import discovery
from google.cloud import logging as cloud_logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

LABEL_KEY = "autoscheduling"
LABEL_VALUE = "true"
DESIRED_STATES = {"start": "RUNNING", "stop": "TERMINATED"}

def scheduler_handler(event, context):
    """Pub/Sub-triggered Cloud Function entry point."""
    payload = _decode_payload(event)
    action = payload.get("action", context.event_id.split("/")[-2] if "/" in context.event_id else "")
    project = payload.get("project", os.environ.get("GCP_PROJECT"))

    if action not in DESIRED_STATES:
        logger.error(f"Invalid action '{action}'. Expected 'start' or 'stop'.")
        return

    compute = discovery.build("compute", "v1", cache_discovery=False)
    instances = _list_autoscheduled_vms(compute, project)

    results = []
    for vm in instances:
        result = _transition_vm(compute, project, vm, action)
        results.append(result)
        logger.info(json.dumps(result))

    return {"processed": len(results), "results": results}

def _decode_payload(event):
    data = event.get("data", "")
    decoded = base64.b64decode(data).decode("utf-8")
    try:
        return json.loads(decoded)
    except json.JSONDecodeError:
        return {"action": decoded.strip()}

def _list_autoscheduled_vms(compute, project):
    """List all VMs in the project with autoscheduling=true label."""
    instances = []
    request = compute.instances().aggregatedList(project=project, filter=f"labels.{LABEL_KEY}={LABEL_VALUE}")
    while request is not None:
        response = request.execute()
        for zone, items in response.get("items", {}).items():
            for vm in items.get("instances", []):
                instances.append({
                    "name": vm["name"],
                    "zone": zone.rsplit("/", 1)[-1],
                    "status": vm["status"],
                    "labels": vm.get("labels", {}),
                })
        request = compute.instances().aggregatedList_next(previous_request=request, previous_response=response)
    return instances

def _transition_vm(compute, project, vm, action):
    zone = vm["zone"]
    name = vm["name"]
    desired_status = DESIRED_STATES[action]

    if vm["status"] == desired_status:
        return {"vm": name, "zone": zone, "action": action, "result": "already_in_desired_state"}

    try:
        if action == "start":
            op = compute.instances().start(project=project, zone=zone, instance=name).execute()
        else:
            op = compute.instances().stop(project=project, zone=zone, instance=name).execute()
        return {"vm": name, "zone": zone, "action": action, "result": "triggered", "operation": op["name"]}
    except Exception as exc:
        logger.exception(f"Failed to {action} {name}")
        return {"vm": name, "zone": zone, "action": action, "result": "error", "error": str(exc)}

Step 6: Create Cloud Scheduler Jobs

Create two cron jobs: one that publishes a start message on weekday mornings, and one that publishes a stop message on weekday evenings. Scheduler jobs are the time-based entry point; keeping start and stop separate lets you tune schedules independently for different environments.

resource "google_cloud_scheduler_job" "start_vms" {
  name             = "start-vms-weekdays"
  description      = "Start autoscheduled VMs at 08:00 Europe/Warsaw on weekdays"
  schedule         = "0 8 * * 1-5"
  time_zone        = "Europe/Warsaw"
  attempt_deadline = "320s"
  project          = var.project_id
  region           = var.region

  pubsub_target {
    topic_name = google_pubsub_topic.start_vms.id
    data       = base64encode(jsonencode({ "action": "start", "project": var.project_id }))
  }
}

resource "google_cloud_scheduler_job" "stop_vms" {
  name             = "stop-vms-weekdays"
  description      = "Stop autoscheduled VMs at 19:00 Europe/Warsaw on weekdays"
  schedule         = "0 19 * * 1-5"
  time_zone        = "Europe/Warsaw"
  attempt_deadline = "320s"
  project          = var.project_id
  region           = var.region

  pubsub_target {
    topic_name = google_pubsub_topic.stop_vms.id
    data       = base64encode(jsonencode({ "action": "stop", "project": var.project_id }))
  }
}

Step 7: Label VMs for Scheduling

Apply the `autoscheduling=true` label to every VM that should be managed by the scheduler. Only labeled VMs are affected. Label-based filtering is the safety guardrail that prevents the scheduler from touching production or shared resources.

gcloud compute instances add-labels dev-web-01 \
  --labels=autoscheduling=true \
  --zone=europe-west1-b \
  --project=$PROJECT_ID

Step 8: Test the Pipeline Manually

Before relying on the cron schedule, publish a test message to Pub/Sub and verify the function starts or stops the labeled VM. Manual validation catches IAM misconfigurations and label-filtering errors before the first scheduled run.

gcloud pubsub topics publish start-vms \
  --message='{"action":"start","project":"'$PROJECT_ID'"}' \
  --project=$PROJECT_ID

# Then check logs
gcloud logging read "resource.type=cloud_function AND jsonPayload.vm=dev-web-01" \
  --limit=10 --project=$PROJECT_ID

Verification & Health Check

Best Practices

  • Use Labels, Not Network Tags
  • Make Start/Stop Idempotent
  • Separate Service Accounts
  • Log Structured JSON

Common Mistakes

  • {"errorCode":"PERMISSION_DENIED on compute.instances.start","symptoms":"Cloud Function logs show 403 errors when calling the Compute Engine API.","rootCause":"The service account lacks `compute.instances.start` or `compute.instances.stop` permission, or the custom role was not granted.","fixCommand":"gcloud projects add-iam-policy-binding $PROJECT_ID \\\n --member=\"serviceAccount:vm-scheduler@$PROJECT_ID.iam.gserviceaccount.com\" \\\n --role=\"projects/$PROJECT_ID/roles/vmScheduler\"\n","code":"resource \"google_project_iam_member\" \"vm_scheduler\" {\n project = var.project_id\n role = google_project_iam_custom_role.vm_scheduler.id\n member = \"serviceAccount:${google_service_account.vm_scheduler.email}\"\n}\n","language":"hcl","filename":"fix-iam.tf","prevention":"Always apply IAM through Terraform and validate with `gcloud projects get-iam-policy`."}
  • {"errorCode":"NO_INSTANCES_AFFECTED","symptoms":"Scheduler runs but logs show `processed: 0` every time.","rootCause":"The target VMs are missing the `autoscheduling=true` label or are in a different project than the function is querying.","fixCommand":"gcloud compute instances list --filter=\"labels.autoscheduling=true\" \\\n --project=$PROJECT_ID --zones=europe-west1-b\n","code":"gcloud compute instances add-labels dev-web-01 \\\n --labels=autoscheduling=true --zone=europe-west1-b --project=$PROJECT_ID\n","language":"bash","filename":"fix-labels.sh","prevention":"Add label checks to your VM creation templates and CI policy validator."}
  • {"errorCode":"FUNCTION_TIMEOUT","symptoms":"Cloud Function invocation exceeds timeout during large fleets.","rootCause":"Sequential API calls across many zones take longer than the default 60-second timeout.","fixCommand":"gcloud functions deploy vm-scheduler --timeout=300 --memory=256Mi\n","code":"service_config {\n timeout_seconds = 300\n max_instance_count = 10\n}\n","language":"hcl","filename":"fix-timeout.tf","prevention":"Benchmark runtime with your fleet size and set timeout + concurrency accordingly."}

Cost Analysis

Featuremetriccost1kcost10kcost100kcost1m
Cloud Scheduler jobs$0.00$0.20$2.00$20.00
Cloud Functions invocations$0.00$0.00$0.40$4.00

References

Browse all tutorials