How we maintain infraheads.com
infraheads.com is a static site hosted on S3 and fronted by Cloudflare. No CMS, no application framework. The interesting part is the path a change takes from an English sentence to a purged CDN cache and search-index notification — five moving pieces, each doing one thing.
1. Prompt → diff
Edits start in the terminal. A request like "rename the nav link How We Help to Services" goes to an AI pair-programmer (Claude Code) that reads the repo, finds the right file, and applies a targeted diff. No scaffolding, no framework — just the HTML/CSS/JS already on disk.
The assistant summary describes intent; the PR describes reality. We review the PR before merging.
2. GitHub via the gh client
Every change goes through a pull request — no direct pushes to main. The whole flow stays in the shell via the gh client:
git checkout -b rename-nav-link
git commit -am "Rename nav link 'How We Help' to 'Services'"
git push -u origin rename-nav-link
gh pr create --title "Rename nav link to Services" --body "..."
gh pr checks --watch
gh pr merge --squash --delete-branch
The PR is the review artifact: file-by-file diff, checks status, and a trail of what shipped. After merge, gh run watch tails the deploy so we see the S3 sync and cache purge finish before context-switching.
3. Push to main → S3 sync
The first GitHub Actions workflow mirrors the repo into the S3 bucket that serves the site. It's deliberately minimal:
name: Deploy to S3
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- name: Sync site to S3
run: |
aws s3 sync . "s3://${{ vars.S3_BUCKET }}" \
--delete \
--exclude ".git/*" \
--exclude ".github/*" \
--exclude ".DS_Store"
--delete keeps the bucket in lockstep with the repo: if we remove a file locally, S3 reflects it on the next push. No drift, no orphaned assets.
4. Selective Cloudflare purge
S3 is the origin; Cloudflare sits in front with long TTLs. Blindly purging the whole zone on every commit wastes cache warmup, so the second workflow — chained via workflow_run — computes the diff and purges only what actually changed:
name: Purge Cloudflare Cache
on:
workflow_run:
workflows: ["Deploy to S3"]
types: [completed]
jobs:
purge:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
ref: ${{ github.event.workflow_run.head_sha }}
- name: Collect changed files
run: |
AFTER="${{ github.event.workflow_run.head_sha }}"
BEFORE=$(git rev-parse "${AFTER}^")
git diff --name-only --diff-filter=ACMRT "$BEFORE" "$AFTER" \
| grep -Ev '^(\.github/|\.gitignore|README\.md)' \
> changed.txt
A small shell loop turns changed paths into the URLs Cloudflare needs to invalidate. The one subtlety: our S3 bucket serves index.html at the folder URL, so editing post/foo/index.html has to purge both the file URL and /post/foo/:
while IFS= read -r f; do
echo "${SITE_ORIGIN%/}/$f" >> urls.txt
case "$f" in
*/index.html)
dir="${f%index.html}"
echo "${SITE_ORIGIN%/}/${dir}" >> urls.txt
echo "${SITE_ORIGIN%/}/${dir%/}" >> urls.txt ;;
esac
done < changed.txt
Cloudflare caps per-request purges at 30 URLs, so batches are split and submitted in parallel-friendly chunks:
split -l 30 urls.txt batch_
for b in batch_*; do
PAYLOAD=$(jq -R -s -c 'split("\n") | map(select(length > 0)) | {files: .}' "$b")
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "$PAYLOAD"
done
There's also a manual escape hatch — workflow_dispatch with a purge_all toggle — for the rare case we actually want to nuke the zone.
5. Tell the search engines
Purging the CDN is only half the story — Bing, Yandex, and friends still discover changes on their own schedule unless we nudge them. Two extras take care of that, in the same workflows.
Sitemap, rebuilt on every deploy. A small Python script walks every *.html in the repo and emits sitemap.xml with per-file lastmod pulled from git:
def git_lastmod(path):
out = subprocess.check_output(
["git", "log", "-1", "--format=%cs", "--", str(path)],
cwd=ROOT, text=True,
).strip()
return out or datetime.date.today().isoformat()
The deploy workflow runs it before the S3 sync, so /sitemap.xml is always a faithful snapshot of what's actually in the bucket.
IndexNow, after a successful purge. Right after Cloudflare acks the purge, the same URL list — filtered to HTML/XML — is POSTed to the IndexNow endpoint. A single request notifies Bing, Yandex, Seznam, and Naver simultaneously:
- name: Submit changed URLs to IndexNow
run: |
grep -E '(\.html?$|\.xml$|/$)' urls.txt > indexnow_urls.txt || true
PAYLOAD=$(jq -R -s -c \
--arg host "${INDEXNOW_HOST}" \
--arg key "${INDEXNOW_KEY}" \
--arg keyLoc "https://${INDEXNOW_HOST}/${INDEXNOW_KEY}.txt" \
'{host:$host, key:$key, keyLocation:$keyLoc,
urlList:(split("\n") | map(select(length > 0)))}' \
indexnow_urls.txt)
curl -sS -X POST "https://api.indexnow.org/IndexNow" \
-H "Content-Type: application/json; charset=utf-8" \
--data "$PAYLOAD"
The key is just a random 32-char hex string served at /<key>.txt; IndexNow fetches it to prove we own the host. The submission is non-fatal — if it fails, the cache is still correct; we just wait for the next natural crawl.
Google deliberately doesn't participate in IndexNow, and its Indexing API is restricted to JobPosting and BroadcastEvent. A fresh sitemap plus Search Console verification is all we get there — no shortcut.
Adding an AI concierge — without adding a server
The newest piece bends the "no application server" rule as little as possible. We wanted a real AI chatbot on the site — one that helps a visitor choose an Academy course or the right service — but an LLM call needs a secret API key, and anything shipped to the browser is public. Embed the key in chatbot.js and it gets scraped and billed against within days.
So the brain goes into the smallest server-side surface that can keep a secret: a Cloudflare Worker. It runs on the same zone that already fronts the site, so a route hands it every request to /api/* — no new domain, no CORS:
routes = [
{ pattern = "infraheads.com/api/*", zone_name = "infraheads.com" }
]
The browser widget POSTs the conversation to /api/chat; the Worker prepends a system prompt grounded in the site's courses and services, calls Claude (Haiku), and streams the reply back as server-sent events. The API key lives only as an encrypted Worker secret — never in the repo, never in the bucket, never in the browser.
Everything else reuses machinery already described. The widget is one self-contained file injected into every page by the same ensure-analytics.js step that stamps the analytics snippets. A third workflow deploys the Worker with wrangler whenever worker/** changes, and the S3 sync gains a single line — --exclude "worker/*" — so the Worker's source never ships to the public bucket. The bot stores nothing; it hands off to the existing contact form and Calendly link whenever a human is needed.
Because a public endpoint that spends money is a target, the Worker leans on cheap guards: an origin allowlist, a per-IP rate limit on Cloudflare's free-tier binding (no Durable Objects, which would force a paid plan), caps on message length and history, and a hard max_tokens. Cloudflare stays on the free plan; the only real cost is Claude tokens — a fraction of a cent per conversation.
Why split the workflows
Keeping deploy and purge in separate jobs keeps secrets scoped (AWS stays out of the Cloudflare job and vice versa), lets each retry independently, and makes the trace of what was deployed vs what was invalidated obvious in the Actions UI. The workflow_run trigger glues them together without coupling them.
End-to-end
From the user's side, the whole loop is:
# describe the change
claude "move the FAQ link after Contact in the nav"
# branch, commit, open PR
git checkout -b reorder-nav
git commit -am "Reorder nav: FAQ after Contact"
git push -u origin reorder-nav
gh pr create --fill
# review + merge
gh pr view --web
gh pr merge --squash --delete-branch
# watch the deploy + purge finish
gh run watch
Typical wall-clock from merge to purged CDN: under two minutes. The only things humans do are submit the prompt and review the PR.