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
- Go to Cloud Build → Triggers
- Click "Create Trigger"
- Connect your backend repository (GitHub, GitLab, etc.)
- 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
- Under Substitution variables, add:
- Variable:
_FRONTEND_URL - Value:
https://your-frontend-xxxxx.run.app
- Variable:
- Save the trigger
5.2 Create Frontend Trigger
- Go to Cloud Build → Triggers
- Click "Create Trigger"
- Connect your frontend repository
- 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
- Under Substitution variables, add:
- Variable:
_NEXT_PUBLIC_BACKEND_URL - Value:
https://your-backend-xxxxx.run.app
- Variable:
- 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_URLis set correctly in the Cloud Build trigger - Check that
cloudbuild.yamlpasses 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
PORTfrom environment:port = int(os.getenv("PORT", "8000")) - Cloud Run sets
PORT=8080by default - Make sure you're binding to
0.0.0.0, not127.0.0.1
Issue 3: CORS Errors
Problem: Browser blocks requests from frontend to backend due to CORS policy.
Solution:
- Set
BACKEND_CORS_ORIGINSin 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:
- Next.js build-time variables:
NEXT_PUBLIC_*must be passed as Docker build arguments, not runtime environment variables - FastAPI port: Read
PORTfrom environment, default to 8000 for local dev - CORS: Configure backend to accept requests from frontend's Cloud Run URL
- Cloud Build triggers: Use substitution variables to pass URLs between services
- 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
- Google Cloud Run Quickstart
- Cloud Build Configuration
- Next.js Environment Variables
- FastAPI Deployment
Happy deploying! 🚀
Post a Comment