Deploying Python APIs to Render or Vercel
Your API earns nothing on your laptop. The move from uvicorn main:app --reload to a URL a paying customer can hit is where most first commercial APIs stall, because the choice you make in that first hour — container or function — sets your cost baseline, your latency floor, and the shape of every operational problem you will have for the next year. Part of the Building & Monetizing API-Driven Micro-SaaS section, this guide walks the whole path: choosing the runtime model, preparing a FastAPI codebase for a production host, the exact render.yaml and vercel.json that work, the cold-start and connection-pool failures that bite serverless Python, and what each option actually costs at 100,000, one million and one hundred million requests a month.
The short recommendation, stated up front: put your commercial Python API on a Render web service and keep Vercel for the marketing site and dashboard in front of it. A long-lived ASGI process is the runtime FastAPI was designed for, it is cheaper than functions above roughly ten million requests a month, and it removes an entire category of failure — cold starts, connection fan-out, execution timeouts — that you would otherwise spend weekends debugging. Vercel earns its place when traffic is genuinely bursty and mostly idle, or when your API is a thin edge in front of someone else's backend.
Pick the runtime model before you pick the platform
Render and Vercel are not two flavours of the same thing. Render runs your process; Vercel runs your function. Everything else follows from that one difference.
A Render web service is a container that boots once and stays up. Your lifespan handler runs exactly one time, your database pool is created once and reused for the life of the process, background tasks scheduled with asyncio.create_task survive past the response, and in-process caches actually cache. You pay for wall-clock time whether or not requests arrive.
A Vercel Python function is invoked per request into a sandbox that may or may not already exist. Nothing is guaranteed to persist between invocations, module-level state survives only while a sandbox stays warm, and execution is capped — 60 seconds on the Hobby plan, 300 on Pro by default. You pay for execution duration, so an idle API costs nothing.
Three questions settle the decision, and they are all about workload shape rather than taste. Does any request need a connection held open — WebSockets, server-sent events, a streaming LLM response? Does your product need work that outlives the HTTP response, such as a Celery queue or a scheduler? Is your traffic steady enough that a box is busy more than a few percent of the time? A yes to any of those means a container. If all three are no — a webhook receiver that fires forty times a day, an internal tool, a launch-week prototype — functions are genuinely cheaper and genuinely less work.
There is a third option worth knowing about before you commit: an isolate-based runtime. Deploying FastAPI to Cloudflare Workers with Python removes cold starts almost entirely but replaces CPython with Pyodide, which rules out most compiled dependencies. If you want the wider field — Railway's usage billing, Fly's per-region machines — the Render vs Railway vs Fly.io comparison covers autoscale behaviour and cold-start cost side by side, and the free-hosting round-up covers what you can get away with before revenue starts.
Prerequisites
Assume Python 3.11 or newer, FastAPI 0.110+, Uvicorn 0.29+, and a Git repository the platform can read. You need a lockfile-grade requirements.txt with exact pins, a /health route, and every environment-dependent value read through os.getenv. If you use Postgres, you need a pooled connection string; if you deploy to Vercel you additionally need mangum for the ASGI-to-serverless bridge.
Set these before the first deploy: DATABASE_URL, ALLOWED_ORIGINS, WEB_CONCURRENCY, APP_VERSION, and whatever API credentials your service consumes. Never bake them into the image — both platforms inject environment variables at runtime, and both let you mark values as secret so they stop appearing in build logs.
Step 1: make the codebase deployable
Cloud builds are unforgiving about ambiguity. Two things break first-time deploys more than anything else: unpinned dependencies that resolve differently in the build container than on your machine, and an app that binds to 127.0.0.1 instead of 0.0.0.0 and therefore never receives traffic from the platform router.
The startup module below is the shape I ship. It reads every knob from the environment, exposes a /health route the platform can poll, uses a lifespan handler so the database pool opens once, and reports its own version from pyproject.toml via tomllib so you can verify which commit is actually live.
import asyncio
import logging
import os
import tomllib
from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
def read_version() -> str:
override = os.getenv("APP_VERSION")
if override:
return override
manifest = Path(__file__).resolve().parent / "pyproject.toml"
if not manifest.exists():
return "0.0.0"
with manifest.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
VERSION = read_version()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.ready = False
# Open pools, warm caches, verify migrations here.
await asyncio.sleep(0)
app.state.ready = True
logger.info("startup complete version=%s", VERSION)
yield
app.state.ready = False
logger.info("draining connections before shutdown")
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[o for o in os.getenv("ALLOWED_ORIGINS", "").split(",") if o],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["authorization", "content-type"],
)
@app.get("/health")
async def health() -> JSONResponse:
ready = getattr(app.state, "ready", False)
return JSONResponse(
{"status": "ok" if ready else "starting", "version": VERSION},
status_code=200 if ready else 503,
)
if __name__ == "__main__":
uvicorn.run(
"main:app",
host=os.getenv("BIND_HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
workers=int(os.getenv("WEB_CONCURRENCY", "2")),
log_level=os.getenv("LOG_LEVEL", "info").lower(),
)
Two details earn their keep. The health route returns 503 until lifespan finishes, which stops the platform routing traffic to a process that has not opened its pool yet — the foundation of zero-downtime deploys. And ALLOWED_ORIGINS defaults to empty rather than *, so a forgotten variable fails loudly in staging instead of quietly shipping an open CORS policy to production.
If you want the same artifact locally and in production, build a container: containerizing Python APIs with Docker covers the Dockerfile, and optimizing Python Docker image size matters more than you expect — Render rebuilds on every push, and a 1.2 GB image costs you ninety seconds of deploy latency each time.
Step 2: deploy to Render as a persistent web service
Render's model is a container per service, a managed Postgres alongside it, and a render.yaml that describes all of it as code. Commit that file and the whole environment becomes reproducible; click through the dashboard instead and you will not remember what you set six months from now.
The blueprint below provisions the web service, a background worker, and Postgres in one region so all internal traffic stays on the private network and costs nothing in egress.
services:
- type: web
name: api-service
runtime: python
region: oregon
plan: starter
buildCommand: pip install --no-cache-dir -r requirements.txt
startCommand: >
gunicorn main:app
-k uvicorn.workers.UvicornWorker
--workers ${WEB_CONCURRENCY}
--bind 0.0.0.0:${PORT}
--timeout 30
--graceful-timeout 25
--max-requests 2000
--max-requests-jitter 200
healthCheckPath: /health
autoDeploy: true
envVars:
- key: WEB_CONCURRENCY
value: "2"
- key: ALLOWED_ORIGINS
sync: false
- key: DATABASE_URL
fromDatabase:
name: api-postgres
property: connectionString
- type: worker
name: api-worker
runtime: python
region: oregon
plan: starter
buildCommand: pip install --no-cache-dir -r requirements.txt
startCommand: arq worker.WorkerSettings
envVars:
- key: DATABASE_URL
fromDatabase:
name: api-postgres
property: connectionString
databases:
- name: api-postgres
region: oregon
plan: basic-256mb
postgresMajorVersion: "16"
Four settings deserve an explanation. -k uvicorn.workers.UvicornWorker is mandatory — plain Gunicorn speaks WSGI and every async route returns a coroutine object instead of a response; the Uvicorn vs Gunicorn worker configuration breakdown explains why you still want the Gunicorn supervisor on top. --graceful-timeout 25 gives in-flight requests time to finish when a deploy sends SIGTERM. --max-requests with jitter recycles workers periodically, which is a cheap insurance policy against slow memory leaks in third-party clients. And sync: false marks a variable as one you will set by hand in the dashboard, so a secret never lands in the repository.
Worker count is the setting builders get wrong most often. On the Starter plan you have 0.5 vCPU and 512 MB. Two Uvicorn workers is the ceiling there — each FastAPI process with SQLAlchemy loaded costs roughly 120–180 MB resident, so four workers will hit the memory limit and Render will restart the service in a loop that looks exactly like a code bug. For async workloads the right lever is concurrency inside the event loop, not process count: one worker on a half-core comfortably serves a few hundred concurrent I/O-bound requests as long as nothing blocks.
Two more Render behaviours are worth internalising before your first paid customer arrives. Deploys are rolling by default: Render boots the new container, waits for healthCheckPath to return 200, shifts traffic, and only then stops the old one — which means a health route that returns 200 before the pool is ready hands live traffic to a half-booted process. And services in the same region talk over a private network using internal hostnames, so keep the internal Postgres URL rather than the external one; the external endpoint routes over the public internet, adds twenty to forty milliseconds per query, and counts against bandwidth. If you run scheduled work, add a type: cron service rather than reaching for an in-process scheduler, because a web service with autoscaling will happily run your nightly job twice.
Step 3: deploy to Vercel as serverless functions
Vercel's Python runtime builds each file under api/ into a function. FastAPI is a single ASGI app, so route everything to one entry point and let the framework do internal routing — a per-route function would multiply your cold starts by the number of endpoints.
{
"version": 2,
"builds": [{ "src": "api/index.py", "use": "@vercel/python" }],
"routes": [{ "src": "/(.*)", "dest": "/api/index.py" }]
}
The handler wraps the ASGI app with mangum. Note lifespan="off": startup and shutdown events do not map onto a per-invocation runtime, and leaving lifespan enabled makes Mangum run your startup handler on every cold start, which is rarely what the code expects.
import os
from fastapi import FastAPI
from mangum import Mangum
app = FastAPI()
@app.get("/health")
async def health() -> dict[str, str]:
target = os.getenv("DEPLOY_TARGET", "vercel")
match target:
case "vercel":
region = os.getenv("VERCEL_REGION", "unknown")
case "render":
region = os.getenv("RENDER_REGION", "unknown")
case _:
region = "local"
return {
"status": "ok",
"version": os.getenv("APP_VERSION", "0.0.0"),
"region": region,
}
handler = Mangum(app, lifespan="off")
Cold starts are the tax you pay for this model, and their size is set almost entirely by your import graph. A lean FastAPI app — framework, Pydantic, httpx — imports in roughly 400 ms on top of about 200 ms of runtime boot. Add pandas and numpy and that import cost jumps past two and a half seconds, which turns a p99 latency figure into a support ticket.
Three fixes bring cold starts down, in order of payoff. Delete dependencies you import but barely use — swapping pandas for a hand-rolled aggregation over dict objects is often a two-second win. Defer heavy imports into the function body so a request that never touches the report endpoint never pays for its imports. And keep response payloads small, because Vercel's function bundle and your JSON both travel over the same billed duration.
Vercel's preview deployments are the genuine advantage of this model and the reason I still put dashboards there. Every pull request gets its own URL with its own environment variables, which makes reviewing an API change something a non-engineer can do by clicking a link. Wire the preview environment to a separate database branch rather than production, though — the default is to share environment variables across preview and production unless you scope them, and one careless DELETE /v1/customers against live data during review is a story you only get to tell once.
The statelessness rule is stricter than most builders expect. An in-process rate limiter counts differently in every sandbox, so quota enforcement has to live in Redis — see caching Python API responses with Redis for the shared-state pattern. Anything you schedule with BackgroundTasks may be killed the instant the response is written. And a per-request database connection multiplied by concurrent invocations is the failure covered below.
Configuration reference
Set these on both platforms. Defaults are what the code above falls back to; the production column is what I actually run.
| Variable | Default | Production setting |
|---|---|---|
WEB_CONCURRENCY | 2 | 2 per 0.5 vCPU |
DATABASE_URL | none | pooled connection string |
DB_POOL_SIZE | 5 | 5 container, 1 serverless |
ALLOWED_ORIGINS | empty | explicit origin list |
LOG_LEVEL | INFO | INFO, DEBUG in staging |
APP_VERSION | pyproject | injected commit SHA |
DB_POOL_SIZE is the one that differs by runtime rather than by taste, and the reasoning is in the failure-modes section. APP_VERSION should carry the commit SHA in CI so /health tells you precisely which build is serving traffic — invaluable when a rollback half-completes.
Cost and performance at real traffic
Deployment decisions are margin decisions. Here is what the same API costs on each platform across four traffic tiers, assuming a 120 ms average handler and 1 GB of memory on the function side. Render figures are the instance plan alone; Vercel figures assume a Pro seat with its included execution allowance, then metered duration above it.
Work the arithmetic yourself, because it is less intimidating than it looks. Ten million requests a month at 120 ms of 1 GB execution is 1,200,000 GB-seconds, or about 333 GB-hours — comfortably inside a Pro plan's included allowance, which is why Vercel holds flat at twenty dollars for three tiers. At a hundred million requests you burn roughly 3,333 GB-hours, and the 2,333 chargeable hours at around $0.18 each add $420 to the bill. Render's curve is the opposite shape: flat and boring until the box saturates, then you step up a plan or add an instance.
Convert both to cost per request and the commercial picture sharpens. Render Starter at one million requests is $0.000007 per call — seven millionths of a dollar. If you charge $0.001 per call, your compute gross margin is 99.3% before bandwidth and database. That is the number to carry into designing API pricing tiers, and the method for computing it properly lives in calculating cost per API request. Compute is almost never what kills API margin; egress, an LLM vendor's per-token bill, and a free tier nobody rate-limits are what kill it.
The performance corollary: a $7 Render Starter instance running two Uvicorn workers handles roughly 150–250 requests per second of I/O-bound async work with p95 under 120 ms, provided nothing blocks the event loop. That is 400 million requests a month of theoretical headroom. Most micro-SaaS APIs never outgrow one box; they outgrow one database, which is a different problem solved by async database access with SQLAlchemy and read replicas.
Gotchas and failure modes
Connection fan-out on serverless. This is the number one production incident for FastAPI on Vercel. Each warm sandbox holds its own SQLAlchemy pool. Forty concurrent invocations with pool_size=5 request two hundred connections, and a small Postgres allows fewer than a hundred. You get FATAL: sorry, too many clients already, and it appears only under the traffic spike you were celebrating. Fix it with pool_size=1, max_overflow=0 plus a transaction-mode pooler in front of the database; fixing connection pool exhaustion has the full diagnosis.
Free-tier spin-down. Render's free instances sleep after fifteen minutes of inactivity and take forty to sixty seconds to wake. That is fine for a demo and fatal for a paid API — a customer's first call after lunch times out. The $7 Starter plan exists precisely to remove this; buy it the day you take money.
Blocking calls inside async routes. One requests.get() or a synchronous database driver inside an async def handler stalls the entire event loop for every concurrent user on that worker. Symptoms look like a platform problem: latency that climbs with concurrency while CPU sits near idle. Use httpx.AsyncClient created once at startup, and run genuinely blocking libraries through asyncio.to_thread.
Vercel's read-only filesystem. Only /tmp is writable and it does not persist. Code that writes a SQLite cache, a generated PDF, or a log file to the working directory raises OSError: Read-only file system on the first production request while passing every local test.
Build/runtime drift. Render builds with the Python version you pin in runtime.txt or PYTHON_VERSION; Vercel with the version its runtime supports. If you use 3.12 syntax locally and the platform defaults to 3.9, the failure surfaces as a SyntaxError deep in a traceback during boot. Pin the version explicitly on both.
Silent rollback gaps. Both platforms keep the previous deploy and can roll back, but neither reverts a database migration. Ship expand-then-contract migrations so the old code still runs against the new schema, and treat that as a hard rule once you have real customers — the discipline overlaps heavily with versioning and evolving public APIs.
Verify the deploy before you announce it
A green build is not a working API. Run these three checks against the live URL, in this order, before you point a single customer at it.
BASE_URL="${BASE_URL:?set the deployed base URL}"
curl -fsS "$BASE_URL/health" | tee /dev/stderr | grep -q '"status":"ok"'
curl -o /dev/null -sS -w 'status=%{http_code} ttfb=%{time_starttransfer}s\n' \
"$BASE_URL/health"
curl -fsS -o /dev/null -w 'cors=%{http_code}\n' -X OPTIONS \
-H "Origin: ${APP_ORIGIN:?set the browser origin}" \
-H "Access-Control-Request-Method: POST" \
"$BASE_URL/api/data"
The first proves the process booted and finished its lifespan. The second gives you a real time-to-first-byte from outside the platform — run it twice on Vercel, because the gap between the two numbers is your cold-start cost. The third catches the CORS misconfiguration that otherwise gets discovered by your first user's browser console.
Then wire the deploy into your test and observability pipeline: run pytest against the API as a gate before the platform promotes a build, ship structured logging so 5xx spikes page you instead of surprising you, publish your schema through OpenAPI documentation, and record per-key call counts from day one via tracking API usage and analytics. Once billing is attached through Stripe, a usage log you did not keep is revenue you cannot invoice, and a developer portal turns that same data into self-serve signups.
FAQ
What does it actually cost to run a Python API at one million requests a month? Seven dollars on a Render Starter instance plus around six for a small managed Postgres, so roughly thirteen dollars all-in — about $0.000013 per request. On Vercel Pro the same traffic sits inside the included execution allowance, so you pay the twenty-dollar seat and nothing more. Both are rounding errors against any realistic subscription price; the cost that hurts at this scale is an unmetered free tier, not compute.
At what traffic level does serverless stop being cheaper? Around ten to twenty million requests a month with a 120 ms handler at 1 GB. Below that, function duration usually fits inside a plan's included allowance. Above it you pay per GB-hour and the bill scales linearly, while a container's cost stays flat until the box saturates — at a hundred million requests the gap is roughly $50 against $440 a month.
How risky is migrating from Vercel to Render later?
Low, if you kept the app a plain ASGI application. You delete api/index.py and the Mangum handler, add a render.yaml with a Gunicorn start command, and repoint DNS. The real migration work is the state you assumed away: an in-process cache, a scheduled job you were faking with an external cron pinger, and pool sizes tuned for one-connection-per-sandbox that should now be five.
How do I rotate a leaked API key or database credential without downtime? Add the new value as a second environment variable, deploy code that accepts either, rotate clients, then delete the old one — never swap a single variable in place, because both platforms restart the service on an environment change and any request mid-flight fails. The dual-accept pattern is covered in rotating API keys without downtime.
Do I need a container image, or is the platform's Python build enough? The native build is enough until you need a system package, a specific Python patch version, or byte-identical behaviour between CI and production. At that point a Dockerfile costs you one afternoon and buys reproducibility on Render, Railway, or Fly with no rewrite — which is the cheapest insurance against platform lock-in you can buy.
Related
Same topic area:
- Render vs Railway vs Fly.io — cold-start and autoscale numbers if you are still choosing a host.
- Zero-Downtime Deploys for Python APIs — readiness gates, graceful drain, and automatic rollback.
- Deploying FastAPI to Cloudflare Workers with Python — the isolate option and its dependency limits.
Adjacent areas:
- Containerizing Python APIs with Docker — one artifact that runs identically on any of these platforms.
- Calculating Cost per API Request — turn the hosting bill above into a defensible price.