Every Python API project on GCP starts the same way. Not with your business logic. With Terraform.
Two or three days of infrastructure setup before you write a single line of the thing you actually wanted to build. Cloud Run configuration, IAM roles, Artifact Registry, GitHub Actions pipeline, Workload Identity Federation, Secret Manager integration.
We got tired of it. So we built it once, properly, and turned it into a starter template.
This article walks through what's in the FastAPI + Cloud Run Starter and, more importantly, why each piece is there.
The architecture
GitHub Actions (CI/CD)
↓ Workload Identity Federation (no service account keys)
Google Artifact Registry
↓ Docker image
Cloud Run (FastAPI application)
↓ reads secrets at runtime
Secret Manager
The entire infrastructure is managed with Terraform. The application never knows where it's running.
Workload Identity Federation — stop using service account keys
Most tutorials tell you to create a service account key JSON and paste it into GitHub Secrets. Don't.
Service account keys are long-lived credentials. They don't expire. They can be leaked. Managing rotation is painful. And GCP's security guidance explicitly recommends against them for CI/CD.
Workload Identity Federation (WIF) lets GitHub Actions authenticate to GCP using a short-lived OIDC token. The trust relationship is configured in Terraform:
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-pool"
display_name = "GitHub Actions Pool"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-provider"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
}
attribute_condition = "assertion.repository == 'your-org/your-repo'"
}
In GitHub Actions:
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.DEPLOY_SA }}
No key file. No secret to rotate. The token lasts for the duration of the workflow run.
Secret Manager → Cloud Run → Pydantic
The pattern for secrets: they live in Secret Manager. Cloud Run mounts them as environment variables. Pydantic's BaseSettings reads environment variables. Your application code never touches the Secret Manager SDK.
Cloud Run secret binding in gcloud run deploy:
--set-secrets "DATABASE_URL=my-db-url:latest,API_KEY=my-api-key:latest"
Pydantic settings:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = ""
api_key: str = ""
settings = Settings()
For local development, create a .env file:
DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=local-dev-key
Same settings model. Different source. Local and production behave identically.
IAM scoping — the mistake most tutorials make
Your Cloud Run service account needs roles/secretmanager.secretAccessor. But on what?
Most examples grant it at the project level. That's overpermissioned — your service account can read every secret in the project.
The correct approach is to grant it per secret:
resource "google_secret_manager_secret_iam_member" "db_url_access" {
secret_id = google_secret_manager_secret.db_url.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}
More Terraform to write, but the right security posture.
The CI/CD pipeline
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: ruff check .
- run: pytest
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.DEPLOY_SA }}
- name: Build and push
run: |
IMAGE="${{ env.REGISTRY }}/my-service:${{ github.sha }}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
- name: Deploy
run: |
gcloud run deploy my-service \
--image "$IMAGE" \
--region us-central1 \
--set-secrets "API_KEY=my-api-key:latest" \
--allow-unauthenticated
Tests run on every push. Deployment runs only on main. Image is tagged with the git SHA — full traceability.
What the starter gives you
All of the above, working together, from the first commit:
- FastAPI scaffold with health endpoint, structured logging, rate limiting
- Terraform modules for Cloud Run, Artifact Registry, IAM, Secret Manager
- GitHub Actions workflow with WIF authentication
- Correct IAM scoping (per-secret, not project-level)
- Pydantic settings that work identically locally and in production
- Dockerfile that follows best practices
One-time purchase. Private repo collaborator access.
→ FastAPI + Cloud Run Starter on Gumroad
KDRA Inc. builds software products with AI agents and hyperautomation. This starter is something we built for ourselves and packaged for others.
Top comments (0)