<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Developer KDRA Inc</title>
    <description>The latest articles on DEV Community by Developer KDRA Inc (@kdra-dev).</description>
    <link>https://dev.to/kdra-dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3990011%2F0170e9d2-c4e7-4c02-88df-7f944fb74ad7.png</url>
      <title>DEV Community: Developer KDRA Inc</title>
      <link>https://dev.to/kdra-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kdra-dev"/>
    <language>en</language>
    <item>
      <title>Deploy FastAPI to Cloud Run in Under 30 Minutes — With Terraform, WIF, and Secret Manager</title>
      <dc:creator>Developer KDRA Inc</dc:creator>
      <pubDate>Thu, 18 Jun 2026 02:55:06 +0000</pubDate>
      <link>https://dev.to/kdra-dev/deploy-fastapi-to-cloud-run-in-under-30-minutes-with-terraform-wif-and-secret-manager-5h6i</link>
      <guid>https://dev.to/kdra-dev/deploy-fastapi-to-cloud-run-in-under-30-minutes-with-terraform-wif-and-secret-manager-5h6i</guid>
      <description>&lt;p&gt;Every Python API project on GCP starts the same way. Not with your business logic. With Terraform.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;We got tired of it. So we built it once, properly, and turned it into a starter template.&lt;/p&gt;

&lt;p&gt;This article walks through what's in the &lt;strong&gt;FastAPI + Cloud Run Starter&lt;/strong&gt; and, more importantly, &lt;em&gt;why&lt;/em&gt; each piece is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entire infrastructure is managed with Terraform. The application never knows where it's running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workload Identity Federation — stop using service account keys
&lt;/h2&gt;

&lt;p&gt;Most tutorials tell you to create a service account key JSON and paste it into GitHub Secrets. Don't.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Workload Identity Federation (WIF) lets GitHub Actions authenticate to GCP using a short-lived OIDC token. The trust relationship is configured in Terraform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_iam_workload_identity_pool"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;workload_identity_pool_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"github-pool"&lt;/span&gt;
  &lt;span class="nx"&gt;display_name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GitHub Actions Pool"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_iam_workload_identity_pool_provider"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;workload_identity_pool_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_iam_workload_identity_pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workload_identity_pool_id&lt;/span&gt;
  &lt;span class="nx"&gt;workload_identity_pool_provider_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"github-provider"&lt;/span&gt;

  &lt;span class="nx"&gt;oidc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;issuer_uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://token.actions.githubusercontent.com"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;attribute_mapping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"google.subject"&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"assertion.sub"&lt;/span&gt;
    &lt;span class="s2"&gt;"attribute.repository"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"assertion.repository"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;attribute_condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"assertion.repository == 'your-org/your-repo'"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In GitHub Actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Authenticate to GCP&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;google-github-actions/auth@v2&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workload_identity_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.WIF_PROVIDER }}&lt;/span&gt;
    &lt;span class="na"&gt;service_account&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.DEPLOY_SA }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key file. No secret to rotate. The token lasts for the duration of the workflow run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secret Manager → Cloud Run → Pydantic
&lt;/h2&gt;

&lt;p&gt;The pattern for secrets: they live in Secret Manager. Cloud Run mounts them as environment variables. Pydantic's &lt;code&gt;BaseSettings&lt;/code&gt; reads environment variables. Your application code never touches the Secret Manager SDK.&lt;/p&gt;

&lt;p&gt;Cloud Run secret binding in &lt;code&gt;gcloud run deploy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--set-secrets&lt;/span&gt; &lt;span class="s2"&gt;"DATABASE_URL=my-db-url:latest,API_KEY=my-api-key:latest"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pydantic settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SettingsConfigDict&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SettingsConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;database_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For local development, create a &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgresql://localhost:5432/mydb&lt;/span&gt;
&lt;span class="py"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;local-dev-key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same settings model. Different source. Local and production behave identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  IAM scoping — the mistake most tutorials make
&lt;/h2&gt;

&lt;p&gt;Your Cloud Run service account needs &lt;code&gt;roles/secretmanager.secretAccessor&lt;/code&gt;. But on &lt;em&gt;what&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;Most examples grant it at the project level. That's overpermissioned — your service account can read every secret in the project.&lt;/p&gt;

&lt;p&gt;The correct approach is to grant it per secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_secret_manager_secret_iam_member"&lt;/span&gt; &lt;span class="s2"&gt;"db_url_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_secret_manager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"roles/secretmanager.secretAccessor"&lt;/span&gt;
  &lt;span class="nx"&gt;member&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"serviceAccount:${google_service_account.cloud_run.email}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More Terraform to write, but the right security posture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI/CD pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.12"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install -e ".[dev]"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruff check .&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Authenticate to GCP&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;google-github-actions/auth@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;workload_identity_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.WIF_PROVIDER }}&lt;/span&gt;
          &lt;span class="na"&gt;service_account&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.DEPLOY_SA }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;IMAGE="${{ env.REGISTRY }}/my-service:${{ github.sha }}"&lt;/span&gt;
          &lt;span class="s"&gt;docker build -t "$IMAGE" .&lt;/span&gt;
          &lt;span class="s"&gt;docker push "$IMAGE"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;gcloud run deploy my-service \&lt;/span&gt;
            &lt;span class="s"&gt;--image "$IMAGE" \&lt;/span&gt;
            &lt;span class="s"&gt;--region us-central1 \&lt;/span&gt;
            &lt;span class="s"&gt;--set-secrets "API_KEY=my-api-key:latest" \&lt;/span&gt;
            &lt;span class="s"&gt;--allow-unauthenticated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests run on every push. Deployment runs only on &lt;code&gt;main&lt;/code&gt;. Image is tagged with the git SHA — full traceability.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the starter gives you
&lt;/h2&gt;

&lt;p&gt;All of the above, working together, from the first commit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI scaffold with health endpoint, structured logging, rate limiting&lt;/li&gt;
&lt;li&gt;Terraform modules for Cloud Run, Artifact Registry, IAM, Secret Manager&lt;/li&gt;
&lt;li&gt;GitHub Actions workflow with WIF authentication&lt;/li&gt;
&lt;li&gt;Correct IAM scoping (per-secret, not project-level)&lt;/li&gt;
&lt;li&gt;Pydantic settings that work identically locally and in production&lt;/li&gt;
&lt;li&gt;Dockerfile that follows best practices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One-time purchase. Private repo collaborator access.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://store.kdrainc.com/l/fastapi-cloudrun-starter" rel="noopener noreferrer"&gt;FastAPI + Cloud Run Starter on Gumroad&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;KDRA Inc. builds software products with AI agents and hyperautomation. This starter is something we built for ourselves and packaged for others.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>googlecloud</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
