Problem
ThreadOps has no infrastructure-as-code yet. The two Cloud Run services (webhook, processor), Pub/Sub topic, Secret Manager secrets, and Artifact Registry are either unprovisioned or would need to be created by hand.
Proposal
Add a complete, apply-ready Terraform configuration under infra/ that provisions all GCP resources ThreadOps needs. Work is split into six phases so each can be validated independently.
Prerequisites (manual, one-time)
Create the GCS bucket for remote state — it cannot be managed by the same Terraform config it backs:
gcloud storage buckets create gs://threadops-tfstate \
--location=europe-north1 \
--uniform-bucket-level-access
What to build
Phase 1 — Scaffold and provider foundation
Create the infra/ directory as the Terraform root with:
versions.tf — pin hashicorp/google (>= 6.0) and hashicorp/google-beta.
backend.tf — GCS backend pointing at the state bucket.
variables.tf — declare project_id, region, environment, image_tag.
outputs.tf — placeholder file, populated in later phases.
main.tf — configure the google provider with project and region.
apis.tf — enable required GCP APIs via google_project_service for_each: run, pubsub, secretmanager, artifactregistry, iam.
Verify: terraform init && terraform validate.
Phase 2 — IAM and service accounts
Create iam.tf with two service accounts:
threadops-receiver — roles/pubsub.publisher.
threadops-worker — roles/pubsub.subscriber, roles/secretmanager.secretAccessor, roles/artifactregistry.reader.
Use google_project_iam_member exclusively (never _binding — it's destructive). Output both SA emails in outputs.tf.
Phase 3 — Secret Manager
Create secrets.tf with three google_secret_manager_secret resources:
threadops-slack-signing-secret
threadops-github-token
threadops-llm-api-key
Set replication { auto {} } on each. Do not create google_secret_manager_secret_version resources — values are seeded manually or via CI to keep secrets out of state.
Phase 4 — Artifact Registry and Pub/Sub
artifact_registry.tf:
google_artifact_registry_repository with repository_id = "threadops", format = "DOCKER".
- Grant
roles/artifactregistry.reader to both SAs.
pubsub.tf:
google_pubsub_topic "thread_transcripts".
google_pubsub_subscription "worker_pull" — pull subscription (ack_deadline_seconds = 60) with a dead-letter topic. Pull avoids a circular dependency with the worker Cloud Run URL.
Phase 5 — Cloud Run services
cloud_run_receiver.tf — google_cloud_run_v2_service:
ingress = "INGRESS_TRAFFIC_ALL" (must accept Slack webhooks from the internet).
- Env vars:
PUBSUB_TOPIC (matches webhook service), PROJECT_ID.
- Secret ref:
SLACK_SIGNING_SECRET from Secret Manager.
- SA:
threadops-receiver.
- Min instances:
1 (Slack requires a 3-second ack — avoid cold starts).
- IAM:
allUsers invoker.
cloud_run_worker.tf — google_cloud_run_v2_service:
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY".
- Env vars:
PUBSUB_SUBSCRIPTION_ID, GCP_PROJECT_ID, GITHUB_REPO.
- Secret refs:
GITHUB_TOKEN, LLM_API_KEY (mapped from threadops-llm-api-key) from Secret Manager.
- SA:
threadops-worker.
- Min instances:
0 (worker latency is not user-facing).
Add both service URLs to outputs.tf.
Env var naming note: the webhook service currently uses PROJECT_ID and PUBSUB_TOPIC; the processor uses GITHUB_TOKEN, GITHUB_REPO, ANTHROPIC_API_KEY, ANTHROPIC_MODEL, SLACK_BOT_TOKEN. The Terraform env var names must match what the Go code reads from os.Getenv.
Phase 6 — Final wiring and review
locals.tf — shared name prefixes and labels (app = "threadops", managed_by = "terraform"). Apply labels to all resources.
infra/README.md — prerequisites, init/plan/apply instructions, how to update image tags via var.image_tag.
- Run
terraform fmt -recursive and terraform validate.
Target file structure
infra/
├── README.md
├── apis.tf
├── artifact_registry.tf
├── backend.tf
├── cloud_run_receiver.tf
├── cloud_run_worker.tf
├── iam.tf
├── locals.tf
├── main.tf
├── outputs.tf
├── pubsub.tf
├── secrets.tf
├── variables.tf
└── versions.tf
Constraints
- Never store secret values in
.tf files or state — stubs only.
- Use
google_project_iam_member exclusively — never _binding.
- Pull subscription only — no push (avoids circular Cloud Run URL dependency).
INGRESS_TRAFFIC_ALL on receiver, INTERNAL_ONLY on worker — this is a security boundary.
- Work phase by phase; run
terraform validate before moving on.
- Run
terraform fmt -recursive at the end of every phase.
Problem
ThreadOps has no infrastructure-as-code yet. The two Cloud Run services (webhook, processor), Pub/Sub topic, Secret Manager secrets, and Artifact Registry are either unprovisioned or would need to be created by hand.
Proposal
Add a complete, apply-ready Terraform configuration under
infra/that provisions all GCP resources ThreadOps needs. Work is split into six phases so each can be validated independently.Prerequisites (manual, one-time)
Create the GCS bucket for remote state — it cannot be managed by the same Terraform config it backs:
What to build
Phase 1 — Scaffold and provider foundation
Create the
infra/directory as the Terraform root with:versions.tf— pinhashicorp/google(>= 6.0) andhashicorp/google-beta.backend.tf— GCS backend pointing at the state bucket.variables.tf— declareproject_id,region,environment,image_tag.outputs.tf— placeholder file, populated in later phases.main.tf— configure thegoogleprovider withprojectandregion.apis.tf— enable required GCP APIs viagoogle_project_servicefor_each:run,pubsub,secretmanager,artifactregistry,iam.Verify:
terraform init && terraform validate.Phase 2 — IAM and service accounts
Create
iam.tfwith two service accounts:threadops-receiver—roles/pubsub.publisher.threadops-worker—roles/pubsub.subscriber,roles/secretmanager.secretAccessor,roles/artifactregistry.reader.Use
google_project_iam_memberexclusively (never_binding— it's destructive). Output both SA emails inoutputs.tf.Phase 3 — Secret Manager
Create
secrets.tfwith threegoogle_secret_manager_secretresources:threadops-slack-signing-secretthreadops-github-tokenthreadops-llm-api-keySet
replication { auto {} }on each. Do not creategoogle_secret_manager_secret_versionresources — values are seeded manually or via CI to keep secrets out of state.Phase 4 — Artifact Registry and Pub/Sub
artifact_registry.tf:google_artifact_registry_repositorywithrepository_id = "threadops",format = "DOCKER".roles/artifactregistry.readerto both SAs.pubsub.tf:google_pubsub_topic "thread_transcripts".google_pubsub_subscription "worker_pull"— pull subscription (ack_deadline_seconds = 60) with a dead-letter topic. Pull avoids a circular dependency with the worker Cloud Run URL.Phase 5 — Cloud Run services
cloud_run_receiver.tf—google_cloud_run_v2_service:ingress = "INGRESS_TRAFFIC_ALL"(must accept Slack webhooks from the internet).PUBSUB_TOPIC(matches webhook service),PROJECT_ID.SLACK_SIGNING_SECRETfrom Secret Manager.threadops-receiver.1(Slack requires a 3-second ack — avoid cold starts).allUsersinvoker.cloud_run_worker.tf—google_cloud_run_v2_service:ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY".PUBSUB_SUBSCRIPTION_ID,GCP_PROJECT_ID,GITHUB_REPO.GITHUB_TOKEN,LLM_API_KEY(mapped fromthreadops-llm-api-key) from Secret Manager.threadops-worker.0(worker latency is not user-facing).Add both service URLs to
outputs.tf.Env var naming note: the webhook service currently uses
PROJECT_IDandPUBSUB_TOPIC; the processor usesGITHUB_TOKEN,GITHUB_REPO,ANTHROPIC_API_KEY,ANTHROPIC_MODEL,SLACK_BOT_TOKEN. The Terraform env var names must match what the Go code reads fromos.Getenv.Phase 6 — Final wiring and review
locals.tf— shared name prefixes and labels (app = "threadops",managed_by = "terraform"). Apply labels to all resources.infra/README.md— prerequisites, init/plan/apply instructions, how to update image tags viavar.image_tag.terraform fmt -recursiveandterraform validate.Target file structure
Constraints
.tffiles or state — stubs only.google_project_iam_memberexclusively — never_binding.INGRESS_TRAFFIC_ALLon receiver,INTERNAL_ONLYon worker — this is a security boundary.terraform validatebefore moving on.terraform fmt -recursiveat the end of every phase.