Deploying Python FastAPI Application On GCP Cloud Run With Terraform

Terraform on Google Cloud V1.6 — Cloud Run

This is the 7th blog post on the series Terraform on Google Cloud, and in this blog, I will talk about Cloud Run, a managed GCP service to run your stateless, containerized workloads.

Before heading into writing Terraform code for Cloud Run, let me talk more about Cloud Run, why they are so good, and where using them makes more sense.

What is Cloud Run?

Cloud Run is a serverless compute platform provided by Google Cloud. It allows you to run containerized applications in a fully managed environment.

A Cloud Run service provides you with the infrastructure required to run a reliable HTTPS endpoint, given that your code listens on a TCP port and handles HTTP requests.

Cloud Run also supports event-driven architectures, allowing you to trigger containerized functions based on various event sources like HTTP requests, Pub/Sub messages, Cloud Storage events, and more.

Two ways to run your code on Cloud Run

Your code can either run continuously as a service or as a job.

  • Cloud Run services – Used to run code that responds to web requests, or events
  • Cloud Run jobs – Used to run code that performs work (a job) and quits when the work is done

Why we love Cloud Run?

You can deploy code written in any programming language on Cloud Run if you can build a container image from it.

If you do not want to build your own Docker image, and if you’re using Go, Node.js, Python, Java, .NET Core, or Ruby, you can use the source-based deployment option that builds the container for you.

Here are some best features of Cloud Run:

  • Unique HTTPS endpoint for every service – Every service is given an HTTPS endpoint on a unique subdomain of the *.run.app domain. You can configure custom domains as well
  • Fast request-based auto scaling – service can rapidly scale out to one thousand instances
  • Built-in traffic management – you can deploy multiple revisions of your service, route traffic to latest and can easily rollback
  • Private and public services – service can be reachable from the internet, or you can restrict access using IAP, IAM and Ingress

Where to use Cloud Run and where not to?

Good use cases:

  • Websites and web applications
  • APIs and microservices
  • Streaming data processing

Not ideal for:

  • Long-running processes that need persistent state
  • Applications that require local file storage

Let’s now deploy Cloud Run on GCP with Terraform

Below are the steps we’ll follow:

  1. Write a basic Python FastAPI app
  2. Dockerize the Python application
  3. Create an Artifact Registry for Docker images
  4. Push the Docker image to Artifact Registry
  5. Enable Cloud Run API with Terraform
  6. Write Terraform configuration for Cloud Run
  7. Deploy the app on Cloud Run

Prerequisites

I assume that you already have a Google Cloud Platform (GCP) project created and that you have the owner or editor role assigned to that project.

You also need gcloud CLI installed on your machine.

For the purposes of this demonstration, we will use basic roles, although it is recommended to use a predefined role that is suitable for your specific use case or create a custom role with the necessary permissions.

Getting started

Since we already have a well-structured Terraform project from the previous parts, we’ll add Cloud Run resources to our existing setup.

Create the application structure:

# Create application directory structure
mkdir -p app/src
touch app/src/main.py app/src/requirements.txt
touch app/Dockerfile

Let’s create the basic FastAPI Python application

Create the FastAPI application

Paste the below code to app/src/main.py:

import os
from fastapi import FastAPI

app = FastAPI(title="Terraform GCP API", version="1.0.0")

# Get configuration from environment variables
PROJECT_ID = os.environ.get('PROJECT_ID', 'your-project-id')
ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev')

@app.get("/")
def read_root():
    return {
        "message": "Hello from Cloud Run!",
        "project_id": PROJECT_ID,
        "environment": ENVIRONMENT
    }

@app.get("/health")
def health_check():
    return {"status": "healthy"}

@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {
        "item_id": item_id, 
        "q": q,
        "environment": ENVIRONMENT
    }

if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 8080))
    uvicorn.run(app, host="0.0.0.0", port=port)

Define Python dependencies

Paste below Python dependencies into app/src/requirements.txt:

fastapi==0.104.1
uvicorn[standard]==0.24.0

Create the Dockerfile

Paste the below code in app/Dockerfile:

# Use an official Python runtime as the base image
FROM python:3.11-slim

# Set the working directory in the container
WORKDIR /app

# Copy requirements first for better Docker layer caching
COPY src/requirements.txt .

# Install the Python dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code to the container
COPY src/ .

# Expose the port on which the application will run
EXPOSE 8080

# Set the command to run the application when the container starts
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Create Artifact Registry with Terraform

Instead of using gcloud commands, let’s create everything with Terraform so we have infrastructure as code.

First, let’s understand what APIs we need and why.

Step 1: Enable required APIs

Create a new file cloud-run.tf and add this code:

# We need to enable APIs before we can use any GCP services
# This is like turning on the services in Google Cloud Console

resource "google_project_service" "artifact_registry_api" {
  service = "artifactregistry.googleapis.com"
  
  # This prevents destroying dependent services when we destroy this resource
  disable_dependent_services = true
  # Keep the API enabled even if we destroy this Terraform resource
  disable_on_destroy = false
}

resource "google_project_service" "cloud_run_api" {
  service = "run.googleapis.com"
  
  disable_dependent_services = true
  disable_on_destroy = false
}

What’s happening here?

  • google_project_service is a Terraform resource that enables APIs in your GCP project
  • We need Artifact Registry API to store our Docker images
  • We need Cloud Run API to deploy our containerized application
  • disable_on_destroy = false means even if we delete this Terraform config, the APIs stay enabled

Step 2: Create Artifact Registry repository

Add this code to your cloud-run.tf:

# Artifact Registry is like a secure warehouse for your Docker images
# Think of it as Docker Hub but private and integrated with GCP

resource "google_artifact_registry_repository" "docker_repo" {
  location      = var.region
  repository_id = "docker-repo"
  description   = "Docker repository for our applications"
  format        = "DOCKER"
  
  # This creates an implicit dependency - Terraform will create the API first
  depends_on = [google_project_service.artifact_registry_api]
}

What’s happening here?

  • google_artifact_registry_repository creates a private Docker registry
  • format = "DOCKER" tells GCP this will store Docker images (not Maven jars or npm packages)
  • depends_on is an explicit dependency – Terraform waits for the API to be enabled first
  • Without depends_on, Terraform would try to create the repository and API at the same time, which would fail

Step 3: Create a service account for Cloud Run

Add this to cloud-run.tf:

# Service accounts are like robot users that applications use to authenticate
# Instead of hardcoding credentials, we give our app an identity

resource "google_service_account" "cloud_run_sa" {
  account_id   = "cloud-run-sa"
  display_name = "Cloud Run Service Account"
  description  = "Service account for Cloud Run services"
}

# Grant the service account permission to write logs
# Without this, we won't see any logs from our application
resource "google_project_iam_member" "cloud_run_logging" {
  role   = "roles/logging.logWriter"
  member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

What’s happening here?

  • Every Cloud Run service needs a service account to run
  • Service accounts follow the principle of least privilege
  • roles/logging.logWriter allows our app to send logs to Cloud Logging
  • We use ${google_service_account.cloud_run_sa.email} to reference the email from the service account we created above

Build and push the Docker image

Before we can deploy to Cloud Run, we need to build and push our Docker image.

# Deploy the infrastructure first to create Artifact Registry
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan
terraform apply tfplan

# Get the registry URL - this is where we'll push our image
REGISTRY_URL="${var.region}-docker.pkg.dev/${var.project_id}/docker-repo"

# Authenticate Docker with our private registry
gcloud auth configure-docker ${var.region}-docker.pkg.dev

# Build the Docker image
cd app
docker build -t ${REGISTRY_URL}/fastapi-app:latest .

# Push the image to our private registry
docker push ${REGISTRY_URL}/fastapi-app:latest

cd ..

Create the Cloud Run service

Now let’s create the actual Cloud Run service. Add this to your cloud-run.tf:

# Cloud Run service is the main resource that runs our containerized application
# It's like a managed Kubernetes deployment but simpler

resource "google_cloud_run_service" "api_service" {
  name     = "fastapi-app"
  location = var.region

  template {
    # Metadata contains configuration about the service
    metadata {
      annotations = {
        # These annotations control scaling behavior
        "autoscaling.knative.dev/minScale" = "0"  # Scale to zero when no traffic
        "autoscaling.knative.dev/maxScale" = "10" # Maximum 10 instances
        "run.googleapis.com/client-name"   = "terraform"
      }
    }
    
    spec {
      # Tell Cloud Run which service account our container should use
      service_account_name = google_service_account.cloud_run_sa.email
      
      containers {
        # This is the Docker image we built and pushed earlier
        image = "${var.region}-docker.pkg.dev/${var.project_id}/docker-repo/fastapi-app:latest"
        
        # Environment variables that our FastAPI app can read
        env {
          name  = "PROJECT_ID"
          value = var.project_id
        }
        
        env {
          name  = "ENVIRONMENT" 
          value = var.environment
        }
        
        env {
          name  = "PORT"
          value = "8080"
        }
        
        # Resource limits prevent runaway costs
        resources {
          limits = {
            cpu    = "1000m"  # 1 CPU core
            memory = "512Mi"  # 512 MB RAM
          }
        }
        
        # Health check endpoint
        liveness_probe {
          http_get {
            path = "/health"
            port = 8080
          }
        }
      }
    }
  }

  # Traffic configuration - 100% traffic goes to latest revision
  traffic {
    percent         = 100
    latest_revision = true
  }

  # Wait for the Cloud Run API to be enabled
  depends_on = [google_project_service.cloud_run_api]
}

What’s happening here?

  • google_cloud_run_service is the main resource that creates our web service
  • template defines how our containers should run (like a Kubernetes Pod template)
  • service_account_name tells Cloud Run which identity our app should use
  • env blocks set environment variables that our Python app can read with os.environ.get()
  • resources.limits prevent our app from using too much CPU/memory and running up costs
  • liveness_probe tells Cloud Run how to check if our app is healthy
  • traffic controls which revision of our app gets traffic (useful for blue-green deployments)

Make the service publicly accessible

By default, Cloud Run services are private. Let’s make ours public for this demo:

# IAM policy that allows anyone on the internet to call our service
# In production, you'd want to restrict this to specific users or service accounts

resource "google_cloud_run_service_iam_member" "public_access" {
  service  = google_cloud_run_service.api_service.name
  location = google_cloud_run_service.api_service.location
  role     = "roles/run.invoker"
  member   = "allUsers"
}

What’s happening here?

  • google_cloud_run_service_iam_member grants permissions to invoke (call) our service
  • roles/run.invoker is the minimum permission needed to send HTTP requests to Cloud Run
  • member = "allUsers" means anyone on the internet can access our service
  • In production, you’d use serviceAccount:name@project.iam.gserviceaccount.com instead

Add outputs to see our service URL

Add this to your outputs.tf:

# Output the URL where our service is running
output "cloud_run_service_url" {
  value       = google_cloud_run_service.api_service.status[0].url
  description = "URL of the deployed Cloud Run service"
}

# Output the registry URL for future image pushes
output "docker_registry_url" {
  value       = "${var.region}-docker.pkg.dev/${var.project_id}/docker-repo"
  description = "URL of the Docker registry"
}

What’s happening here?

  • google_cloud_run_service.api_service.status[0].url gets the HTTPS URL that Google assigned to our service
  • This URL will look like https://fastapi-app-xyz123-uc.a.run.app
  • The [0] is needed because status is an array (list) in Terraform

Deploy the Cloud Run resources

# Apply our Terraform configuration
terraform apply -auto-approve

In the end, you will see the Terraform output:

cloud_run_service_url = "https://fastapi-app-xyz123-uc.a.run.app"

You can also get the URL using this gcloud command:

gcloud run services describe fastapi-app --region=us-central1 --format='value(status.url)'

Test your deployment

Copy the service URL from the output and test it:

# Get the URL from Terraform output
SERVICE_URL=$(terraform output -raw cloud_run_service_url)

# Test the endpoints
curl $SERVICE_URL/
curl $SERVICE_URL/health
curl $SERVICE_URL/items/123?q=test

You should see responses like:

{
  "message": "Hello from Cloud Run!",
  "project_id": "your-project-id", 
  "environment": "dev"
}

Understanding Terraform dependencies

In this tutorial, we used both implicit and explicit dependencies:

Implicit dependencies (Terraform figures these out automatically):

  • Cloud Run service references the service account email
  • IAM member references the service name
  • Terraform knows to create the service account before the service

Explicit dependencies (we tell Terraform with depends_on):

  • Repository depends on Artifact Registry API
  • Cloud Run service depends on Cloud Run API
  • We use these when Terraform can’t figure out the dependency automatically

Why dependencies matter: Without proper dependencies, Terraform might try to create the Cloud Run service before the API is enabled, which would fail.

What we accomplished

In this blog, we successfully:

Created a FastAPI application with health checks and environment configuration
Set up Artifact Registry with Terraform instead of gcloud commands
Deployed Cloud Run service with proper scaling and resource limits
Used service accounts for secure authentication
Made the service publicly accessible with IAM policies
Learned about Terraform dependencies and how they work

The beauty of using Terraform is that everything is defined as code. If we need to create the same setup in another project or environment, we just change the variables and run terraform apply.

Thanks for reading. See you at the next one!


This is all for this blog post. If you found it useful, do clap, comment, follow, and subscribe. See you at the next one.


Connect with me:

Tags: #GoogleCloud #CloudRun #Terraform #FastAPI #Containerization #DevOps #Infrastructure

Akhilesh Mishra

Akhilesh Mishra

I am Akhilesh Mishra, a self-taught Devops engineer with 11+ years working on private and public cloud (GCP & AWS)technologies.

I also mentor DevOps aspirants in their journey to devops by providing guided learning and Mentorship.

Topmate: https://topmate.io/akhilesh_mishra/