guide · computers

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.

July 14, 2026 · By Alastair Fraser

An Astro static site tree flowing through build, regression, rsync, and a Cloudflare edge node.

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

StageWhere it runsWhat it does
1. Author + commit/srv/abs-site/src/content/posts/<slug>.mdWrite the post; commit when ready
2. Buildnpm run build from /srv/abs-siteAstro SSG → dist/ (146 MB on ABS today)
3. rsyncdist//var/www/agenticbotsitter/Overwrite webroot; preserve immutable caches via cache-bust query strings
4. Cloudflare edge purgeCF API /zones/<id>/purge_cacheInvalidate /guides/, /reviews/, /news/, /images/<slug>, etc.
5. Three-SHA deploy gategit rev-parse against local + origin + githubConfirm 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 chars
  • description ≤ 200 chars
  • type ∈ [news, review, guide, take]
  • category ∈ [ai, gadgets, computers, gaming]
  • image under /images/
  • pubDate ISO 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

#StepCommandWhat it proves
1Schemanpm run build exits 0Frontmatter is valid; build is green
2Origincurl -sS -I https://agenticbotsitter.com/guides/<slug>/rsync landed; origin serves the new path
3Edgecurl -sS -I https://agenticbotsitter.com/guides/<slug>/ (twice)CF cache has the new content; not the 404 drain
4Tunnelcloudflared tunnel info <tunnel-name>The connector is still bound to the same UUID
5SHAsSee Step 5 aboveThe fix is in three places: local + origin + GitHub

Common failures

SymptomFirst checkFix
npm run build failshead -50 src/content/posts/<slug>.md for the YAML frontmatterTitle/description/category overflow; trim and rebuild
rsync appears silentls -la /var/www/agenticbotsitter/<slug>/Webroot permissions; sudo chown -R to your uid if needed
CF purge 401 / 403`echo $CLOUDFLARE_API_TOKENhead -c 8`
Dist size balloonsdu -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 behindcat /var/log/abs-deploy.log on the origin serverHook 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 push alone. The git push triggers 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

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.

Sources

#astro#cloudflare#tunnel#static-site#deploy#npm#rsync#devops

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.

Your email address will not be published. Required fields are marked.