Building and Deploying an Astro Static Site Behind a Cloudflare Tunnel
An operator's recipe for taking a static Astro site from `npm run build` to a live URL behind a Cloudflare Tunnel, with rsync, edge cache purges, and the three-SHA deploy gate.

What you’ll have at the end
An Astro site that lives locally, builds cleanly with npm run build, ships to /var/www/agenticbotsitter/ over rsync, sits behind a named Cloudflare Tunnel, and is reachable at https://<your-domain>. The same recipe runs reviews every time you push a new post — under 20 minutes per publish.
This guide assumes you’ve read the companion Cloudflare Tunnel setup guide at /guides/cloudflare-tunnel-static-site/ — at minimum, you have a running cloudflared tunnel run that already proxies one hostname. If you don’t, read that first.
The five-stage deployment loop
| Stage | Where it runs | What it does |
|---|---|---|
| 1. Author + commit | /srv/abs-site/src/content/posts/<slug>.md | Write the post; commit when ready |
| 2. Build | npm run build from /srv/abs-site | Astro SSG → dist/ (146 MB on ABS today) |
| 3. rsync | dist/ → /var/www/agenticbotsitter/ | Overwrite webroot; preserve immutable caches via cache-bust query strings |
| 4. Cloudflare edge purge | CF API /zones/<id>/purge_cache | Invalidate /guides/, /reviews/, /news/, /images/<slug>, etc. |
| 5. Three-SHA deploy gate | git rev-parse against local + origin + github | Confirm the same SHA is reachable from all three roots before declaring done |
Why the loop has five stages (and not three)
Build-and-ship looks like two steps. The reason it isn’t: Cloudflare caches every /<page> and /images/<slug>.* URL the first time it sees one. A brand-new image path returns a 404 from the edge for the first ~30-60 seconds (CF “drain-on-first-deploy” pattern documented in skill P29). The edge purge is what unblocks the cache, so it has to ship as part of the loop, not as a post-deploy cleanup. The three-SHA gate is the operator-facing part: GitHub is the live truth, and a fix that exists only on /var/www/ and not on github.com/MarvinAi5/ABS will look gone to the next visitor who pushes through it.
Step 1: Author + commit
Markdown posts live at src/content/posts/<slug>.md. The schema (in src/content.config.ts) requires:
title≤ 120 charsdescription≤ 200 charstype∈ [news, review, guide, take]category∈ [ai, gadgets, computers, gaming]imageunder/images/pubDateISO date string
If any of those exceeds, the Astro build fails loudly with InvalidContentEntryDataError. Verify before committing.
Step 2: npm run build
cd /srv/abs-site
npm run build
The pipeline is node tools/score_reviews.mjs && astro build && node tools/finalize-matrix.mjs && node tools/verify-matrix-route.mjs && node tools/affiliate-link-tag.mjs. On the ABS tree today this takes ~16 seconds end-to-end (astro build alone is ~13 seconds; the surrounding toolchain adds the rest) and produces dist/ at 146 MB.
Why the post-build tools run: score_reviews recomputes grade chips and ranking, finalize-matrix + verify-matrix-route write a literal .html URL that the AI Model Matrix needs on the apex domain, and affiliate-link-tag rewrites plain Amazon links with the Associates tracking tag (agenticbotsit-20).
Step 3: rsync to the origin
rsync -a --delete /srv/abs-site/dist/ /var/www/agenticbotsitter/
The --delete removes stale files. The dist tree is ~146 MB dominated by dist/images/ (132 MB of 32 reviews × PNG + WebP companions; 32 WebP conversions on disk). The origin server (Caddy on 127.0.0.1:8793 per the cloudflared ingress) serves from this directory.
Step 4: Cloudflare edge cache purge
For a single new post:
TOKEN=$(python3 -c "from pathlib import Path; print([l.split('=',1)[1] for l in Path('/opt/data/.env').read_text().splitlines() if l.startswith('CLOUDFLARE_API_TOKEN=')][0])")
ZONE=$(curl -sS -H "Authorization: Bearer ${TOKEN}" "https://api.cloudflare.com/client/v4/zones?name=agenticbotsitter.com" | python3 -c "import json,sys; print(json.load(sys.stdin)['result'][0]['id'])")
URLS='["https://agenticbotsitter.com/guides/<slug>/", "https://agenticbotsitter.com/guides/", "https://agenticbotsitter.com/images/<slug>.png", "https://agenticbotsitter.com/images/<slug>.webp", "https://agenticbotsitter.com/index.html", "https://agenticbotsitter.com/sitemap-0.xml"]'
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"files\": ${URLS}}"
/sitemap-0.xml is included so the next crawl sees the new page; the index pages are purged because the homepage shows recent posts; the image URLs are purged because the cloudflared cache paths treat them as cold until hit.
Step 5: Three-SHA deploy gate
LOCAL=$(git -C /srv/abs-site rev-parse HEAD)
ORIGN=$(git -C /srv/abs-site ls-remote origin refs/heads/main | cut -f1)
GITHUB=$(git -C /srv/abs-site ls-remote github refs/heads/main | cut -f1)
echo "local: $LOCAL"
echo "origin: $ORIGN"
echo "github: $GITHUB"
[ "$LOCAL" = "$ORIGN" ] && [ "$ORIGN" = "$GITHUB" ] && echo OK || echo MISMATCH
If the SHA mismatch persists past one minute after the push, the post-receive hook probably failed — check /opt/data/logs/abs-deploy.log.
Verification checklist
| # | Step | Command | What it proves |
|---|---|---|---|
| 1 | Schema | npm run build exits 0 | Frontmatter is valid; build is green |
| 2 | Origin | curl -sS -I https://agenticbotsitter.com/guides/<slug>/ | rsync landed; origin serves the new path |
| 3 | Edge | curl -sS -I https://agenticbotsitter.com/guides/<slug>/ (twice) | CF cache has the new content; not the 404 drain |
| 4 | Tunnel | cloudflared tunnel info <tunnel-name> | The connector is still bound to the same UUID |
| 5 | SHAs | See Step 5 above | The fix is in three places: local + origin + GitHub |
Common failures
| Symptom | First check | Fix |
|---|---|---|
npm run build fails | head -50 src/content/posts/<slug>.md for the YAML frontmatter | Title/description/category overflow; trim and rebuild |
| rsync appears silent | ls -la /var/www/agenticbotsitter/<slug>/ | Webroot permissions; sudo chown -R to your uid if needed |
| CF purge 401 / 403 | `echo $CLOUDFLARE_API_TOKEN | head -c 8` |
| Dist size balloons | du -sh dist/images/* | A new review PNG ran without WebP conversion (P42); run node tools/convert_reviews_to_webp.js |
| Post-commit but origin SHA is one commit behind | cat /var/log/abs-deploy.log on the origin server | Hook failed mid-build; git reset --hard |
What not to do
- Don’t skip the edge purge. A new image URL is cached as 404 for up to a minute by CF; the purge is what makes it serveable.
- Don’t gate on
git pushalone. Thegit pushtriggers the bare repo on origin to rebuild + rsync; if the post-receive hook fails, push doesn’t tell you. Always compare SHAs. - Don’t keep old image variants in
public/images/“in case.” A zero-byte cache invalidation path will serve the old WebP from edge for hours.
Sources
- Astro docs: content collections
- Cloudflare Tunnel install and setup
- Cloudflare cache purge API
- ABS companion guide: Cloudflare Tunnel Setup for a Static Site
- ABS companion troubleshooting: How to troubleshoot a Cloudflare Tunnel
Last verified: 2026-07-14 against Astro, the Cloudflare API, and the live ABS origin/tunnel/edge. Re-run npm run build and confirm the dist tree before following a step that touches the edge cache.

Submit a take
Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.