Gravity Gravity Docs

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.