Deploying Your SaaS MVP: Next.js Frontend + FastAPI Backend to Google Cloud Run

Deploying Your SaaS MVP: Next.js Frontend + FastAPI Backend to Google Cloud Run



Introduction: So you've built your SaaS MVP with a modern microservices architecture - a Next.js frontend in one repo and a FastAPI/Uvicorn backend in another. Now you want to deploy both to Google Cloud Run for a scalable, serverless solution. This guide covers everything you need to know, from Dockerfiles to environment variables to Cloud Build triggers.

🏗️ Architecture Overview

For this deployment, we'll assume:

  • Frontend Repo: Next.js application
  • Backend Repo: FastAPI/Uvicorn application
  • Deployment Target: Google Cloud Run (separate services)
  • Communication: Frontend calls backend via HTTPS

⚠️ Critical Concepts Before We Start

1. Next.js Environment Variables (Build-Time vs Runtime)

THE MOST IMPORTANT THING: Next.js requires NEXT_PUBLIC_* variables to be available at BUILD TIME, not runtime. This is because Next.js embeds these values into the compiled JavaScript during npm run build.

❌ Wrong: Setting NEXT_PUBLIC_BACKEND_URL as a runtime environment variable in Cloud Run

✅ Correct: Passing it as a build argument to Docker during the build process

2. FastAPI Port Configuration

Cloud Run automatically sets the PORT environment variable (usually 8080). Your FastAPI app must read this variable, not hardcode a port.

3. CORS Configuration

Your backend needs to allow requests from your frontend's Cloud Run URL. This must be configurable via environment variables.

📁 Step 1: Backend Setup (FastAPI)

1.1 Create Backend Dockerfile

# backend/Dockerfile
FROM python:3.11-slim

WORKDIR /app

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

# Copy application code
COPY . .

# Cloud Run provides PORT env var (defaults to 8080)
# Your app should read this, not hardcode a port
ENV PORT=8080

# Expose the port (Cloud Run will map this)
EXPOSE $PORT

# Run as non-root user for security
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

# Start the server
CMD ["python", "start_server.py"]

1.2 Update Your FastAPI Start Script

# backend/start_server.py
import os
import uvicorn

if __name__ == "__main__":
    # Cloud Run sets PORT env var, default to 8000 for local dev
    port = int(os.getenv("PORT", "8000"))
    uvicorn.run(
        "main:app",
        host="0.0.0.0",
        port=port,
        reload=False
    )

1.3 Configure CORS in FastAPI

# backend/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import os

app = FastAPI()

# Read CORS origins from environment (comma-separated)
cors_origins = os.getenv(
    "BACKEND_CORS_ORIGINS",
    "http://localhost:3000,http://127.0.0.1:3000"
)
allowed_origins = [origin.strip() for origin in cors_origins.split(",") if origin.strip()]

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get("/health")
async def health():
    return {"status": "ok"}

# ... rest of your routes

1.4 Create Backend cloudbuild.yaml

# backend/cloudbuild.yaml
steps:
  # Build the Docker image
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - 'gcr.io/$PROJECT_ID/your-backend:$COMMIT_SHA'
      - '-t'
      - 'gcr.io/$PROJECT_ID/your-backend:latest'
      - '.'

  # Push to Container Registry
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/your-backend:$COMMIT_SHA']

  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/your-backend:latest']

  # Deploy to Cloud Run
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'your-backend'
      - '--image'
      - 'gcr.io/$PROJECT_ID/your-backend:$COMMIT_SHA'
      - '--region'
      - 'us-central1'
      - '--platform'
      - 'managed'
      - '--allow-unauthenticated'
      - '--port'
      - '8080'
      - '--set-env-vars'
      - 'BACKEND_CORS_ORIGINS=${_FRONTEND_URL},http://localhost:3000'

# Images to push
images:
  - 'gcr.io/$PROJECT_ID/your-backend:$COMMIT_SHA'
  - 'gcr.io/$PROJECT_ID/your-backend:latest'

# Default substitution variables
substitutions:
  _FRONTEND_URL: 'https://your-frontend-xxxxx.run.app'

# Options
options:
  logging: CLOUD_LOGGING_ONLY

📁 Step 2: Frontend Setup (Next.js)

2.1 Create Frontend Dockerfile (CRITICAL: Build-Time Variables)

# frontend/Dockerfile
FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# ⚠️ CRITICAL: NEXT_PUBLIC_* variables must be available at BUILD TIME
# This ARG is passed from Cloud Build
ARG NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL

# Build the application (this is when NEXT_PUBLIC_* vars are embedded)
RUN npm run build

# Production image
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Copy built application
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

# Use configurable port
ARG PORT=3000
EXPOSE $PORT
ENV PORT=$PORT
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

2.2 Configure Next.js for Standalone Output

// frontend/next.config.ts
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone', // Required for Docker
  // ... other config
};

export default nextConfig;

2.3 Create Frontend cloudbuild.yaml (Pay Attention!)

# frontend/cloudbuild.yaml
steps:
  # Build the Docker image with build arguments
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '--build-arg'
      # ⚠️ Pass NEXT_PUBLIC_BACKEND_URL as build argument
      # This comes from substitution variable _NEXT_PUBLIC_BACKEND_URL
      - 'NEXT_PUBLIC_BACKEND_URL=${_NEXT_PUBLIC_BACKEND_URL}'
      - '--build-arg'
      - 'PORT=3000'
      - '-t'
      - 'gcr.io/$PROJECT_ID/your-frontend:$COMMIT_SHA'
      - '-t'
      - 'gcr.io/$PROJECT_ID/your-frontend:latest'
      - '.'

  # Push to Container Registry
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/your-frontend:$COMMIT_SHA']

  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/your-frontend:latest']

  # Deploy to Cloud Run
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'your-frontend'
      - '--image'
      - 'gcr.io/$PROJECT_ID/your-frontend:$COMMIT_SHA'
      - '--region'
      - 'us-central1'
      - '--platform'
      - 'managed'
      - '--allow-unauthenticated'
      - '--port'
      - '3000'

# Default substitution variables
# ⚠️ This is the backend URL that will be embedded into Next.js build
substitutions:
  _NEXT_PUBLIC_BACKEND_URL: 'https://your-backend-xxxxx.run.app'

# Images to push
images:
  - 'gcr.io/$PROJECT_ID/your-frontend:$COMMIT_SHA'
  - 'gcr.io/$PROJECT_ID/your-frontend:latest'

# Options
options:
  machineType: 'E2_HIGHCPU_8'
  logging: CLOUD_LOGGING_ONLY

🔧 Step 3: Deploy Backend First

3.1 Deploy Backend

cd backend
gcloud builds submit --config cloudbuild.yaml \
  --substitutions=_FRONTEND_URL=https://your-frontend-xxxxx.run.app

Note: You'll get the backend URL after deployment. Copy it for the next step.

3.2 Get Backend URL

gcloud run services describe your-backend \
  --region us-central1 \
  --format 'value(status.url)'

You'll get something like: https://your-backend-xxxxx.run.app

🔧 Step 4: Deploy Frontend

4.1 Deploy with Backend URL

cd frontend
gcloud builds submit --config cloudbuild.yaml \
  --substitutions=_NEXT_PUBLIC_BACKEND_URL=https://your-backend-xxxxx.run.app

⚠️ CRITICAL: Replace https://your-backend-xxxxx.run.app with your actual backend URL from Step 3.2.

⚙️ Step 5: Set Up Cloud Build Triggers (Automatic Deployments)

5.1 Create Backend Trigger

  1. Go to Cloud Build → Triggers
  2. Click "Create Trigger"
  3. Connect your backend repository (GitHub, GitLab, etc.)
  4. Configure:
    • Name: deploy-backend
    • Event: Push to a branch (e.g., main)
    • Configuration: Cloud Build configuration file (yaml or json)
    • Location: Repository
    • Cloud Build configuration file: cloudbuild.yaml
  5. Under Substitution variables, add:
    • Variable: _FRONTEND_URL
    • Value: https://your-frontend-xxxxx.run.app
  6. Save the trigger

5.2 Create Frontend Trigger

  1. Go to Cloud Build → Triggers
  2. Click "Create Trigger"
  3. Connect your frontend repository
  4. Configure:
    • Name: deploy-frontend
    • Event: Push to a branch (e.g., main)
    • Configuration: Cloud Build configuration file (yaml or json)
    • Location: Repository
    • Cloud Build configuration file: cloudbuild.yaml
  5. Under Substitution variables, add:
    • Variable: _NEXT_PUBLIC_BACKEND_URL
    • Value: https://your-backend-xxxxx.run.app
  6. Save the trigger

⚠️ IMPORTANT: Make sure the substitution variable name matches exactly what's in your cloudbuild.yaml (with the underscore prefix).

🐛 Common Issues and Solutions

Issue 1: Frontend Still Shows localhost:8000

Problem: The frontend was built with the default backend URL instead of the Cloud Run URL.

Solution:

  • Verify the substitution variable _NEXT_PUBLIC_BACKEND_URL is set correctly in the Cloud Build trigger
  • Check that cloudbuild.yaml passes it as a build argument: --build-arg NEXT_PUBLIC_BACKEND_URL=${_NEXT_PUBLIC_BACKEND_URL}
  • Ensure the Dockerfile accepts it: ARG NEXT_PUBLIC_BACKEND_URL
  • Trigger a new build - the variable must be set at build time, not runtime

Issue 2: Backend Returns 502 Bad Gateway

Problem: FastAPI isn't listening on the correct port.

Solution:

  • Ensure your FastAPI start script reads PORT from environment: port = int(os.getenv("PORT", "8000"))
  • Cloud Run sets PORT=8080 by default
  • Make sure you're binding to 0.0.0.0, not 127.0.0.1

Issue 3: CORS Errors

Problem: Browser blocks requests from frontend to backend due to CORS policy.

Solution:

  • Set BACKEND_CORS_ORIGINS in backend's Cloud Run environment variables
  • Include both your frontend's Cloud Run URL and localhost for local testing
  • Format: https://your-frontend-xxxxx.run.app,http://localhost:3000

✅ Verification Checklist

After deployment, verify:

  • ✅ Backend health endpoint works: https://your-backend-xxxxx.run.app/health
  • ✅ Frontend loads: https://your-frontend-xxxxx.run.app
  • ✅ Frontend can call backend (check browser console for errors)
  • ✅ No CORS errors in browser console
  • ✅ Environment variables are set correctly (check Cloud Run console)

📝 Summary

Key takeaways for deploying a Next.js + FastAPI microservices architecture to Google Cloud Run:

  1. Next.js build-time variables: NEXT_PUBLIC_* must be passed as Docker build arguments, not runtime environment variables
  2. FastAPI port: Read PORT from environment, default to 8000 for local dev
  3. CORS: Configure backend to accept requests from frontend's Cloud Run URL
  4. Cloud Build triggers: Use substitution variables to pass URLs between services
  5. Deploy order: Deploy backend first, get its URL, then deploy frontend with that URL

With these configurations, every push to your main branch will automatically trigger a new deployment, making your CI/CD pipeline seamless and efficient!

🔗 Additional Resources

Happy deploying! 🚀