Deployment & Server Modes
Gravity runs as a single ASGI app (asgi.py) under one of two server modes,
controlled by the GRAVITY_SERVER_MODE environment variable. Both modes run
the exact same application code — the difference is entirely in what
supervises the process, not in how requests are handled.
The two modes
uvicorn (default) |
gunicorn |
|
|---|---|---|
| What it is | A single async Uvicorn process | Gunicorn's arbiter managing a pool of uvicorn_worker-class processes |
| Crash recovery | None — a crashed process stays dead until something external restarts it | Automatic — the arbiter respawns any worker that dies |
| Memory-leak protection | None built in | max_requests/max_requests_jitter in gunicorn.conf.py periodically recycles workers |
| Horizontal scaling | Left entirely to the hosting platform | Also left to the platform, plus optional multi-process fan-out within one container (GUNICORN_WORKERS) |
| Best fit | A platform that already replaces unhealthy container instances and autoscales horizontally on its own | Anywhere nothing else is watching the process |
Concretely: Cloud Run, Azure Container Apps, and Cloudflare Containers all
supervise container instances themselves — a crashed container gets killed
and replaced by the platform within seconds, and each one already
autoscales horizontally. Running Gunicorn's process supervision inside a
container on one of these platforms is redundant, and running multiple
worker processes there fights the platform's own scaling model instead of
cooperating with it: a single async Uvicorn process can already serve many
concurrent requests, so the platform's own instance-level concurrency
setting (e.g. Cloud Run's --concurrency) does the scaling work that
multiple Gunicorn workers would otherwise have tried to do locally.
A standalone server, a bare VM, or a persistent docker compose up has
no such platform watching it. If the one process serving traffic there
crashes or slowly leaks memory over weeks of uptime, nothing notices or
recovers automatically — that's exactly what Gunicorn's arbiter is for.
Vercel is not in this table at all: it has no process manager in the picture, invoking the ASGI callable directly per request.
Defaults per target
| Target | Default | Why | Where it's set |
|---|---|---|---|
| Google Cloud Run | uvicorn |
Platform-supervised, autoscaled | scripts/deploy_gcp.sh |
| Azure Container Apps | uvicorn |
Platform-supervised (KEDA-based autoscaling) | scripts/deploy_azure.sh |
| Cloudflare Containers | gunicorn |
This deployment addresses one always-on named container instance (see cloudflare/worker.js's getByName("default")), not an autoscaled fleet — closer to a standalone server than to Cloud Run's model |
scripts/deploy_cloudflare.sh |
| Vercel | n/a | No process manager in Vercel's execution model | — |
Standalone server / persistent docker compose up |
gunicorn |
Nothing else supervises the process | docker-compose.yml, gravity-compose.yml |
make run-local (local dev) |
plain uvicorn --reload |
Fast iteration matters more than resilience during development | Makefile |
Overriding the default
Every deploy script accepts GRAVITY_SERVER_MODE as an environment variable
before running it, e.g.:
GRAVITY_SERVER_MODE=gunicorn ./scripts/deploy_gcp.sh
Or, once deployed, most platforms let you flip it after the fact without a redeploy:
# Cloud Run
gcloud run services update <service> --set-env-vars GRAVITY_SERVER_MODE=gunicorn
# Cloudflare (redeploy needed — env vars are baked in at deploy time)
GRAVITY_SERVER_MODE=uvicorn ./scripts/deploy_cloudflare.sh
How the switch actually works
scripts/entrypoint.sh is the one place the mode dispatch lives — the
Dockerfile's CMD always runs it, and it reads GRAVITY_SERVER_MODE at
container startup:
case "${GRAVITY_SERVER_MODE:-uvicorn}" in
gunicorn) exec gunicorn --config gunicorn.conf.py asgi:app ;;
uvicorn|*) exec uvicorn asgi:app --host 0.0.0.0 --port "${PORT:-8000}" \
--workers "${UVICORN_WORKERS:-1}" ;;
esac
gunicorn.conf.py stays in the repo either way — it's simply unused when
GRAVITY_SERVER_MODE=uvicorn. Its worker_class is set to
uvicorn_worker.UvicornWorker, which lets Gunicorn's arbiter manage
processes that still run the ASGI app correctly (Gunicorn's own sync
worker class only understands WSGI).
Verifying the difference yourself
Both modes should serve identical responses — the only difference is resilience under a crash, which is otherwise invisible in normal operation. To see it directly:
# Terminal 1 — start under each mode and note the process tree
GRAVITY_SERVER_MODE=uvicorn ./scripts/entrypoint.sh &
GRAVITY_SERVER_MODE=gunicorn ./scripts/entrypoint.sh &
# Terminal 2 — kill a worker process under each and watch what happens
kill -9 <worker-pid>
# uvicorn mode: that process stays dead, capacity is silently reduced
# gunicorn mode: the arbiter logs a respawn and a new worker takes its place
Connecting a Git repository (optional)
make deploy pushes straight from your local machine — nothing about it
requires a git remote. make push-repo (scripts/push_repo.sh) is a
separate, opt-in step for the common next step: creating a real GitHub or
GitLab repository for the project and pushing to it.
make push-repo # GitHub, private, repo name = directory name
PROVIDER=gitlab make push-repo # GitLab instead
REPO_VISIBILITY=public make push-repo
REPO_NAME=my-site make push-repo
It requires the corresponding CLI, already authenticated — gh auth login
for GitHub, glab auth login for GitLab. Re-running it after the repo
exists just pushes your latest commit.
One thing it handles deliberately: a site installed via scripts/install.sh
is a git clone of EIM's shared open-source template, so its origin
already points at that shared repo, not a project of your own. push-repo
detects that (any origin matching eim_opensource/gravity-python), renames it to
upstream so you can still pull template updates later, and creates your
own repo as the new origin instead of pushing your site into the shared
template.
On its own, make push-repo doesn't change how make deploy behaves — Vercel
still deploys straight from your machine. To make Vercel rebuild
automatically on every future git push instead, opt in explicitly:
make deploy PLATFORM=vercel VERCEL_LINK_GIT=1
This isn't the default: not every project wants Vercel to own its deploy
trigger, and it requires a git remote to exist first (run make push-repo
beforehand). GCP and Cloudflare don't have an equivalent flag yet — their
deploys already build and push a container image directly, so there's no
platform-native "watch this repo" hook to wire up the same way.
Edge caching
Adhara-backed content (currently: the blog — /blog, /blog/<slug>) doesn't
need to make a round trip to Adhara on every single request. app/caching.py's
edge_cache() marks a response cacheable with a standard Cache-Control
header — the one mechanism every platform's edge network actually understands,
instead of a different setting per target:
Cache-Control: public, max-age=0, s-maxage=<TTL>, stale-while-revalidate=<SWR>
max-age=0 tells a visitor's own browser to always revalidate (so their back
button never shows them something stale); s-maxage, which a shared/edge cache
reads in preference to max-age, tells the CDN to actually serve the cached
copy for GRAVITY_EDGE_CACHE_TTL seconds (default 300) before checking again,
plus GRAVITY_EDGE_CACHE_SWR seconds (default 60) of serving a stale copy
while it re-fetches in the background. Set GRAVITY_EDGE_CACHE_TTL=0 to
disable this outright.
Never applied to a logged-in admin's own view of the same page — a handful
of otherwise-public pages render an admin-only "manage posts"/"edit this post"
button directly into the HTML (app/theme.py::_edit_bar()), so edge_cache()
re-checks that exact same gate and marks the response private, no-store
instead whenever it's active, regardless of the TTL. See app/caching.py's
module docstring for why this matters — it isn't just a staleness question, it
would show one visitor's admin session to the next anonymous one.
What actually benefits, per target:
| Target | Honors Cache-Control today? |
|---|---|
| Vercel | Yes — Vercel's Edge Network caches Serverless Function responses according to Cache-Control automatically. Nothing else to configure. |
| Cloudflare | Yes — cloudflare/worker.js explicitly reads the container's Cache-Control response and stores/serves it via the Workers Cache API itself, rather than assuming Cloudflare's edge does this automatically for a Containers-binding response the way it does for a normal proxied origin (not a documented guarantee for that product). |
| GCP Cloud Run | Not automatically — Cloud Run has no built-in edge cache. The header is already correct and ready; it takes effect once Cloud CDN is placed in front (a Global External HTTPS Load Balancer + Serverless NEG pointing at the Cloud Run service — a separate resource scripts/deploy_gcp.sh doesn't provision today). |
| Azure Container Apps | Same story as Cloud Run — needs Azure Front Door or Azure CDN in front; scripts/deploy_azure.sh doesn't provision one today. |
Extending this to another route (events, docs, ...): call
edge_cache(response, request) from the controller after declaring a
response: Response parameter — see app/controllers/blog.py for the pattern,
and AGENTS.md
at the repo root.