Skip to content

6 Lessons Learned Deploying FastAPI to Google Cloud Run

I built starter-fastapi, a deployment-ready starter template for bootstrapping high-performance Python APIs. As developers, we often spend the first few days of a new project just wiring things together—setting up the database, configuring the linter, wrangling Docker, and figuring out the CI/CD pipeline.

My goal: move faster and save tokens by not having AI assistants recreate boilerplate from scratch on every project. The result is a template pre-packaged with modern best practices like SQLModel, uv, Ruff, and a complete, automated deployment pipeline.

Why Google Cloud Run?

I could have simplified this by deploying to Vercel, Render, or other platform-as-a-service providers. I chose Google Cloud Run because it aligns with my long-term goals: automating deployments of MCP servers and AI agents to Cloud Run and Vertex AI Agent Engine. The ultimate objective is building a workflow where agents can automatically create and deploy new agents.

Similarly, Cloud Run can deploy directly from GitHub repositories. I chose GitHub Actions to gain hands-on experience with CI/CD automation, which will be essential for more complex agent deployment workflows.

While the local development experience was smooth, automating the deployment to Google Cloud Run using GitHub Actions revealed several nuances. It's one thing to deploy from your laptop; it's significantly more complex to configure automated deployments that run securely and reliably. Here are the 6 key lessons I learned during the process, which I hope will save you some debugging time.

The Tech Stack: Speed and Precision

This project isn't just another "Hello World" example. I chose a stack that prioritizes developer experience and performance:

  • FastAPI: The de-facto standard for modern Python APIs, offering high performance and automatic documentation.
  • SQLModel: Developed by the creator of FastAPI, it bridges the gap between SQL databases and Python objects, allowing you to use the same models for both your database schema and your API validation.
  • uv: A game-changer in the Python ecosystem. It's an incredibly fast package installer and resolver that replaces the slow legacy chain of pip + pip-tools + virtualenv. Environment setup goes from minutes to seconds. This sets a new standard for developer experience.
  • Ruff: A lightning-fast linter and formatter written in Rust. It replaces the entire legacy toolchain of Flake8, Black, and isort into a single, unified tool. What used to take seconds now takes milliseconds.
  • Docker: Ensures that the application runs exactly the same way in production as it does on your local machine.
  • src/ Layout: Uses the gold standard package structure (src/starter_fastapi/) that prevents import errors and makes the project properly installable as a package.
  • structlog: Structured logging from the start. This is what separates a toy project from a production-ready service—consistent, parseable logs that integrate with observability platforms.

These choices aren't just about speed—they represent a paradigm shift in Python tooling toward modern, production-grade development practices.

The Deployment Architecture

The goal was simple: git push should result in a deployed app.

The pipeline is defined in .github/workflows/google-cloudrun-docker.yml. It uses workflow_dispatch for manual triggering rather than automatic deployment on every push. This design choice is intentional for a starter template—it lets users opt-in to deployment rather than failing automatically if secrets aren't configured.

The workflow runs the test suite, builds the Docker image, pushes it to the Google Artifact Registry, and finally deploys the new revision to Cloud Run.

CI/CD Philosophy: Automation Over Local Scripts

Do not rely on local deploy.sh scripts. They lack audit trails and reproducibility.

The Hybrid Approach:

  • Use local scripts only for one-time infrastructure setup (creating resources, enabling APIs)
  • Let GitHub Actions handle all software deployments
  • Manual triggers (workflow_dispatch) are ideal for starter templates—users control when to deploy

You can check out the full repository and the DEPLOYMENT.md guide for details.

Lessons Learned

1. Service Accounts are not "First Class Citizens"

Lesson: Google Cloud treats humans and automated systems (Service Accounts) differently.

When you run commands locally using your personal credentials (gcloud auth login), Google often performs "magic" on your behalf. For example, if you try to push an image to a registry that doesn't exist, the CLI might prompt you to create it. Or if an API is disabled, it might offer to enable it for you.

However, when your GitHub Action runs with a Service Account, Google is strict and unforgiving. It fails immediately if resources don't exist or permissions aren't explicitly granted.

What happened: My CI/CD pipeline failed immediately because the underlying resources (like the Artifact Registry repository) didn't exist, even though gcloud run deploy had worked fine from my laptop previously.

Takeaway: You must explicitly create your infrastructure and enable APIs before your automation tries to use them. This "Infrastructure as Code" mindset is crucial.

Infrastructure Setup
# Explicitly create the "closet" for your images
gcloud artifacts repositories create cloud-run-source-deploy \
    --repository-format=docker \
    --location=$REGION \
    --description="Docker repository for Cloud Run" \
    --project=$PROJECT_ID

# Explicitly enable the necessary APIs
gcloud services enable \
  artifactregistry.googleapis.com \
  run.googleapis.com \
  cloudbuild.googleapis.com

2. The "Act As" Permission is Counter-Intuitive

Lesson: Being an "Admin" isn't enough to deploy code. You need the "Three Keys."

You might think that granting roles/run.admin to your service account is sufficient. Logically, an admin should be able to do anything, right? Wrong.

In Google Cloud IAM, deploying a containerized service requires three specific permissions:

  1. artifactregistry.writer: Permission to store the Docker image ("the box")
  2. run.admin: Permission to update the Cloud Run service configuration
  3. iam.serviceAccountUser: Permission to assign identity to the running service

The third one is the counter-intuitive gotcha. When you deploy a service to Cloud Run, that service itself runs under an identity (usually the default Compute Engine service account). Your deployment Service Account needs permission to assign that identity to the new service.

What happened: Even with run.admin privileges, the deployment failed with a permission error. The error message was cryptic, but essentially, the Service Account wasn't allowed to "act as" the runtime service account.

Takeaway: The roles/iam.serviceAccountUser role acts as a "baton pass," allowing the deployment Service Account to say, "I authorize this new Cloud Run service to run as the Compute Engine default service account." Without this, the Service Account can manage the service definition but cannot launch it.

Simplicity vs. Security

While Workload Identity Federation is the most secure method (no long-lived keys), Service Account Keys are significantly easier to set up for a starter template. For learning and initial setup, the JSON key approach is acceptable—just follow proper key hygiene.

3. Cloud Build is "Old School" by Default

Lesson: Your local Docker is often smarter than the default cloud builder.

What happened: My Dockerfile used modern BuildKit features to optimize the build process. Specifically, I used RUN --mount=type=cache to cache pip dependencies between builds. This drastically speeds up builds by not re-downloading packages if requirements.txt hasn't changed.

While this worked perfectly locally, the gcloud builds submit command crashed. It turns out the default builder in Google Cloud uses an older Docker daemon configuration that doesn't support these advanced syntax features out of the box.

Takeaway: You have two options: 1. Simplify: Remove BuildKit features (like caching) from your Dockerfile. 2. Configure: Create a complex cloudbuild.yaml to explicitly enable BuildKit.

For this starter kit, I opted for a compatible Dockerfile to keep the deployment simple and universally compatible, trading a few seconds of build time for robustness.

4. The Port 8080 Default

Lesson: Platform defaults matter—either follow them or configure explicitly.

What happened: My FastAPI app was configured to run on port 8000, which is the standard default for most Python web frameworks. Cloud Run deployed the container successfully, but then immediately killed it. The logs showed that the health check failed.

Why? Because Cloud Run checks port 8080 by default to see if the container is alive. Since my app was listening on 8000, Cloud Run assumed the app was dead.

Takeaway: You have two choices: either match the platform's default port (8080) or explicitly configure the deployment to use your preferred port. For a starter template, sticking with conventions reduces configuration overhead.

Update your Dockerfile to use port 8080:

CMD ["uvicorn", "starter_fastapi.main:app", "--host", "0.0.0.0", "--port", "8080"]

Keep your app on port 8000 and tell Cloud Run explicitly:

gcloud run deploy myapp --port 8000

Or use the PORT environment variable in your Dockerfile:

CMD ["uvicorn", "starter_fastapi.main:app", "--host", "0.0.0.0", "--port", "$PORT"]

5. "Coat Check" Tagging

Lesson: Precision in naming is everything.

In a CI/CD pipeline, you often build an image in one step (the "Build" job) and deploy it in another (the "Deploy" job). You need a reliable way to hand off the artifact from one step to the next.

What happened: I initially considered just tagging the image as latest. The problem is concurrency. If two developers push code at the same time, Build A might finish, then Build B finishes and overwrites latest. Then Deploy A runs, but it accidentally deploys the code from Build B.

Takeaway: Using the Git commit SHA (${{ github.sha }}) is the best practice. It creates a unique, immutable link between your specific Git commit and the Docker image. It ensures that the code you reviewed in the Pull Request is exactly the code that ends up in production.

.github/workflows/deploy.yml
env:
  IMAGE_TAG: ${{ github.sha }}
# ...
steps:
  - name: Build
    run: docker build -t $REGISTRY/$IMAGE:$IMAGE_TAG .

  - name: Deploy
    run: gcloud run deploy ... --image $REGISTRY/$IMAGE:$IMAGE_TAG

6. Visibility is Key (The Logs)

Lesson: You cannot fix what you cannot see.

What happened: At one point, the GitHub Action crashed with a generic "Forbidden" error. It was frustratingly vague. It turned out the build process couldn't write logs to Google's default log bucket, effectively silencing the actual error details.

Takeaway: Creating your own GCS Log Bucket (--gcs-log-dir) gives you ownership of the data and allows you to stream logs directly into GitHub Actions. This prevents the need to tab-switch between GitHub and Google Cloud Console to debug build failures. It puts the error message right where you need it.

The Probe Configuration Challenge

One of the trickier parts of the configuration involved Liveness and Startup probes. These are mechanisms Cloud Run uses to manage the lifecycle of your container.

  • Startup Probe: Tells Cloud Run, "I'm done booting up, you can send traffic now."
  • Liveness Probe: Tells Cloud Run, "I'm still healthy, please don't kill me."

If you don't configure these, Cloud Run guesses. But if your app takes 5 seconds to connect to the database, and Cloud Run gives up after 3 seconds, your deployment will fail.

I had to carefully tune the initialDelaySeconds to give the application enough breathing room to establish its database connections before the probes started pinging it. If you see your container starting and then immediately restarting in a loop, check your probe settings first!

Security Note: Keys vs. Federation

Security Best Practice

The current workflow in starter-fastapi uses a long-lived Service Account Key (GCP_SA_KEY) stored in GitHub Secrets. This is done for simplicity to help users get started quickly.

However, for a true production environment, the gold standard is Workload Identity Federation. This allows GitHub Actions to request a short-lived access token from Google Cloud without ever needing to store a permanent secret key. It eliminates the risk of a leaked key compromising your infrastructure. I plan to add a guide for this in a future update to the starter kit.

Key Hygiene Best Practices

When working with Service Account Keys:

1. Use Repository Secrets, Not Environment Secrets

Environment Secrets are not available in private repositories on the GitHub Free plan. Using Repository Secrets ensures your template works for everyone.

2. Secure Key Handling with GitHub CLI

The safest way to upload a multiline JSON key is piping it directly:

gh secret set GCP_SA_KEY < key.json

This avoids copy-paste errors, keeps the key off your clipboard, and ensures proper formatting.

3. Delete Local Keys Immediately

Once uploaded to GitHub Secrets, delete the local key.json file. Never commit it to git, and don't leave it sitting in your Downloads folder.

# Upload and immediately delete
gh secret set GCP_SA_KEY < key.json && rm key.json

Future Enhancements

This template is designed to evolve. Planned additions include:

  • Authentication: API key-based authentication and OAuth2 integration for production-ready security
  • Workload Identity Federation: Replace service account keys with the more secure WIF approach
  • Database migrations: Automated Alembic migration workflows in CI/CD
  • Observability: Integration with Google Cloud Monitoring and structured logging dashboards

Contributions and feature requests are welcome!

Conclusion

Deploying to Cloud Run via GitHub Actions is a powerful setup, but it requires understanding how automated systems interact with IAM permissions and resource management. It forces you to be explicit about things you might take for granted locally.

By explicitly defining infrastructure, aligning ports, ensuring log visibility, and understanding the security model, you can build a robust deployment pipeline that scales with your team.

This project is part of my broader automation strategy: streamline deployments to move faster. Next, I'm working on automating MCP servers and AI agents to Cloud Run and Vertex AI Agent Engine. The ultimate goal is building a workflow where agents can automatically create and deploy new agents—closing the loop on autonomous AI development.

Check out starter-fastapi to see these lessons implemented in code, and feel free to fork it for your next project!