Let's start with an example for better understanding.
If your devices are allowed to talk only to your own backend (no third-party sites), the cleanest path is to serve the UI directly from your FastAPI app i.e. HTML, CSS, JS, and images and expose JSON endpoints under the same domain. This post shows a production-practical pattern: a static, Bootstrap-styled UI (Login → Welcome → Weather with auto-refresh) fronted entirely by FastAPI, plus a quick path to deploy on AWS Lambda.
This article builds on an example project with pages
/login
,/welcome
,/weather
, health checks, and a weather API using OpenWeatherMap, already structured for Lambda.
Why “front via backend” (a.k.a. backend-served UI)?
- Single domain: Avoids CORS headaches, cookie confusion, and device restrictions that block third-party websites.
- Security & control: Gate all traffic through your API (auth, rate limiting, WAF/CDN).
- Simplicity: One deployable artifact, one CDN/domain, one set of logs.
- Edge caching: Cache static assets while keeping API dynamic.
Minimal project layout
fastAPIstaticpage/
├── main.py # FastAPI app
├── lambda_handler.py # Mangum/handler for Lambda
├── requirements.txt
├── static/
│ ├── css/style.css
│ ├── js/login.js
│ ├── js/welcome.js
│ ├── js/weather.js
│ ├── login.html
│ ├── welcome.html
│ └── weather.html
└── (serverless.yml or template.yaml, deploy.sh)
The static directory holds your UI; FastAPI serves those files and exposes API routes like /api/login
, /api/welcome
, /api/weather
.
FastAPI: serve pages + APIs from one app
1) Boot the app and mount static files
# main.py
import os, httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY")
app = FastAPI(title="Frontend via Backend with FastAPI")
# Serve everything under /static (CSS/JS/Images/HTML)
app.mount("/static", StaticFiles(directory="static"), name="static")
# Optionally make pretty routes for pages:
@app.get("/", include_in_schema=False)
@app.get("/login", include_in_schema=False)
def login_page():
return FileResponse("static/login.html")
@app.get("/welcome", include_in_schema=False)
def welcome_page():
return FileResponse("static/welcome.html")
@app.get("/weather", include_in_schema=False)
def weather_page():
return FileResponse("static/weather.html")
Tip: If you prefer templating (Jinja2) over plain HTML files, use
from fastapi.templating import Jinja2Templates
and render context from the server. For pure static HTML + fetch() calls,FileResponse
is perfect.
2) JSON endpoints that the UI calls
@app.post("/api/login")
async def login(payload: dict):
email = payload.get("email")
password = payload.get("password")
# Demo only: replace with proper auth in production
if email == "admin" and password == "admin":
return {"ok": True, "user": {"email": email}}
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.get("/api/welcome")
async def welcome():
# In real apps, read user/session; here we return a demo message
return {"message": "Welcome back, Admin!"}
@app.get("/api/weather")
async def weather(city: str = "Bengaluru", units: str = "metric"):
if not OPENWEATHER_API_KEY:
raise HTTPException(500, "OPENWEATHER_API_KEY missing")
url = "https://api.openweathermap.org/data/2.5/weather"
params = {"q": city, "appid": OPENWEATHER_API_KEY, "units": units}
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url, params=params)
if r.status_code != 200:
raise HTTPException(r.status_code, "Weather API error")
return r.json()
@app.get("/health", include_in_schema=False)
def health():
return {"status": "ok"}
The pages: keep HTML static, fetch data with JS
static/login.html
(snippet)
<form id="loginForm">
<input name="email" placeholder="email" />
<input name="password" type="password" placeholder="password" />
<button type="submit">Sign in</button>
</form>
<script src="/static/js/login.js"></script>
static/js/login.js
(snippet)
document.getElementById("loginForm").addEventListener("submit", async (e) => {
e.preventDefault();
const form = new FormData(e.target);
const res = await fetch("/api/login", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
});
if (res.ok) location.href = "/welcome";
else alert("Invalid credentials");
});
static/weather.html
(snippet)
<div>
<h2>Weather</h2>
<select id="city">
<option>Bengaluru</option><option>Mumbai</option><option>Delhi</option>
</select>
<pre id="result">Loading...</pre>
</div>
<script src="/static/js/weather.js"></script>
static/js/weather.js
(snippet, 10s auto-refresh)
async function load() {
const city = document.getElementById("city").value;
const r = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
document.getElementById("result").textContent = JSON.stringify(await r.json(), null, 2);
}
document.getElementById("city").addEventListener("change", load);
load();
setInterval(load, 10_000); // auto-refresh every 10s
The example app in the attached README uses the same flow: /
(login) → /welcome
→ /weather
, with Bootstrap UI and a 10-second weather refresh.
Shipping it on AWS Lambda (two quick options)
You can deploy the exact same app to Lambda behind API Gateway.
Option A: SAM (recommended for many teams)
- Add
template.yaml
and run:
sam build
sam deploy --guided
- Point a domain (Route 53) + CloudFront if needed for caching static assets.
(These steps mirror the attached project scaffolding.)
Option B: Serverless Framework
npm i -g serverless serverless-python-requirements
serverless deploy
Both approaches package your FastAPI app for Lambda. If you prefer a single entrypoint, use Mangum:
# lambda_handler.py
from mangum import Mangum
from main import app
handler = Mangum(app)
Pro tip: set appropriate cache headers for
/static/*
and no-cache for JSON endpoints.
Production hardening checklist
- Auth: Replace demo creds with JWT/session, store secrets in AWS Secrets Manager.
- HTTPS only: Enforce TLS; set
Secure
,HttpOnly
,SameSite
on cookies if used. - Headers: Add CSP, X-Frame-Options, Referrer-Policy, etc. via a middleware.
- CORS: Usually unnecessary when UI and API share the same domain—keep it off by default.
- Rate limits/WAF: Use API Gateway/WAF; some CDNs block requests lacking
User-Agent
. - Observability: Push logs/metrics to CloudWatch; add
/health
and structured logs. - Performance: Cache static assets at CloudFront; compress; fingerprint files (e.g.,
app.abc123.js
).
Architecture Diagram
This diagram illustrates how a single-origin architecture works when serving both frontend (HTML, CSS, JS) and backend (API) traffic through FastAPI running on AWS Lambda.
Flow of Requests
1. User / Device
- The client (e.g., browser, in-vehicle device, mobile app) makes a request to your app domain.
2. CloudFront
- Acts as a Content Delivery Network (CDN) and TLS termination point.
- Provides caching, DDoS protection, and performance optimization.
- All requests are routed through CloudFront.
3. API Gateway
- CloudFront forwards the request to Amazon API Gateway.
- API Gateway handles routing, throttling, authentication (if configured), and request validation.
- All paths (
/
,/login
,/welcome
,/weather
,/api/...
) pass through here.
- API Gateway invokes the AWS Lambda function running FastAPI (using Mangum).
- This single app serves:
- Static content (HTML/CSS/JS bundled with the Lambda package or EFS)
- API responses (login, weather, welcome, etc.)
Supporting Components
1. Local / EFS / In-package static
- Your frontend files (e.g., login.html, weather.html, JS bundles) are either packaged inside the Lambda zip, stored in EFS, or mounted locally.
- This allows the FastAPI app to return HTML/JS without needing a separate S3 bucket.
2. Observability & Secrets
- CloudWatch Logs & Metrics capture all Lambda and API Gateway activity (for debugging, monitoring, and alerting).
- Secrets Manager stores sensitive data (e.g., OpenWeatherMap API key, DB credentials). Lambda retrieves these securely at runtime.
Why This Architecture ?
- One origin (no separate frontend on S3), meaning devices only talk to your backend domain.
- No CORS needed because UI and API share the same domain.
- Tight control over auth, caching, and delivery.
- Ideal when working with restricted environments (e.g., in-vehicle browsers or IoT devices).
When this architecture shines (and is cost-efficient) ?
Devices must hit only your domain
- In-vehicle browsers, kiosk/IVI, corporate-locked devices.
- You serve HTML/CSS/JS and APIs from one origin → no CORS, simpler auth, tighter control.
Low-to-medium, spiky traffic (pay-per-use wins)
- Nights/weekends idle, bursts during the day or at launches.
- Lambda scales to zero; you don’t pay for idle EC2/ECS.
Small/medium static assets bundled or EFS-hosted
- App shell HTML + a few JS/CSS files (tens of KBs → a few MBs).
- CloudFront caches most hits; Lambda mostly executes on first cache miss.
Simple global delivery needs
- CloudFront gives TLS, caching, DDoS mitigation, and global POPs with almost no ops.
Tight teams / fast iteration
- One repo, one deployment path (SAM/Serverless).
- Great for prototypes → pilot → production without re-architecting.
Traffic & cost heuristics (rules of thumb)
Use these to sanity-check costs; they’re order-of-magnitude, excluding data transfer:
Lambda is cheapest when:
- Average load is bursty and < a few million requests/month, and
- Per-request work is modest (sub-second, 128–512 MB memory), and
- Static assets are cache-friendly (CloudFront hit ratio high).
Rough mental math (how to approximate)
- Per-request Lambda cost ≈ (memory GB) × (duration sec) × (price per GB-s) + (request charge).
- Example shape (not exact pricing): at 256 MB and 200 ms, compute cost per 100k requests is typically pennies to low dollars; the bigger bill tends to be egress/data transfer if your assets are large.
- CloudFront greatly reduces Lambda invocations for static paths (high cache hit ratio → far fewer Lambda runs).
If your bill is mostly data (images, big JS bundles, downloads), move those to S3 + CloudFront (dual-origin). It’s almost always cheaper for heavy static.
Perfect fits (based on real-world patterns)
- In-vehicle Apps with Web view : UI must come from your backend only; traffic is intermittent; pages are light; auth and policies live at the edge/API Gateway.
- Internal tools, admin consoles, partner portals with uneven usage.
- Geo-gated or compliance-gated UIs where a single origin simplifies policy.
- Early-stage products and pilots where you want minimal ops and fast changes.
When to switch (or start with a dual-origin) ?
- Front-heavy sites (lots of images/video, large JS bundles)
/static/*
and keep Lambda for /api/*
.Same domain via CloudFront behaviors → still no CORS.
- High, steady traffic (always busy)
- Very low latency or long-lived connections
- Heavy CPU/GPU per request (ML inference, large PDFs, video processing)
Dedicated containers/instances (ECS/EKS/EC2) with right sizing are usually cheaper and faster.
Simple decision tree
Do you need single origin + locked-down devices?Yes → Single-origin Lambda is great.
Are your static assets > a few MB and dominate traffic?
Yes → Dual-origin (S3 for static + Lambda for API).
Is traffic high and steady (e.g., >5–10M req/mo with sub-second work)?
Consider ECS/Fargate for cost predictability.
Do you need near-zero cold-start latency?
Prefer containers or keep Lambda warm (provisioned concurrency → raises cost).
Cost-saving tips (keep Lambda, cut the bill)
- Cache hard at CloudFront: long TTLs for
/static/*
, hashed filenames; no-cache for/api/*
. - Slim assets: compress, tree-shake, code-split, use HTTP/2.
- Right-size Lambda memory: test 128/256/512 MB; pick the best $/latency.
- Warm paths (if needed): provisioned concurrency only on critical API stages/times.
- Move heavy static to S3 while keeping single domain via CloudFront behaviors.
Bottom line
- If your devices can only call your backend, traffic is bursty/medium, and your frontend is lightweight, this Lambda + API Gateway + CloudFront single-origin setup is both operationally simple and cost-efficient.
- As static volume or steady traffic grows, go dual-origin (S3 + Lambda) first; if traffic becomes large and constant or latency targets tighten, move APIs to containers.
Bibliography
- FastAPI — Official Docs. Core concepts, routing, and production guidance. FastAPI
- FastAPI Static Files — Using
StaticFiles
to mount and serve assets. FastAPI+1 - Mangum — ASGI adapter for running FastAPI on AWS Lambda (API Gateway/ALB/Function URL). GitHub
- AWS SAM — Developer Guide (define, build, and deploy serverless apps). AWS Documentation+2GitHub+2
- Serverless Framework — Getting started and Lambda functions guide. serverless.com+2serverless.com+2
- AWS Lambda (Python) — Building and configuring Python functions. AWS Documentation
- OpenWeather — Current Weather & One Call API docs (used in the example). openweathermap.org+2openweathermap.org+2
- MDN — CORS guides and reference headers (
Access-Control-Allow-*
). MDN Web Docs+4MDN Web Docs+4MDN Web Docs+4 - MDN — Content Security Policy (CSP) guides and header reference. MDN Web Docs+2MDN Web Docs+2
- Lambda + ASGI production experiences & templates. Stack Overflow+1