
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.
| Concept | Explanation | When to use |
|---|---|---|
| Cloud Scheduler | Managed cron service that triggers events on a recurring schedule. | When you need reliable, minute-accurate scheduling without running a VM. |
| Pub/Sub | Asynchronous 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 Labels | Key-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).
| Feature | thisService | altA | altB |
|---|---|---|---|
| Trigger model | Event-driven via Pub/Sub | Cron on a VM (Compute Engine) | Compute Engine resource policies |
| Target scope | Cross-project, label-filtered | Single VM or project only | Single project and region |
| IAM & audit | Dedicated SA, Cloud Logging | Shared SSH keys, limited logs | Built-in audit, less granular |
| Cost | Pay per invocation (~free for small fleets) | Always-on VM cost | Free, but less flexible |
| Deployment | Terraform / CI-CD | Manual script management | Console 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
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| Cloud Scheduler jobs | $0.00 | $0.20 | $2.00 | $20.00 | |
| Cloud Functions invocations | $0.00 | $0.00 | $0.40 | $4.00 |