Context
sheehan-workspace today is a GCP-based personal monorepo: OpenTofu IaC in infra/ provisioning a Hugo static site (site/) on Firebase Hosting, deployed via GitHub Actions with Workload Identity Federation. There is no Python, no database, and no scheduled work.
The goal is to add a place to write Python cron jobs that persist data to Postgres via an ORM, on the same GCP project, without breaking the existing site stack. The longer-term aim is a Python web backend (Django) fronted by Flutter — so this scaffold needs to be expandable into a web app, not a throwaway shell.
Key locked decisions:
- Compute: Cloud Run Jobs, one per cron task, triggered by Cloud Scheduler.
- Database: Cloud SQL Postgres (
db-f1-micro), public IP, connected via the platform-managed unix socket (cloud_sql_instanceson the Cloud Run Job). No VPC connector — saves ~$10/mo. - ORM: Django ORM in standalone mode — cron jobs run as Django management commands (
python manage.py run_job <name>), somanage.pyhandlesdjango.setup()for free. Same project gains web views later by addingurls.pyentries and a second Cloud Run Service. - Secrets: Secret Manager, mounted as env into the Cloud Run Job.
- Deploy: new
.github/workflows/deploy-jobs.ymltriggered byjobs-v*tags, reusing the existing WIF +site-deployerSA with extra IAM bindings.
Estimated cost at idle: ~$12–14/mo, ~95% of which is Cloud SQL.
1. OpenTofu additions under infra/
Modify
apis.tf— append tolocal.required_services:artifactregistry.googleapis.com,run.googleapis.com,cloudscheduler.googleapis.com,sqladmin.googleapis.com,secretmanager.googleapis.com.variables.tf— addjobs_image_tag(default"latest"),db_tier(default"db-f1-micro"),db_name(default"jobsdb"),db_user(default"jobs").cicd.tf— grantsite-deployerSA:roles/artifactregistry.writeron thejobsrepo,roles/run.developerproject-wide, androles/iam.serviceAccountUseron the newjobs-runtimeSA (required to deploy a job that runs as that SA).outputs.tf— addjobs_image_repo,jobs_runtime_sa,db_connection_name.
Create
artifact_registry.tf—google_artifact_registry_repository.jobs(Docker format,us-central1).cloudsql.tf:google_sql_database_instance.main:POSTGRES_15,var.db_tier, zonal, 10 GB HDD, public IP, no authorized networks,cloudsql.iam_authentication = on,deletion_protection = true.google_sql_database.app,google_sql_user.app(password auth),random_password.db.
secrets.tf:google_secret_manager_secret.db_password+ version fromrandom_password.db.google_secret_manager_secret.django_secret_key+ version fromrandom_password.django(length 64).secretAccessorIAM binding forjobs-runtimeSA on both.
cloudrun_jobs.tf:google_service_account.jobs_runtime(account_id = "jobs-runtime").roles/cloudsql.clienton jobs_runtime.locals.jobsmap:{ migrate = { args = ["migrate"], schedule = null }, example_stats = { args = ["run_job", "example_stats"], schedule = "0 * * * *" } }.google_cloud_run_v2_job.thiswithfor_each = local.jobs:template.template.service_account = jobs_runtime.email.template.template.cloud_sql_instances = [google_sql_database_instance.main.connection_name]— provides/cloudsql/<conn>unix socket.containers.image = "${region}-docker.pkg.dev/${project}/jobs/app:${var.jobs_image_tag}".containers.command = ["python", "manage.py"],containers.args = each.value.args.- Env:
DJANGO_SETTINGS_MODULE=config.settings.cloud,GCP_PROJECT_ID,DB_INSTANCE_CONNECTION_NAME,DB_NAME,DB_USER;DB_PASSWORDandDJANGO_SECRET_KEYviavalue_source.secret_key_ref. lifecycle.ignore_changes = [template[0].template[0].containers[0].image]sogcloud run jobs updatefrom CI doesn’t fight tofu.
scheduler.tf:google_service_account.scheduler+google_cloud_run_v2_job_iam_member.scheduler_invoker(roles/run.invoker) per scheduled job.google_cloud_scheduler_job.thiswithfor_each = { for k, v in local.jobs : k => v if v.schedule != null }.http_target.uri = "https://${region}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${project}/jobs/jobs-${each.key}:run".oauth_token(notoidc_token) since the target is*.googleapis.com— common pitfall.
2. Python project under jobs/
Tree
jobs/
├── Dockerfile
├── .dockerignore
├── pyproject.toml
├── manage.py
├── config/
│ ├── settings/{__init__,base,local,cloud}.py
│ ├── urls.py # empty list today; ready for views later
│ ├── wsgi.py
│ └── asgi.py
├── core/
│ ├── apps.py
│ ├── models.py
│ ├── admin.py
│ ├── migrations/
│ └── management/commands/run_job.py
└── jobs_pkg/
├── registry.py # name -> callable
└── example_stats.py
Key points
- Jobs are Django management commands, not standalone scripts.
manage.pyrunsdjango.setup()automatically — no bare-script init needed. run_jobis a one-line dispatcher:JOBS[name](). Adding a new cron job = a new module injobs_pkg/, a line inregistry.py, a line inlocal.jobsin tofu.config/urls.pyexists but is empty today. Adding web views later = no restructure.INSTALLED_APPSincludesdjango.contrib.{contenttypes,auth}+corefrom day one, so future web migrations stay clean.DATABASES["default"]reads env vars; on Cloud RunDB_HOST=/cloudsql/${DB_INSTANCE_CONNECTION_NAME}(unix socket via the platform proxy).pyproject.tomldeps:django>=5,<6,psycopg[binary]>=3.2,httpx. Dev:ruff,pytest,pytest-django.
Example job (jobs_pkg/example_stats.py)
Fetches top 30 Hacker News story IDs hourly, stores them in HnTopStory (fields: captured_at, rank, item_id, title, score, url). No API keys, idempotent per timestamp — a plausible “what was HN doing when I posted this” stat feed for the personal site. Demonstrates HTTP fetch + bulk ORM insert.
Dockerfile
python:3.12-slim, non-root user, pip install --no-cache-dir ., ENTRYPOINT ["python", "manage.py"]. No Cloud SQL Auth Proxy in the image — Cloud Run Jobs gen2 provides the socket.
3. Connectivity choice
Cloud SQL public IP + cloud_sql_instances unix socket on the Cloud Run Job. GCP’s managed proxy authenticates via IAM; no public ingress to Postgres even though the instance has a public IP. Saves ~$10/mo vs. a Serverless VPC Connector.
Django uses the built-in jobs user + Secret Manager password — IAM DB auth requires a custom psycopg connection factory in Django, not worth the complexity today. The cloudsql.iam_authentication flag is on so you can switch later.
4. GitHub Actions: .github/workflows/deploy-jobs.yml
Trigger: push.tags: ['jobs-v*'] + workflow_dispatch. Permissions: id-token: write, contents: read.
Steps:
- Checkout, auth via existing WIF (
site-deployerSA),setup-gcloud. gcloud auth configure-docker us-central1-docker.pkg.dev.- Compute
IMAGE_TAG(tag → version, or${GITHUB_SHA}for manual dispatch); tag image as both$TAGandlatest. docker build ./jobs && docker pushto Artifact Registry.gcloud run jobs update jobs-migrate --image $IMAGE:$TAG --region us-central1.gcloud run jobs execute jobs-migrate --region us-central1 --wait(the--waitis required to surface migration failures as workflow failures).- Loop over cron jobs (
jobs-example_stats, …) running the sameupdate.
Do not run tofu apply from this workflow. Infra changes stay manual / separate.
5. Verification
Local
cd jobs
python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:15
DJANGO_SETTINGS_MODULE=config.settings.local python manage.py migrate
DJANGO_SETTINGS_MODULE=config.settings.local python manage.py run_job example_stats
DJANGO_SETTINGS_MODULE=config.settings.local python manage.py shell \
-c "from core.models import HnTopStory; print(HnTopStory.objects.count())" # expect 30
Cloud bring-up
cd infra && tofu init && tofu apply— provisions DB, secrets, jobs, scheduler.git tag jobs-v0.1.0 && git push origin jobs-v0.1.0— runs the workflow.gcloud run jobs executions list --job jobs-migrate --region us-central1— confirm success.gcloud run jobs execute jobs-example_stats --region us-central1 --wait— manual smoke.gcloud scheduler jobs run run-example_stats --location us-central1— verify Scheduler → Job wiring.- After first scheduled fire (or temporarily set
*/5 * * * *): connect viacloud-sql-proxy <conn_name> &thenpsql "host=127.0.0.1 user=jobs dbname=jobsdb"→SELECT count(*), max(captured_at) FROM core_hntopstory;.
6. Critical files
Create:
infra/{artifact_registry,cloudsql,secrets,cloudrun_jobs,scheduler}.tfjobs/Dockerfilejobs/pyproject.tomljobs/manage.pyjobs/config/settings/{base,local,cloud}.pyjobs/config/urls.pyjobs/core/models.pyjobs/core/management/commands/run_job.pyjobs/jobs_pkg/{registry,example_stats}.py.github/workflows/deploy-jobs.yml
Modify:
infra/apis.tf— extendlocal.required_services.infra/variables.tf— 4 new vars.infra/cicd.tf— 3 IAM bindings forsite-deployer.infra/outputs.tf— 3 new outputs..gitignore— Python artifacts.
7. Gotchas
- Cloud Scheduler → Cloud Run Jobs uses
oauth_token, notoidc_token(target host is*.googleapis.com, not the run.app URL). --waitongcloud run jobs executeis what propagates the exit code; without it a failing migration silently passes CI.lifecycle.ignore_changeson the job image lets tofu andgcloud run jobs updatecoexist without drift fights.cloud_sql_instanceson the Cloud Run Job provides/cloudsql/<connection_name>automatically; do not also embed the Cloud SQL Auth Proxy in the Dockerfile.db-f1-microis not available in every region/Postgres-version combo —db_tieris a variable so you can swap todb-g1-smallwithout code changes.