guide · computers

Static Site Deploy: rsync, Cloudflare Purge, CDN Cache — The 3-Step Loop

The static-site deploy loop and what each step does: rsync dist/ to the webroot, ordered CF purge (HTML -> ASSETS -> BUNDLE), then verify. Plus failure shapes and the order that minimizes 404 leaks.

July 14, 2026 · By Alastair Fraser

A friendly retro robot operating a 3-step assembly line: on the left an rsync step with a copy arrow pointing to disk icons, in the middle a CF PURGE label between two arrows, and on the right a green CHECKMARK verifying the deploy through a small CLI terminal window.

What the loop is

The 3-step deploy loop on a Cloudflare-fronted static site:

  1. rsync -a --delete /srv/abs-site/dist/ /var/www/agenticbotsitter/ — syncs new HTML + assets to disk on the VPS.
  2. CF edge cache purge — flushes 12 specific URLs to invalidate any stale edge POPs.
  3. curl -I <host> verification — confirms the cache says HIT on the new asset within 60 seconds.

Each step is reversible (rsync with --delete is destructive but backed-up via git; purge only invalidates the cache layer; verify is read-only).

Step 1: rsync the dist to the webroot

rsync -a --delete /srv/abs-site/dist/ /var/www/agenticbotsitter/

The --delete flag is what makes this a deploy, not a copy. Without it, deleted files linger on the webroot and the server keeps serving them after the source is gone.

The -a flag enables archive mode: recursive, symlinks preserved, permissions preserved, timestamps preserved. Use this; do not skip.

For sites that are only a single HTML folder, this is the whole on-disk step. The webroot here is owned by root; if you’re working as a non-root user, prepend sudo or run the script as the deploy cron. A silent permission denied is the most common Step 1 failure — verify with ls -la /var/www/agenticbotsitter/ showing the new timestamp on index.html.

Before rsync: the cache-bust

The build produces HTML that links to images and JS with a ?v=20260714-N query string (CB constant in Base.astro). Bump the constant in Base.astro before npm run build, not after — the value lands in the HTML at build time. If you forget, the HTML points to the old ?v= and the CF edge keeps serving the previously-cached image at the stale URL. The fix is one constant change, one rebuild, one rsync — not three separate rsync steps.

Special case: JS scripts

The CF edge caches anything under /scripts/* for 4 hours by default. If a guide, the news brief, or the deal-matrix dashboard ships with new JS, the cache-bust CB constant must change and the ?v= query string must appear on every <script src="/scripts/foo.js?..."> reference. Without the query string, the deploy ships but the JS doesn’t change for 4 hours — the file on the server is correct but the user gets stale JS for the rest of the cache lifetime.

Step 2: purge the 12 URLs

Twelve URLs is the right number for a one-new-guide deploy: the new guide, the new PNG, the new WebP, the guides index, the news index, the reviews index, the homepage, the sitemap, the RSS, the 301 redirect rules, the CNAME layout, and the brand-new asset bundle if any.

The actual invocation against the ABS zone:

curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "$(/opt/data/scripts/build_purge_payload.py)" | jq '.success'

Run the script. It builds the right URL list based on what changed in git diff --name-only HEAD~1 HEAD.

Step 3: verify the cache is hot

for i in 1 2 3 4 5; do
  hit=$(curl -sS -I "https://agenticbotsitter.com/guides/${GUIDE}/" | grep -i 'cf-cache-status' | tr -d '\r')
  echo "$i: $hit"
  sleep 5
done

Expect MISS for the first 1-3 attempts, then HIT. If MISS past attempt 5, the wrong URL got purged or the tunnel is dead.

What goes wrong

#FailureSymptomFix
1rsync silent permission deniedWebroot updated for files you can write, 403 on the restRun rsync as the webroot owner; check stat /var/www/agenticbotsitter first
2CF purge wrong zone.success is false, no edge changeRe-export ZONE from the deploy env file; never hard-code zone IDs
3CDN 504 on PUP originFirst request after purge returns 504 from edgeOrigin daemon is sized for steady-state, not a flush surge; purge in smaller URL sets
4rsync stalls on large single fileHangs at a single filename for 10+ minutesCheck the source file size; if it’s a build artifact >500MB, split the build step
5Verify shows MISS past 5cf-cache-status: MISS for a minute after purgeWrong URL was purged; re-run with the exact path the HTML links to

Regression discipline

Before pushing the deploy to GitHub, run the full regression suite. Not a smoke check, not a curl probe — the full suite, which captures the bug history of the site. Every regression test fails means a real bug came back; the push is blocked until the test passes.

cd /srv/abs-site/scripts
for s in test_abs_*.py; do
  python3 "$s" || { echo "FAIL: $s"; exit 1; }
done

The suite grows monotonically — every bug found, verified, fixed is now a test that re-runs forever. If you find a bug during the deploy (e.g. a guide’s image URL is wrong), fix it, then add a regression test for it, even if the bug is small. The discipline is the reason the suite catches regressions the next time somebody touches a similar feature.

Skipping the regression suite to ship faster is the standard cause of “the deploy worked but the bug came back tomorrow.” Don’t skip it.

What not to do

  • Don’t scp the dist to the VPS instead of rsync — scp doesn’t respect --delete, and missing files will keep being served.
  • Don’t purge everything with purge_everything: true — that means CF goes back to origin for every request for 1-2 minutes, which is the surge your origin daemon was sized to avoid.
  • Don’t skip the verify step — the only signal you have that the deploy worked is the cf-cache-status header.
  • Don’t add 3rd-party CDNs (CloudFront, Fastly) on top of CF without re-doing the loop — they add their own cache layer and the purge of one doesn’t reach the other.
  • Don’t ship a deploy during peak traffic without first running step 2’s purge with a smaller URL set — the surge of MISS responses can overload the origin.

Verification checklist

#CommandWhat it proves
1curl -sS https://agenticbotsitter.com/guides/${GUIDE}/ | head -1Page renders
2curl -sS -I https://agenticbotsitter.com/images/${IMG}.webp | grep cf-cache-statusImage is on the edge
3curl -sS https://agenticbotsitter.com/sitemap-0.xml | grep ${GUIDE}Sitemap includes the new guide
4tail /var/log/cloudflared.log | grep Registered tunnelTunnel forwarded the deploy time

Sources

Last verified: 2026-07-14 against the live ABS deploy loop.

Sources

#deploy#rsync#cloudflare#purge#static-site#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.

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