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_KEY auto-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=1 to 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 a read that 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.