The Stack, and the One Cliff Most Guides Skip
A FastAPI API, a Postgres database, and a static frontend can all run for nothing in 2026. The problem is that the obvious version of this stack self-destructs on a schedule.
Render offers one free Postgres database per workspace: 1 GB of storage, no backups, no connection pooling — and it expires 30 days after you create it. After expiry you get a 14-day grace period to upgrade, and then Render deletes the database and its data (Render free tier docs). That is the cliff. A tutorial that puts the database on Render’s free tier builds an app that becomes inaccessible at 30 days and is deleted after the 14-day grace period.
The fix is an architecture split:
- Cloudflare Pages — the static frontend, deployed from plain HTML and JS.
- Render Free web service — the stateless FastAPI container.
- Neon Free — the Postgres database, listed at $0/month on the Neon plans page.
Render hosts compute. Neon owns state. Pages serves the frontend. This post builds that stack in order, then maps every free-tier limit and ranks exactly where it breaks first.
Architecture: Why the Database Doesn’t Live on Render
The request path is three boxes and one direction of trust:
Browser → Cloudflare Pages (static files)
Browser → Render (FastAPI over HTTPS) → Neon (Postgres)
The frontend is just files served from Pages, and the browser calls the API directly. The API is a container with no local state. The database is the only component with durable data, and it lives with the provider whose free tier is not time-limited.
This split is forced by two Render facts, not by preference:
- The filesystem is ephemeral on all Render services, and Free web services cannot attach persistent disks. Local files are lost on redeploy, restart, or spin-down (Render free tier docs).
- Render may restart a Free web service at any time, and a Free service cannot scale beyond one instance, run one-off jobs, or give you shell access (Render free tier docs).
So a SQLite file inside the container is not a fallback either — it would vanish on the next redeploy, and there is no disk to put it on. Every byte of application state goes to Neon. For how an app connects to Neon from outside Neon’s own tooling, see Neon’s connect-from-any-app guide.
Step 1: Create the Neon Database
Create a project in the Neon console and copy the connection string from the Neon console and store it only in an environment variable. That string is the only credential you will configure on Render, and it is never hardcoded in the app.
What the Free plan gives you (Neon plans):
- 100 projects, with up to 10 branches per project
- 100 CU-hours per project per month — enough for a 0.25 CU compute running about 400 hours a month
- Autoscaling up to 2 CU, and scale to zero after 5 minutes idle; suspended computes accrue no CU-hours
- 1 GB of storage per project, 20 GB account total
- 5 GB of public network transfer per project
- A 6-hour history (restore) window and 1 manual snapshot
- 1 day of monitoring
Scale-to-zero is the feature that makes the CU-hour budget survivable, and it is also the second cold start you will pay later in this stack. For more on trimming compute spend, see Neon’s cost-optimization docs and the billing overview.
Step 2: The FastAPI App (Notes CRUD)
Start locally with uv:
uv init fastapi-notes && cd fastapi-notes
uv add "fastapi[standard]" pydantic
mkdir app && touch app/__init__.py && mv main.py app/main.py
You also need a Postgres driver; this app uses psycopg:
uv add "psycopg[binary]"
Then export a lockstep requirements file, exactly as the FastAPI Docker deployment guide prescribes:
uv export --format requirements-txt --no-dev --no-emit-project --output-file requirements.txt
app/main.py reads DATABASE_URL from the environment — os.environ["DATABASE_URL"] — so a missing variable fails loudly at startup instead of silently pointing somewhere else. Schema creation runs in the lifespan handler, which is why the exec-form CMD in the next step matters:
import os
from contextlib import asynccontextmanager
from datetime import datetime
import psycopg
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
class NoteIn(BaseModel):
text: str
class Note(NoteIn):
id: int
created_at: datetime
def get_conn():
return psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
with get_conn() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS notes (
id BIGSERIAL PRIMARY KEY,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
yield
app = FastAPI(title="Notes", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://your-project.pages.dev"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/notes", status_code=201, response_model=Note)
def create_note(payload: NoteIn):
with get_conn() as conn:
row = conn.execute(
"INSERT INTO notes (text) VALUES (%s) RETURNING id, text, created_at",
(payload.text,),
).fetchone()
return Note(id=row[0], text=row[1], created_at=row[2])
@app.get("/notes", response_model=list[Note])
def list_notes():
with get_conn() as conn:
rows = conn.execute(
"SELECT id, text, created_at FROM notes ORDER BY id DESC"
).fetchall()
return [Note(id=r[0], text=r[1], created_at=r[2]) for r in rows]
@app.get("/notes/{note_id}", response_model=Note)
def get_note(note_id: int):
with get_conn() as conn:
row = conn.execute(
"SELECT id, text, created_at FROM notes WHERE id = %s", (note_id,)
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="note not found")
return Note(id=row[0], text=row[1], created_at=row[2])
@app.delete("/notes/{note_id}", status_code=204)
def delete_note(note_id: int):
with get_conn() as conn:
cur = conn.execute("DELETE FROM notes WHERE id = %s", (note_id,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="note not found")
Four routes, one resource, one environment variable. There is no local database path anywhere in this file by design. The CORS middleware lets the browser on your Pages domain call the API directly; replace the origin with your real Pages URL.
Step 3: The Dockerfile (Exec-Form CMD and PORT Binding)
FROM python:3.14
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY app /app/app
CMD ["sh", "-c", "exec fastapi run app/main.py --port ${PORT} --proxy-headers"]
Three details carry real consequences:
- The CMD is exec form, not shell form. The FastAPI Docker deployment guide is explicit that exec form is what lets FastAPI shut down gracefully and trigger lifespan events — the same handler that creates the
notestable. The shell form does not give you that behaviour. --port 80becomes${PORT}. The official example hardcodes port 80. This post assumes your host sets aPORTenvironment variable; confirm the exact name in Render’s web services docs before you deploy.sh -cexpands${PORT}at start time, andexechands the process back to FastAPI.--proxy-headerstells Uvicorn to trust forwarded headers from a TLS-terminating proxy, as the FastAPI guide describes.
Two more rules from the same guide: copy requirements.txt before the app code so Docker can cache the install layer, and build your own image. The old tiangolo/uvicorn-gunicorn-fastapi base image is deprecated — do not use it. FastAPI’s broader deployment overview is at FastAPI deployment.
Step 4: Deploy to Render as a Free Web Service
In the Render dashboard: New → Web Service → connect the repository → select the Free instance type → add the environment variable DATABASE_URL with your Neon connection string → deploy. What the Free compute plan includes is documented in Render’s compute plans docs and the Render pricing page.
The operating constraints you are accepting (Render free tier docs):
- 750 Free instance hours per workspace per calendar month. They reset monthly and do not roll over.
- The service spins down after 15 minutes with no inbound HTTP or WebSocket traffic. Spin-up takes about one minute, during which browsers see a loading page.
- One instance, no shell access, no one-off jobs, and Render may restart the service at any time.
- Outbound SMTP ports 25, 465, and 587 are blocked for Free web services, so transactional email must go out over HTTPS to an email API instead.
- Outbound traffic counts against your workspace’s bandwidth allowance, and Render may suspend a Free service that pushes an uncommonly high volume of outbound traffic — which includes heavy external database access. Neon is an external database, so every query from this app counts as external traffic under that rule. Render publishes no threshold; a notes app’s query volume is small, but treat the risk as unquantified.
Step 5: Static Frontend on Cloudflare Pages
Build a single index.html with a small fetch() wrapper around the four routes on your Render URL, then create a Pages project from that directory and deploy — direct upload or Git, both work. Cloudflare’s own walkthrough is at Cloudflare Pages get started.
The Free plan limits (Cloudflare Pages limits):
- 500 builds per month, 1 build at a time, builds time out after 20 minutes
- 100 Pages projects per account, 100 custom domains per project
- 20,000 files per site, 25 MiB maximum per asset
- Unlimited preview deployments
- 100 rules in
_headers, and 2,100 combined redirects (2,000 static plus 100 dynamic) in_redirects— the syntax is in Cloudflare’s redirects configuration guide
For a notes frontend, those ceilings are effectively unreachable. The one to watch is the 500-builds-per-month cap: batch your commits rather than pushing on every tweak.
Step 6: Verify End-to-End (and Expect the Cold Starts)
Open the Pages URL, create a note, refresh, and confirm it persists. Then wait 15+ minutes with no traffic and hit the API again. You are watching two independent cold starts stack on a single request:
- Render: 15 minutes of idle triggers spin-down; the first request back takes roughly a minute while the service boots and shows a loading page.
- Neon: 5 minutes of idle scales the compute to zero; the first query wakes it.
Neither is an error. Both are the cost of the free tier, and Neon’s shorter 5-minute idle threshold means its wake-up can happen more often than Render’s.
The Limits Map: Where Each Free Tier Breaks
| Service | Free limit | What breaks | Workaround |
|---|---|---|---|
| Render web service | 750 instance hours per workspace per month, shared by all Free services; resets monthly, no rollover | Every Free service in the workspace suspended when hours run out | Run one always-on Free service per workspace; each always-on service uses 744 hours in a 31-day month, so a second one exceeds the cap |
| Render spin-down | 15 min idle → about 1 min spin-up | First visitor waits ~1 min and sees a loading page | Accept it, or send keep-alive pings — which burn instance hours |
| Render filesystem | Ephemeral; no persistent disks on Free | Local files lost on redeploy, restart, or spin-down | All state in Neon; never local SQLite |
| Render outbound SMTP | Ports 25, 465, 587 blocked on Free | Cannot send email from the service | Use an external email API over HTTPS |
| Render outbound volume | Counts against workspace bandwidth; high-volume services may be suspended | Suspension for heavy external API, DB, or storage traffic | Keep external calls modest |
| Render free Postgres | 1 GB; expires 30 days after creation, 14-day grace, then deleted; no backups, no pooling | Data deletion — the cliff | Do not use it. Use Neon |
| Neon compute | 100 CU-hours per project per month (a 0.25 CU compute for ~400 hrs) | Compute suspended until the next billing period or an upgrade | Suspended computes accrue no hours. Always-on use of a 0.25 CU compute (744 hrs) exceeds the cap, so idle time is what keeps you under it |
| Neon storage | 1 GB per project, 20 GB per account | Insert, update, and delete operations fail | Archive or upgrade; data is not deleted |
| Neon egress | 5 GB per project | Compute suspended when transfer runs out | Same handling as CU-hours |
| Neon branches | 10 per project | Branch creation fails | Delete stale branches |
| Neon history | 6-hour restore window, 1 manual snapshot | Cannot restore older than 6 hours | Take a manual snapshot before risky changes |
| Cloudflare Pages builds | 500 per month, 1 concurrent, 20-min timeout | Monthly build quota used up | Batch commits |
| Cloudflare Pages size | 100 projects, 20,000 files per site, 25 MiB per asset | Deploy rejected | Trim assets; for a static page these limits are generous |
What Breaks First (Ranked)
1. Render’s idle cold start — happens daily, and it is a UX issue, not an outage. Fifteen minutes without traffic, then about a minute of spin-up. Your API did not go down; it went to sleep. Expect this every morning and after every lunch break.
2. Neon’s 100 CU-hours per project per month. Hit the cap and the compute is suspended until the next billing period or an upgrade. This is a monthly ceiling, not a surprise deletion. Scale-to-zero stops the meter during idle time, since suspended computes accrue no CU-hours.
3. Neon’s 1 GB storage cap per project. At the cap, storage-increasing operations — inserts, updates, and deletes — fail. Existing rows are untouched. Archive or upgrade.
4. Render’s 750 instance-hour cap. Resets monthly with no rollover. Note the arithmetic: one Free service running around the clock uses 744 hours in a 31-day month, which fits under 750. The cap is shared by every Free service in the workspace, though, so a second always-on service exhausts it, and Render then suspends all of your Free services until the month resets.
This ordering is the author’s judgement, not a measured result: the thing you will probably hit first (cold starts) is the least serious, and the thing that sounds scariest (data loss) is the one this architecture removes from the table.
Frequently Asked Questions
Can I just use Render’s free Postgres and skip Neon? No. It expires 30 days after creation, and Render deletes the database and its data after a 14-day grace period, with no backups and no connection pooling. That is precisely the cliff this stack exists to avoid.
Why is my API slow every morning? Two cold starts stack on one request. Render spins down after 15 minutes of idle traffic and takes about a minute to come back, and Neon scales to zero after 5 minutes of idle. The first visitor after any quiet period pays both.
What happens if I blow past Neon’s limits — is my data gone? No. When CU-hours or public transfer run out, compute is suspended until the next billing period. When storage caps are hit, storage-increasing operations fail. Branch creation fails past 10 branches. None of these limits delete data.
The Bottom Line
This stack works, and it is genuinely portable: standard Postgres, a standard Dockerfile, and plain static files. Nothing here is proprietary, and moving any of the three pieces elsewhere should be a contained change in principle, though you should test that yourself.
It is also a hobby-tier stack with a known failure order. Cold starts will annoy your users daily. Neon’s compute hours are the monthly ceiling you will actually approach. Storage is the eventual one, and Render’s shared 750-hour cap is the quiet one: a second always-on Free service exhausts it. None of those failures is catastrophic, and exceeding Neon’s limits does not delete data. Neon’s Free restore window is only 6 hours, so keep your own exports.
The failure mode this architecture removes is expiry-driven database deletion, because the database was never on Render. Render emails warnings before expiry and before the grace period ends, so that cliff is announced, not silent.
How This Guide Was Built
This guide is based on official documentation and pricing pages — Render’s free-tier docs, the Render pricing page, Neon’s plans documentation, Cloudflare’s Pages limits page, and FastAPI’s official Docker deployment guide — all reviewed on 2026-10-17.
We did not run this stack hands-on for this article, and nothing here is first-hand testing or timing data. Figures are the vendor-published limits as they stood on the review date; verify them on each provider’s pricing page before you build.
