A complete guide for deploying internal-only Cloud Run functions on Google Cloud Platform with automated scheduling and optional Apps Script integration.
This repository demonstrates how to:
- Deploy a secure, internal-only Cloud Run function (Gen 2)
- Automate execution with Cloud Scheduler
- Grant Google Drive/Workspace API access
- Trigger functions on-demand from Google Apps Script
- Google Cloud Project with billing enabled
gcloudCLI installed and configured- Basic familiarity with Python and Google Cloud Platform
.
├── main.py # Your Python function code
├── requirements.txt # Python dependencies
├── azure-pipelines.yml # Azure DevOps CI/CD pipeline (see below)
└── README.md # This file
This repo includes azure-pipelines.yml, which automates everything in "Step 2: Deploy the Function" below. Every push to main validates and redeploys the function; every pull request into main is validated only (no deploy). Once it's set up, you no longer need to run gcloud functions deploy by hand.
The pipeline has two stages:
-
Validate (runs on every PR and on
main)- Installs
requirements.txt - Byte-compiles
main.py(python -m py_compile) to catch syntax errors before deploying
- Installs
-
Deploy (runs only on pushes to
main, after Validate passes)- Downloads the GCP service account key from Azure DevOps' Secure Files (never committed to the repo or written to logs)
- Installs the
gcloudCLI on the build agent - Authenticates with
gcloud auth activate-service-account - Runs
gcloud functions deployas a Gen2 function with--ingress-settings=internal-onlyand--no-allow-unauthenticated, matching the security posture described in this README - Passes
SHARED_DRIVE_FOLDERandSHARED_DRIVE_IDvia--set-env-vars, sourced live from an Azure DevOps variable group — so changing those values in Azure DevOps and re-running the pipeline is enough to update the deployed function's config - Revokes the service account credentials from the build agent at the end of the job, whether it succeeded or failed
Before the pipeline can run, configure three things in your Azure DevOps project:
1. Secure file — the deployment credential
In Pipelines → Library → Secure files, upload your GCP service account key JSON, named exactly gcp-sa-key.json. Authorize it for use by this pipeline (or the production environment) under its permissions tab.
The service account behind this key needs, at minimum:
roles/run.developer(orroles/cloudfunctions.developer)roles/iam.serviceAccountUser
on the target GCP project.
2. Variable group — deployment configuration
In Pipelines → Library → Variable groups, create a group named gcp-folder-map-function with these variables:
| Variable | Description | Example |
|---|---|---|
GCP_PROJECT_ID |
Target GCP project | my-gcp-project |
GCP_REGION |
Deployment region | europe-west1 |
FUNCTION_NAME |
Cloud Function/Run service name | folder-map-function |
RUNTIME |
Python runtime for Gen2 | python312 |
SHARED_DRIVE_FOLDER |
Folder ID the map file is uploaded to | 1AbCdEfGhIjKlMnOp |
SHARED_DRIVE_ID |
Shared Drive ID to scan for folders | 0AbCdEfGhIjKlMnOp |
None of these values are secret (the sensitive part is the key file above), so plain variables are fine.
3. Environment — deployment history and gates
In Pipelines → Environments, create an environment named production. The Deploy stage targets this environment, which gives you a deployment history in Azure DevOps and lets you optionally add a manual-approval check before deploys go out.
Point an Azure DevOps pipeline at azure-pipelines.yml (Pipelines → New pipeline → Existing YAML file). From then on:
- Opening a PR into
maintriggers Validate only. - Merging/pushing to
maintriggers Validate then Deploy.
To update the deployed environment variables without a code change, edit the variable group and re-run the pipeline — no local gcloud needed.
Define your Python dependencies:
functions-framework==3.*
google-api-python-client==2.*
google-auth==2.*
Your function must include the @functions_framework.http decorator:
import os
import functions_framework
import google.auth
from googleapiclient.discovery import build
@functions_framework.http
def main(request):
"""
HTTP Cloud Function entry point.
Args:
request (flask.Request): The request object.
Returns:
The response text, or any set of values that can be turned into a
Response object using `make_response`.
"""
try:
# Get default credentials
creds, project_id = google.auth.default()
# Your business logic here
# Example: Interact with Google Drive API
# service = build('drive', 'v3', credentials=creds)
return {"status": "success", "message": "Function executed successfully"}, 200
except Exception as e:
return {"status": "error", "message": str(e)}, 500Deploy as a Gen 2 Cloud Function with internal-only access:
gcloud functions deploy your-function-name \
--gen2 \
--runtime=python310 \
--region=europe-west1 \
--source=. \
--entry-point=main \
--trigger-http \
--ingress-settings=internal-onlyKey Parameters:
--gen2: Uses Cloud Run (Gen 2) infrastructure--ingress-settings=internal-only: Blocks all public internet traffic--entry-point=main: Points to the function name in your code
Since the function is internal-only, you need a service account to invoke it.
gcloud iam service-accounts create function-scheduler-sa \
--display-name="Function Scheduler Service Account"gcloud run services add-iam-policy-binding your-function-name \
--region=europe-west1 \
--member="serviceAccount:function-scheduler-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/run.invoker"If your function needs to access Google Drive or other Workspace APIs:
gcloud run services describe your-function-name \
--region=europe-west1 \
--format="value(template.serviceAccount)"Copy the service account email and share your Drive folder/Shared Drive with it, granting appropriate permissions (e.g., Content Manager, Editor).
Set up automatic execution using Cloud Scheduler:
gcloud scheduler jobs create http scheduled-function-job \
--location=europe-west1 \
--schedule="0 9 * * 1-5" \
--time-zone="Europe/Berlin" \
--uri="https://YOUR-CLOUD-RUN-URL.run.app/" \
--http-method=GET \
--oidc-service-account-email="function-scheduler-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com"Common Cron Schedules:
0 9 * * 1-5: Every weekday at 9 AM0 */6 * * *: Every 6 hours*/30 * * * *: Every 30 minutes0 0 * * 0: Every Sunday at midnight
gcloud scheduler jobs run scheduled-function-job --location=europe-west1To invoke your internal function from a Google Sheet or Apps Script:
Enable "Show manifest file" in Apps Script settings, then edit appsscript.json:
{
"oauthScopes": [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/script.external_request"
]
}function triggerCloudFunction() {
const projectId = 'YOUR_PROJECT_ID';
const location = 'europe-west1';
const jobId = 'scheduled-function-job';
const url = `https://cloudscheduler.googleapis.com/v1/projects/${projectId}/locations/${location}/jobs/${jobId}:run`;
const options = {
method: 'post',
headers: {
Authorization: 'Bearer ' + ScriptApp.getOAuthToken()
},
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
Logger.log(response.getContentText());
return JSON.parse(response.getContentText());
}To update an existing deployment:
# Create project directory
mkdir my-cloud-function
# Move files
mv main.py requirements.txt my-cloud-function/
# Navigate to directory
cd my-cloud-function
# Redeploy (uses same function name)
gcloud functions deploy your-function-name \
--gen2 \
--runtime=python310 \
--region=europe-west1 \
--source=. \
--entry-point=main \
--trigger-http \
--ingress-settings=internal-only# Update code in place and redeploy
gcloud functions deploy your-function-name \
--gen2 \
--runtime=python310 \
--region=europe-west1 \
--source=. \
--entry-point=main \
--trigger-http \
--ingress-settings=internal-onlyNote: Updates deploy with zero downtime. The old version continues serving requests while the new version builds.
gcloud functions logs read your-function-name \
--region=europe-west1 \
--limit=50gcloud scheduler jobs describe scheduled-function-job \
--location=europe-west1Visit the Cloud Functions Console for:
- Execution metrics and graphs
- Real-time logs
- Error tracking
- Invocation history
- Never expose internal functions publicly - Always use
--ingress-settings=internal-only - Use service accounts - Create dedicated service accounts for each function/purpose
- Principle of least privilege - Grant only necessary IAM roles
- Rotate credentials - Regularly review and update service account permissions
- Enable Cloud Audit Logs - Track who accesses your functions
- Verify service account has
roles/run.invokerpermission - Check Cloud Scheduler job is enabled:
gcloud scheduler jobs describe JOB_NAME --location=LOCATION - Review Cloud Scheduler logs for authentication errors
- Confirm the runtime service account has access to required resources (Drive, etc.)
- Verify API services are enabled:
gcloud services list --enabled
- Increase timeout: Add
--timeout=540sto deployment command (max 540s for Gen 2) - Optimize your code for faster execution
- Consider using Cloud Tasks for longer-running jobs
- Cloud Functions: Free tier includes 2M invocations/month
- Cloud Scheduler: First 3 jobs per month are free
- Cloud Run (Gen 2 backend): Pay only for execution time
- See Google Cloud Pricing Calculator for estimates
- Cloud Functions Documentation
- Cloud Scheduler Documentation
- Service Account Best Practices
- Cron Schedule Format
MIT License - See LICENSE file for details
Contributions are welcome! Please open an issue or submit a pull request.