Skip to main content
All posts

Deploying Astro + FastAPI SaaS to Cloudflare: Zero-Downtime, Edge-First

16 June 2026

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):

Backend (FastAPI on Cloudflare Workers via Wrangler):

This eliminates three things I hated:

  1. Waiting for cold starts
  2. Debugging latency across regions
  3. 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:

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.

You might also like

Comments

All comments are moderated before appearing.

Loading comments…

Leave a comment

0/2000

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch