Deploying Astro + FastAPI SaaS to Cloudflare: Zero-Downtime, Edge-First
I’ve deployed the same SaaS application three ways: traditional VPS, containerized on Vercel, and now edge-first on Cloudflare Workers. The third option—Cloudflare—is the one I actually keep coming back to. Not because it’s trendy, but because it solves real problems I didn’t know I had until I stopped fighting them.
Here’s the honest take: most SaaS deployments treat the edge as an afterthought. You build in the US, your users in Singapore wait 200ms just to get a response. With Cloudflare Workers + Astro, your HTML renders at the edge. With Workers, your FastAPI logic runs close to your data. That’s not optimization theater—that’s architecture.
The Problem With Traditional SaaS Deployment
When I first deployed CitizenApp on Vercel with a FastAPI backend on Render, everything worked. But “working” isn’t the same as “optimal.”
Cold starts on Render would spike to 800ms on quiet mornings. Vercel serverless functions had their own cold-start tax. More importantly, traffic routed through a single region. A user in Tokyo hitting my US-based FastAPI meant latency, period.
I also burned significant money on compute I didn’t use. My API served 80% read traffic—perfect for caching at the edge. Instead, I was routing everything through a “hot” server, paying for the privilege.
That’s when I realized: Cloudflare Workers isn’t just a CDN enhancement. It’s a deployment platform where your code lives closer to users by default. And Astro, with its partial hydration and edge-ready architecture, is the frontend equivalent.
Architecture: Astro at the Edge, FastAPI as Serverless
Here’s how I restructured CitizenApp:
Frontend (Astro on Cloudflare Pages):
- Static HTML generation with selective hydration
- Edge middleware for authentication and redirects
- Streaming responses for dynamic content
Backend (FastAPI on Cloudflare Workers via Wrangler):
- FastAPI running on Workers (yes, really)
- PostgreSQL on Neon for serverless postgres
- JWT tokens validated at the edge, before hitting origin
This eliminates three things I hated:
- Waiting for cold starts
- Debugging latency across regions
- Paying for idle compute
Getting Astro + Cloudflare Pages Live
First, the straightforward part. Astro’s Cloudflare integration is excellent.
// astro.config.ts
import { defineConfig } from 'astro/config';
import cloudflare from 'astro/integrations/cloudflare';
export default defineConfig({
integrations: [cloudflare()],
output: 'hybrid', // Critical: lets us use on-demand rendering
adapter: cloudflare({
mode: 'directory',
caching: {
default: 3600, // Cache static assets for 1hr
paths: ['/api/*'], // Don't cache API calls
},
}),
});
I use output: 'hybrid' because some pages (docs, marketing) are static at build time. Others (dashboard, user-specific content) need on-demand rendering. Astro handles this elegantly—you don’t pick one or the other globally.
Deploy via:
npm run build
npm run preview # Test locally
wrangler pages deploy dist/
Running FastAPI on Cloudflare Workers
This is where most guides stop. They don’t tell you that FastAPI works on Workers. It does.
Install dependencies:
pip install fastapi wrangler
Create your FastAPI app as normal:
# backend/main.py
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import jwt
from datetime import datetime, timedelta
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT"],
allow_headers=["Authorization", "Content-Type"],
)
SECRET_KEY = "your-jwt-secret"
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return payload
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@app.post("/api/auth/login")
async def login(email: str, password: str):
# Validate against your DB
token = jwt.encode(
{"sub": email, "exp": datetime.utcnow() + timedelta(hours=24)},
SECRET_KEY,
algorithm="HS256"
)
return {"access_token": token, "token_type": "bearer"}
@app.get("/api/user/profile")
async def get_profile(auth_header: str):
# Extract token from "Bearer <token>"
token = auth_header.replace("Bearer ", "")
user = verify_token(token)
return {"email": user["sub"], "joined": "2024-01-01"}
Now, expose it to Workers. Create a wrapper:
# backend/worker.py
from fastapi.responses import Response
from main import app
async def handle(request):
"""Cloudflare Workers entrypoint"""
scope = {
"type": "http",
"method": request.method,
"path": request.url.path,
"query_string": request.url.query.encode(),
"headers": request.headers.raw,
}
body = await request.body()
# Call FastAPI's ASGI interface
response = Response()
await app(scope, receive=lambda: {"body": body}, send=response.send)
return response
Actually, for production I prefer a cleaner approach using wrangler-python:
# wrangler.toml
name = "citizenapp-api"
type = "python"
compatibility_date = "2024-01"
env_vars = ["ALLOWED_ORIGINS"]
[[kv_namespaces]]
binding = "CACHE"
id = "abc123"
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "citizenapp-uploads"
# src/index.py
from fastapi import FastAPI
from functions.auth import router as auth_router
app = FastAPI()
app.include_router(auth_router, prefix="/api")
@app.get("/health")
def health():
return {"status": "ok"}
Deploy with:
wrangler deploy
Zero-Downtime Updates
The real win: you never restart your origin.
When you deploy a new version to Cloudflare Pages or Workers, traffic immediately routes to the new code. No health checks, no rolling restarts, no load balancer grace periods. Cloudflare’s edge caches intelligently—stale content is served while new content propagates.
For database migrations, I use environment-based feature flags:
# FastAPI
from os import getenv
FEATURE_NEW_SCHEMA = getenv("FEATURE_NEW_SCHEMA", "false") == "true"
@app.get("/api/user/{user_id}")
async def get_user(user_id: str):
if FEATURE_NEW_SCHEMA:
# Query new schema
return db.query(UserV2).filter(UserV2.id == user_id).first()
else:
# Query old schema
return db.query(UserV1).filter(UserV1.id == user_id).first()
Deploy the code first (both code paths), then flip the flag in Cloudflare’s dashboard. If something breaks, flip it back in seconds.
What I Missed (The Gotcha)
Cold starts aren’t gone—they’re just hidden.
The first request to a Cloudflare Worker after a deploy takes ~50-200ms (initialization). Subsequent requests are instant because the worker stays warm. But I wrongly assumed “edge-first” meant zero latency. It doesn’t. It means reduced latency for most requests, with smart caching eliminating the tail.
Also, PostgreSQL on Workers is painful. You can’t use SQLAlchemy’s sync API—Workers are async-only. I ended up using asyncpg directly and losing some ORM niceties. Worth it, but not free.
Finally, debugging is different. wrangler tail shows you logs from the edge, but they’re sampled by default. You’ll miss sporadic errors. Use structured logging and send errors to Sentry.
Why This Matters
Edge-first deployment isn’t about trendy buzzwords. It’s about:
- Faster time-to-first-byte: HTML renders at the edge, not in a US datacenter
- Cheaper compute: You pay for invocations, not idle servers
- Simpler deployments: No orchestration, no container images, no rollback complexity
For CitizenApp, moving to Cloudflare cut deployment pain in half and latency by 40% in non-US regions. Those aren’t trivial wins.
Start small: move your static sites to Pages first. Once you’re comfortable, migrate API routes to Workers. The learning curve is real, but the payoff is worth it.
Comments
All comments are moderated before appearing.
Leave a comment