DEV Community

Developer KDRA Inc
Developer KDRA Inc

Posted on

Deploy FastAPI to Cloud Run in Under 30 Minutes — With Terraform, WIF, and Secret Manager

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
Enter fullscreen mode Exit fullscreen mode

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'"
}
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

For local development, create a .env file:

DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=local-dev-key
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)