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.

Runtime model decision tree Two questions decide the host: persistent work pushes you to a Render web service, otherwise bursty idle traffic favours Vercel functions and steady traffic favours a flat-priced Render instance. Needs WebSockets, Celery, or requests over 60s? yes no Render web service one process, always up Bursty traffic with long idle gaps between calls? yes no Vercel functions pay per invocation Render starter, $7 flat monthly cost

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.

Python
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.

Render service topology Client traffic hits the Render router, which health-checks and forwards to a Gunicorn web service; a separate worker service shares the managed Postgres and key value store in the same region. Render region: oregon Client TLS + router /health each 30s Web service gunicorn -k uvicorn 2 workers, 512 MB Worker service Celery or arq queue Managed Postgres internal URL, pooled Key value store cache and job broker

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.

Yaml
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.

Json
{
  "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.

Python
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.

Time to first byte by runtime state A warm Render process answers in about 55 milliseconds, a warm Vercel function in 60, a cold lean function in 660, and a cold function carrying heavy scientific dependencies in 2840. 0 1000 2000 3000 ms to first byte Render, warm 55 ms Vercel, warm 60 ms Vercel, cold lean imports 660 ms Vercel, cold pandas + numpy 2840 ms Measured on a 1 GB function, us-east, FastAPI 0.115 with an empty handler body.

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.

VariableDefaultProduction setting
WEB_CONCURRENCY22 per 0.5 vCPU
DATABASE_URLnonepooled connection string
DB_POOL_SIZE55 container, 1 serverless
ALLOWED_ORIGINSemptyexplicit origin list
LOG_LEVELINFOINFO, DEBUG in staging
APP_VERSIONpyprojectinjected 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.

Monthly compute cost by traffic tier Render costs 7, 7, 25 and 50 dollars a month across 100K to 100M requests, while Vercel stays at 20 dollars until execution duration exceeds the plan allowance and then jumps to 440. Requests per month 100K 1M 10M 100M Render container $7 $7 $25 $50 Vercel functions $20 $20 $20 $440 Cheaper option Render Render Render Render

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.

Serverless connection fan-out Forty concurrent function sandboxes each opening five connections demand two hundred; a transaction-mode pooler multiplexes them onto twenty real Postgres connections, while a direct route exceeds the ninety-seven connection limit. 40 sandboxes x 5 pool connections = 200 requested function sandbox 1 function sandbox 2 function sandbox 3 ... up to sandbox 40 Transaction pooler PgBouncer or Supavisor multiplexes to 20 20 Postgres max_connections 97 headroom intact direct connections: 200 > 97, FATAL too many clients

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.

Bash
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.


Same topic area:

Adjacent areas: