Deploying
One command per platform — Vercel, Cloudflare, Google Cloud Run, or Netlify — plus storage provisioning and Git integration.
make setup # interactive menu
make deploy PLATFORM=vercel # or gcp | cloudflare | netlify
Each make deploy-* prompts for anything missing (SECRET_KEY, persistent storage if none
is configured yet — defaulting to that platform’s own storage option), saves answers to
.env, and prints the live URL.
Vercel
make deploy-vercel
@astrojs/vercel builds a real Vercel Function — no config file needed for the default
case. Storage default: Vercel Blob.
Connecting the repo straight to Vercel’s own git integration also works — Import Project
from the Vercel dashboard (or GitLab), no make deploy-vercel and no env vars configured at
all. astro.config.mjs auto-detects it’s building on Vercel from Vercel’s own VERCEL
system env var and picks the right adapter automatically — see
Project Structure. make deploy-vercel still adds
real value on top of that (persistent storage provisioning, an actual SECRET_KEY, syncing
Adhara config) — it’s just no longer required for the site to build and serve correctly.
SECRET_KEY specifically isn’t read anywhere in this Node port today (no local session
signing exists yet — admin auth validates a real Adhara token instead), so a deploy with it
unset works fine; it’s asked for as forward-looking best practice, carried over from the
Python original where it’s load-bearing, not because anything here currently depends on it.
Google Cloud Run
make deploy-gcp
Builds and deploys this repo’s Dockerfile via Artifact Registry + Cloud Run. Requires
gcloud + Docker. Storage default: Google Cloud Storage.
Cloudflare
make deploy-cloudflare
Deploys a real Cloudflare Worker via @astrojs/cloudflare — no Docker, no container, no
proxy layer. (The Python original has to run Flask/Gunicorn behind a Cloudflare Container +
Durable Object, since Workers’ V8-isolate runtime can’t run Python; Astro compiles straight
to a Worker, a genuine simplification this port gets for free.) Storage default: Cloudflare
R2.
Netlify
(new — not in the Python original)
make deploy-netlify
@astrojs/netlify builds Netlify Functions + edge middleware automatically. Storage
default: Netlify Blobs (zero-provisioning — auto-available once deployed on Netlify).
Persistent storage
Every target above has an ephemeral filesystem by default — locally-stored content and
uploaded media need one of the storage backends in Storage to survive across
deploys/restarts. make deploy-* prompts you through provisioning it; set the relevant env
vars yourself beforehand to skip that prompt, or set GRAVITY_STORAGE_SKIP=1 to opt out of
the storage wizard entirely and stay on ephemeral local files.
Edge caching
Public, non-personalized pages are cached at the edge, configurable via GRAVITY_EDGE_CACHE_TTL
(default 60 seconds) and GRAVITY_EDGE_CACHE_SWR (stale-while-revalidate window, default
300 seconds) — set to 0 to disable entirely. This is the real fix for the concern behind
it: several cached routes (blog, events, links, shop, scheduling’s index, podcast, gallery)
read from Adhara on every render — without caching, that’s a live Adhara round trip on every
single visit. A first request renders normally and gets cached; every request after that,
until the TTL expires, is served without touching Adhara at all.
This uses Astro’s own built-in cache system (cache/routeRules in astro.config.mjs,
stable in this Astro version, not something hand-rolled for this project) — not a bespoke
mechanism, and not just HTTP headers hoping a CDN respects them. Each deploy target gets its
own real cache provider, resolved automatically at build time from DEPLOY_TARGET, the
same pattern as adapter selection:
| Target | Provider | What it actually is |
|---|---|---|
vercel |
@astrojs/vercel/cache |
Vercel’s real CDN (Vercel-CDN-Cache-Control / Vercel-Cache-Tag) |
netlify |
@astrojs/netlify/cache |
Netlify’s durable cache (Netlify-CDN-Cache-Control / -Cache-Tag) |
cloudflare |
@astrojs/cloudflare/cache |
Cloudflare Workers’ built-in cache API, auto-tagged by path — no Cloudflare dashboard Cache Rule needed, unlike a plain Cache-Control-header-only approach |
node |
astro/cache/memory |
An in-process memory cache — there’s no CDN in front of a bare Node/Docker/Cloud Run deploy by default, so this is what actually avoids the Adhara round trip there. Still emits standard cache headers too, so a real CDN placed in front (Cloud CDN, etc.) benefits as well |
Verified directly, not assumed: a first GET to a cached route returns X-Astro-Cache: MISS;
a second one within the TTL returns X-Astro-Cache: HIT (in-process provider) with no
re-render. /admin, /portal, /api/*, cart/checkout, and /schedule/[slug] (which shows
live booking availability — caching it would show stale open slots, a real correctness bug,
not just a staleness inconvenience) are deliberately never cached. /docs/* isn’t in the
cache config at all — those pages are prerendered at build time, not server-rendered per
request, so route-level caching doesn’t apply to them.
Caching is always fully disabled under npm run dev regardless of the env vars — this is
Astro’s own built-in behavior, not something this project added, so local development never
shows stale content. Route patterns and per-route tags live in astro.config.mjs’s
CACHEABLE_ROUTES/ROUTE_TAGS — add a new public, non-personalized route there if you add
one, following the existing pattern.
A known, deliberate gap: routes are tagged by content type (blog, events, shop, …)
specifically so a future write path could call cache.invalidate({ tags: ['blog'] }) right
when a post is published, purging the cache immediately instead of waiting out the TTL — the
tags are wired up and ready for this, but nothing calls invalidate() yet. That needs request
context (cache.invalidate() lives on the Astro request/middleware context) threaded into
blogStore.ts/eventStore.ts/etc.’s write paths, which today are also called from plain CLI
scripts with no request context at all — a real design question, not a small patch, so it’s
left as a documented next step rather than guessed at.
Deploying from a prompt (agent shells)
All four deploy_*.sh scripts, and the storage-provisioning wizard they share
(scripts/lib/storage_setup.sh), are safe to run with stdin not a TTY — the shape an
agent shell (Claude Code, Codex, CI) runs commands in. Every interactive prompt degrades to a
sensible default instead of hanging or hard-failing:
SECRET_KEYauto-generates instead of failing with “SECRET_KEY is required.”- Storage auto-provisions the platform’s recommended backend (Vercel Blob / GCS / R2 /
Netlify Blobs) instead of skipping with just a warning. Set
GRAVITY_STORAGE_SKIP=1to opt out instead. A backend that needs real credentials it can’t invent (Cloudflare R2’s access key/secret) fails cleanly with the exact env vars to set, rather than hanging on areadthat will never get input — and that failure degrades to the local-storage fallback rather than aborting the whole deploy.
Git integration
make push-repo # PROVIDER=github|gitlab, default github
make deploy PLATFORM=vercel VERCEL_LINK_GIT=1 # connect the pushed repo to Vercel
make push-repo (scripts/push_repo.sh) creates a GitHub or GitLab repository via the
gh/glab CLI (needs one of them already authenticated — see
Install Your Tools) and pushes this code to it — safe to re-run any
time to push the latest commit. If this repo’s origin still points at the shared
open-source template (a fresh git clone of it does), it’s renamed to upstream first so
you keep the ability to pull template updates, and a new repo of your own is created instead
of pushing into the shared template.
Not run automatically by anything else here — it’s opt-in, paired with VERCEL_LINK_GIT=1
on deploy_vercel.sh (Vercel only, for now) to connect the pushed repo to the Vercel
project, so a future git push triggers an automatic rebuild there instead of needing to
re-run the deploy script by hand.
Reference
Full env var and CLI command tables live in Environment Variables and CLI Reference.