Scheduled tasks for a Cloud Run backend: Cloud Scheduler with OIDC, Cloud Run jobs, or a cron declared next to the code it calls.
Cloud Run serves HTTP requests and scales to zero, so it has no built-in scheduler. Recurring work like purging expired rows or sending a daily digest needs something outside the service to call it on time, with credentials, and without exposing the endpoint to the internet.
Google's answer is Cloud Scheduler, or a scheduled Cloud Run job for batch work. This guide shows both, starting with Encore (a tool that works alongside your framework), where the schedule is declared in code next to the endpoint it calls. All examples purge expired notes from Postgres at 03:00 UTC.
Each option is covered below, starting with Encore.
Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. On GCP, services run on Cloud Run and cron jobs are triggered by authenticated, cryptographically signed requests from the Encore platform, and the service verifies that each request comes from Encore.
The job is a private endpoint plus a CronJob declaration:
// notes/cleanup.ts
import { api } from "encore.dev/api";
import { CronJob } from "encore.dev/cron";
import { db } from "./notes";
// Deletes expired notes. Safe to run twice: a second run finds nothing to delete.
export const purgeExpired = api(
{ expose: false },
async (): Promise<{ deleted: number }> => {
const row = await db.queryRow<{ n: number }>`
WITH d AS (DELETE FROM notes WHERE expires_at < NOW() RETURNING 1)
SELECT COUNT(*)::int AS n FROM d`;
return { deleted: row?.n ?? 0 };
},
);
const _ = new CronJob("purge-expired-notes", {
title: "Purge expired notes",
schedule: "0 3 * * *",
endpoint: purgeExpired,
});
expose: false keeps purgeExpired off the public API, so the only callers are the cron and other services in the app. The db import is the notes service's own SQLDatabase, which is Cloud SQL on GCP.
Cron jobs do not run under encore run. To test, call the endpoint from the local development dashboard. In our run with one expired note, the first call returned {"deleted":1} and the second {"deleted":0}, which is the property you want when a schedule fires twice or a deploy overlaps a run.
Simple schedules can use every: "1h" instead of a cron expression; intervals must divide 24 hours evenly, and the compiler rejects ones that do not.
For a coding agent, the cron job is one more line of TypeScript in the same change as the endpoint it calls.
The cron jobs docs cover schedule syntax and idempotency, and the deployment guide covers how a push becomes a running service.
Run in your terminal to get started locally.
The setup Google documents is a Cloud Run service that requires authentication and a Scheduler job that calls it with an OIDC token:
gcloud iam service-accounts create notes-scheduler
gcloud run services add-iam-policy-binding notes-api \
--region=europe-west1 \
--member="serviceAccount:notes-scheduler@my-project.iam.gserviceaccount.com" \
--role="roles/run.invoker"
gcloud scheduler jobs create http purge-expired-notes \
--location=europe-west1 \
--schedule="0 3 * * *" \
--time-zone="Etc/UTC" \
--http-method=POST \
--uri="https://notes-api-abc123-ew.a.run.app/internal/purge-expired" \
--oidc-service-account-email="notes-scheduler@my-project.iam.gserviceaccount.com" \
--oidc-token-audience="https://notes-api-abc123-ew.a.run.app"
The Invoker role applies to the whole Cloud Run service, so if the same service also handles public traffic with --allow-unauthenticated, Cloud Run no longer checks the token for you and anyone can call /internal/purge-expired. Teams either split the job into its own service or verify the OIDC token inside the handler. Cloud Scheduler also has its own retry settings and attempt deadline, which should match how long the endpoint takes.
For work that runs longer than a request should, a Cloud Run job runs a container to completion, and Cloud Scheduler triggers it through the Cloud Run API. There is no endpoint to protect, but the job is a separate container image or entrypoint with its own deploy, environment variables and database credentials. That fits a nightly export that reads millions of rows. For a delete query that runs in a second, it is more moving parts than the task needs.
Scheduler retries, slow runs and manual reruns mean a job can execute more than once for the same window. Write the job so a second run is harmless, like the DELETE ... WHERE expires_at < NOW() above, rather than relying on exactly one call per schedule.
Pick Encore when you want the schedule reviewed in the same pull request as the code and run against a private endpoint in your own GCP project.
Pick Cloud Scheduler with an authenticated endpoint when you run plain Cloud Run services and want the schedule in Terraform, and a scheduled Cloud Run job when each run is a long batch process that should not share the API's container.
The standard setup is a Cloud Scheduler job that sends an HTTP request with an OIDC token to a Cloud Run endpoint, with the scheduler's service account granted the Cloud Run Invoker role. For batch work you can schedule a Cloud Run job instead. With Encore, you write new CronJob("purge-expired-notes", { schedule: "0 3 * * *", endpoint: purgeExpired }) next to the endpoint, and Encore calls it on schedule with signed requests after you deploy to GCP, with no Cloud Scheduler job to create.
Cloud Scheduler defaults to UTC and accepts a --time-zone flag per job.
Call the endpoint yourself. Cloud Scheduler does not trigger jobs on a laptop, so the job's logic should live in a normal endpoint you can call by hand and that is safe to run twice.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.