How to deploy an Nginx frontend and a FastAPI backend with Cloudflare Turnstile CAPTCHA, Firestore, and custom domain mapping on Google Cloud Run.
Live site: fadethepublic.com | API: api.fadethepublic.com
The Problem Link to heading
I have a simple need: my web app at fadethepublic.com needs to process “Request Early Access” form submissions to test an MVP idea. The solution has to be secure, cheap, and ready to scale when the idea takes off. I went with Google Cloud because it’s cheaper and needs less configuration than AWS.
Google Cloud Architecture Link to heading
A + AAAA"] D2["api.fadethepublic.com
CNAME → ghs.googlehosted.com"] end subgraph GCR["Google Cloud Run · managed TLS + routing"] subgraph SVC1["fadethepublic-web · nginx"] direction LR AS1["Autoscaler
min 0 / max 3"] I1["instance"] I1Z["💤 scaled to zero
(no traffic, no cost)"] AS1 -->|"request"| I1 AS1 -.->|"idle"| I1Z end subgraph SVC2["fadethepublic-api · FastAPI"] direction LR AS2["Autoscaler
min 0 / max 1"] I2["instance"] I2Z["💤 scaled to zero
(no traffic, no cost)"] AS2 -->|"request"| I2 AS2 -.->|"idle"| I2Z end end FS[("Firestore")] D1 -->|"domain mapping"| SVC1 D2 -->|"domain mapping"| SVC2 SVC2 -->|"writes submissions"| FS
The Setup Link to heading
The setup is straightforward:
- Frontend: An Nginx container serving the static site, deployed to Cloud Run and mapped to
fadethepublic.com. - Backend: A FastAPI service deployed to Cloud Run and mapped to
api.fadethepublic.com. - Database: Google Firestore (NoSQL) for storing form submissions.
- CAPTCHA: Cloudflare Turnstile to block bots — free and privacy-friendly.
The frontend sends a POST request to api.fadethepublic.com/early-access-form. The backend validates the payload, verifies the Turnstile token, writes to Firestore, and returns a response.
Both services use minimal resources — the frontend runs on 128Mi memory and the backend on 256Mi. Both scale to zero when idle, keeping costs near nothing for a pre-launch form.
Why FastAPI? Link to heading
I go with Python and FastAPI for a few reasons:
- Auto-generated Swagger docs — every endpoint gets interactive API documentation out of the box at
/docs. - Lightweight — minimal overhead, fast cold starts on Cloud Run.
- Pydantic integration — request validation is declarative and strict. If someone submits a malformed email, Pydantic catches it before your handler ever runs.
It’s the same backend I reached for in playcall, where FastAPI streams a text-to-SQL agent to the browser over server-sent events.
Step 1: Build the Nginx Web Container Link to heading
The frontend is a Next.js static export served by Nginx. The project lives in the web/ directory:
Project Structure Link to heading
web/
├── app/ # Next.js app directory
├── public/ # Static assets
├── Dockerfile
├── cloudbuild.yaml
├── deploy.sh
├── next.config.js
└── package.json
Dockerfile
Link to heading
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/out /usr/share/nginx/html
COPY --from=builder /app/public /usr/share/nginx/html
EXPOSE 8080
RUN sed -i 's/listen\(.*\)80;/listen 8080;/' /etc/nginx/conf.d/default.conf
This is a multi-stage build. The first stage builds the Next.js app with pnpm build, which outputs static files to /app/out. The second stage copies those files into a clean nginx:alpine image. The sed command rewrites Nginx to listen on port 8080 instead of 80 — Cloud Run expects your container to listen on 8080 by default.
The result is a tiny image that serves static HTML/CSS/JS with zero runtime overhead.
Step 2: Build the FastAPI Service Link to heading
Here’s a stripped-down version of the API. The key pieces are the form endpoint, Turnstile verification, rate limiting, and CORS.
Project Structure Link to heading
api/
├── app/ # FastAPI application
├── tests/ # Test suite
├── Dockerfile
├── cloudbuild.yaml
├── deploy.sh
└── requirements.txt
requirements.txt
Link to heading
fastapi
uvicorn[standard]
pydantic[email]
slowapi
httpx
python-dotenv
google-cloud-firestore
app/main.py
Link to heading
import os
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from google.cloud import firestore
app = FastAPI()
# --- CORS ---
# Required since the frontend (fadethepublic.com) calls the API (api.fadethepublic.com).
# Without this, the browser blocks the request.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://fadethepublic.com"],
allow_credentials=True,
allow_methods=["POST"],
allow_headers=["*"],
)
# --- Rate Limiting ---
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return {"error": "Too many requests. Slow down."}, 429
# --- Firestore Client ---
db = firestore.Client()
# --- Pydantic Model ---
class EarlyAccessForm(BaseModel):
name: str
email: EmailStr
company: str | None = None
turnstile_token: str
# --- Turnstile Verification ---
TURNSTILE_SECRET = os.environ.get("TURNSTILE_SECRET_KEY")
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
async def verify_turnstile(token: str) -> bool:
async with httpx.AsyncClient() as client:
resp = await client.post(TURNSTILE_VERIFY_URL, data={
"secret": TURNSTILE_SECRET,
"response": token,
})
return resp.json().get("success", False)
# --- Endpoints ---
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/early-access-form")
@limiter.limit("5/minute")
async def early_access(request: Request, form: EarlyAccessForm):
# Verify CAPTCHA
if not await verify_turnstile(form.turnstile_token):
raise HTTPException(status_code=400, detail="CAPTCHA verification failed.")
# Write to Firestore
doc_ref = db.collection("early_access_submissions").document()
doc_ref.set({
"name": form.name,
"email": form.email,
"company": form.company,
})
return {"message": "You're on the list."}
Key points:
- CORS middleware is critical here. The frontend at
fadethepublic.commakes cross-origin requests toapi.fadethepublic.com. Without the CORS config, browsers reject the preflightOPTIONSrequest and your form silently fails. - SlowAPI provides rate limiting keyed to the client IP — a simple layer of spam protection.
- Pydantic’s
EmailStrvalidates email format at the model level. No regex required.
Dockerfile
Link to heading
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
Cloud Run expects your container to listen on port 8080 by default.
Step 3: Set Up Firestore Link to heading
- Enable the Firestore API in your GCP project:
gcloud services enable firestore.googleapis.com
- Create a Firestore database (Native mode) if you haven’t already:
gcloud firestore databases create --location=us-central1
- Your Cloud Run service’s default service account already has Firestore access if it’s in the same project — no extra IAM config needed.
That’s it. The google-cloud-firestore Python client handles authentication automatically when running on GCP infrastructure.
Step 4: Set Up Cloudflare Turnstile Link to heading
Turnstile is Cloudflare’s free, privacy-first CAPTCHA alternative. No annoying image grids.
- Create a free account at cloudflare.com.
- Navigate to Turnstile and add a new widget.
- Configure the widget:
- Name:
fadethepublic.com-early-access - Hostname:
fadethepublic.com - Widget mode: Managed
- Pre-Clearance: No (not needed unless you’re using a Cloudflare proxy)
- Name:
- Once created, you get two keys:
- Site Key → used by the frontend (embedded in the Turnstile widget on your page).
- Secret Key → used by the FastAPI backend to verify tokens server-side.
Store the Secret Key in GCP Secret Manager Link to heading
Don’t hardcode secrets. Use Secret Manager:
# Enable the API
gcloud services enable secretmanager.googleapis.com
# Create the secret
echo -n "your-turnstile-secret-key" | gcloud secrets create TURNSTILE_SECRET_KEY --data-file=-
# Grant your Cloud Run service account access
gcloud secrets add-iam-policy-binding TURNSTILE_SECRET_KEY \
--member="serviceAccount:YOUR_PROJECT_NUMBER-compute@developer.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
Then reference it in your Cloud Run service configuration so it’s injected as the TURNSTILE_SECRET_KEY environment variable.
Step 5: Deploy to Cloud Run Link to heading
I use simple deploy.sh scripts that build via Cloud Build and deploy to Cloud Run. Here are simplified versions:
Deploy the Frontend (web/deploy.sh)
Link to heading
#!/bin/bash
PROJECT_ID="fadethepublic"
SERVICE_NAME="fadethepublic-web"
REGION="us-central1"
# Build & push image via Cloud Build
gcloud builds submit . \
--config cloudbuild.yaml
# Deploy to Cloud Run
gcloud run deploy $SERVICE_NAME \
--image gcr.io/$PROJECT_ID/$SERVICE_NAME \
--platform managed \
--region $REGION \
--allow-unauthenticated \
--memory 128Mi \
--cpu 1 \
--min-instances 0 \
--max-instances 3 \
--cpu-boost
Deploy the API (api/deploy.sh)
Link to heading
#!/bin/bash
PROJECT_ID="fadethepublic"
SERVICE_NAME="fadethepublic-api"
REGION="us-central1"
# Build & push image via Cloud Build
gcloud builds submit --tag gcr.io/$PROJECT_ID/$SERVICE_NAME .
# Deploy to Cloud Run
gcloud run deploy $SERVICE_NAME \
--image gcr.io/$PROJECT_ID/$SERVICE_NAME \
--platform managed \
--region $REGION \
--allow-unauthenticated \
--memory 256Mi \
--cpu 1 \
--min-instances 0 \
--max-instances 1 \
--cpu-boost \
--set-secrets "TURNSTILE_SECRET_KEY=TURNSTILE_SECRET_KEY:latest" \
--set-env-vars "ALLOWED_ORIGINS=https://fadethepublic.com,https://www.fadethepublic.com"
Key points:
--min-instances 0means the service scales to zero when idle — no cost when there’s no traffic.--cpu-boostgives extra CPU during cold starts, reducing latency for the first request.- The frontend gets 3 max instances while the API gets 1 — more than enough for a pre-launch MVP.
- The API injects the Turnstile secret via
--set-secretsso it’s never in source code. - Cloud Build handles the Docker build remotely on x86_64, so it works regardless of your local architecture (handy if you’re on an M-series Mac).
Verify the Deployment Link to heading
Hit the health endpoint on the auto-generated Cloud Run URL:
curl https://api.fadethepublic.com/health
# {"status":"ok"}
Step 6: Map Your Custom Domains Link to heading
You want fadethepublic.com and api.fadethepublic.com, not Cloud Run URLs with hashes in them.
Map the Frontend Link to heading
# Map the apex domain
gcloud run domain-mappings create \
--service fadethepublic-web \
--domain fadethepublic.com \
--region us-central1
# Map the www subdomain
gcloud run domain-mappings create \
--service fadethepublic-web \
--domain www.fadethepublic.com \
--region us-central1
Both fadethepublic.com and www.fadethepublic.com point to the same fadethepublic-web Nginx service on Cloud Run.
Map the API Link to heading
gcloud run domain-mappings create \
--service fadethepublic-api \
--domain api.fadethepublic.com \
--region us-central1
You can also do all of this through the Cloud Run console under Manage Custom Domains.
DNS Configuration Link to heading
Add these records with your DNS provider:
| Type | Name | Value |
|---|---|---|
| A | @ | (IP provided by GCP) |
| AAAA | @ | (IP provided by GCP) |
| CNAME | www | ghs.googlehosted.com. |
| CNAME | api | ghs.googlehosted.com. |
When you create domain mappings, GCP gives you the exact A/AAAA records for the apex domain and CNAME targets for subdomains. The apex domain (fadethepublic.com) requires A/AAAA records since CNAME records aren’t allowed at the zone root. The www and api subdomains both use CNAME records pointing to ghs.googlehosted.com.
After DNS propagation, verify:
# Frontend
curl https://fadethepublic.com
curl https://www.fadethepublic.com
# API
curl https://api.fadethepublic.com/health
# {"status":"ok"}
Recap Link to heading
Here’s what this sets up:
- FastAPI backend with Pydantic validation, SlowAPI rate limiting, and CORS.
- Cloudflare Turnstile for bot protection — site key on the frontend, secret key verified server-side.
- Firestore as a zero-config NoSQL store for form submissions.
- GCP Secret Manager to keep the Turnstile secret out of source code.
- Cloud Run deployment with simple
deploy.shscripts using Cloud Build. - Custom domain mapping so the frontend serves
fadethepublic.com+www.fadethepublic.comand the API lives atapi.fadethepublic.com.
The whole thing is serverless, scales to zero when idle, and costs next to nothing for a pre-launch form. Cloud Run handles TLS, scaling, and container orchestration — you just ship a Dockerfile.