From cf-static-site
Use this skill when setting up a new static site on Cloudflare. Two paths — (1) full stack: Astro + Cloudflare Workers + Terraform + GitHub Actions tag-driven deploys + SSM secrets; (2) plain static HTML + Cloudflare Pages + no-Terraform bootstrap script. Trigger phrases: "set up a new site", "bootstrap cloudflare site", "new cloudflare workers site", "reproduce the worldfoundry backend", "spin up a new domain".
How this skill is triggered — by the user, by Claude, or both
Slash command
/cf-static-site:cf-static-siteThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Two paths — choose before starting:
templates/Taskfile.ymltemplates/astro.config.mjstemplates/infrastructure/cloudflare/bootstrap/main.tftemplates/infrastructure/cloudflare/global/backend.tftemplates/infrastructure/cloudflare/global/email_routing.tftemplates/infrastructure/cloudflare/global/outputs.tftemplates/infrastructure/cloudflare/global/providers.tftemplates/infrastructure/cloudflare/global/variables.tftemplates/infrastructure/cloudflare/global/workers.tftemplates/infrastructure/cloudflare/global/zone.tftemplates/infrastructure/cloudflare/iam-self/backend.tftemplates/infrastructure/cloudflare/iam-self/providers.tftemplates/infrastructure/cloudflare/iam-self/token.tftemplates/package.jsontemplates/pnpm-workspace.yamltemplates/scripts/bootstrap-site.shtemplates/scripts/check-links.jstemplates/scripts/lighthouse-threshold.shtemplates/scripts/secrets-bootstrap.shtemplates/scripts/secrets-pull.shTwo paths — choose before starting:
| Path 1 — Astro + Workers | Path 2 — Static HTML + Pages | |
|---|---|---|
| When to use | Full production site, SSR, CSP nonce, scroll animations | Placeholder or early-stage site before design is decided |
| Framework | Astro | None (raw HTML) |
| Hosting | Cloudflare Workers | Cloudflare Pages |
| Infra | Terraform (bootstrap → global → iam-self) | scripts/bootstrap-site.sh only |
| Secrets | AWS SSM | GitHub Actions secrets (no AWS) |
| Deploy | wrangler deploy | wrangler pages deploy site/ |
For Path 1, continue with Phase A below. For Path 2, jump directly to the Pages path section.
All template files live in templates/ alongside this skill file. For each file: read the template, substitute all <PLACEHOLDER> tokens with the user's values, write to the target project.
1. Domain — apex domain, e.g. worldfoundry.org
2. Slug — short kebab-case resource prefix, e.g. wf-org or sl
Used as: Worker name, S3 bucket prefix, SSM path prefix, token name
3. AWS region — for Terraform state backend, e.g. us-west-2
4. Email dest — forward hello@<domain> to this address, or "none" to skip email routing
5. GitHub username — your GitHub username or org, e.g. wbniv
Repo is derived as <GH_USER>/<DOMAIN>
Don't proceed until you have all five answers.
Derive these additional values from the inputs before starting:
<SLUG_UPPER> — slug uppercased, hyphens → underscores (e.g. wf-org → WF_ORG)| Placeholder | Example | Source |
|---|---|---|
<DOMAIN> | worldfoundry.org | input 1 |
<SLUG> | wf-org | input 2 |
<SLUG_UPPER> | WF_ORG | derived from slug |
<REGION> | us-west-2 | input 3 |
<EMAIL_DEST> | [email protected] | input 4 |
<GH_USER>/<DOMAIN> | wbniv/worldfoundry.org | input 5 |
<ACCOUNT_ID> | abc123… (32-char hex) | output of cloudflare-domain-setup (Phase A) |
<ZONE_ID> | def456… (32-char hex) | output of cloudflare-domain-setup (Phase A) |
<PROJECT_NAME> | foundrylinux-org | slug with . replaced by - (Pages project name) |
<GH_ORG> | foundry-linux | GitHub org or username |
<GH_REPO> | foundrylinux.org | GitHub repo name (often same as <DOMAIN>) |
Invoke the cloudflare-domain-setup skill now.
That skill handles everything: account setup, bootstrap token creation (automated via meta-token), zone creation, nameserver lookup, and polling for activation. It outputs the three values you need here.
When cloudflare-domain-setup completes, export its outputs:
export CLOUDFLARE_API_TOKEN=$CF_TOKEN # bootstrap token
export TF_VAR_account_id=$CF_ACCOUNT_ID
export ZONE_ID=$ZONE_ID # needed for Phase D
Then continue to Phase B.
Read templates/infrastructure/cloudflare/bootstrap/main.tf, substitute placeholders, write to infrastructure/cloudflare/bootstrap/main.tf.
Apply:
cd infrastructure/cloudflare/bootstrap
terraform init && terraform apply
Read and substitute each file in templates/infrastructure/cloudflare/global/, write to infrastructure/cloudflare/global/.
If email dest is none: omit email_routing.tf entirely (don't copy it).
Prerequisite — remove non-Cloudflare MX records. Cloudflare Email Routing cannot be enabled while foreign MX records exist in the zone (API returns 409 code 2008). Before applying, run:
ZONE_ID=$(curl -sf \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=<DOMAIN>&account.id=$TF_VAR_account_id" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
../python-tui-lib/scripts/cf-remove-foreign-mx.sh "$ZONE_ID"
Or from a project that has the Taskfile wired: task remove-foreign-mx.
Apply:
cd infrastructure/cloudflare/global
terraform init
terraform apply -var="account_id=$TF_VAR_account_id"
The zone_id Terraform output should match $ZONE_ID from Phase A — if Terraform reports a different value, use the Terraform output.
Email routing note: On first apply, Cloudflare emails a verification link to
<EMAIL_DEST>. The user must click it beforehello@<DOMAIN>forwarding goes live.
Read and substitute each file in templates/infrastructure/cloudflare/iam-self/, write to infrastructure/cloudflare/iam-self/. Replace the zone_id default with $ZONE_ID (from Phase A).
Apply (requires bootstrap token from Phase A):
cd infrastructure/cloudflare/iam-self
terraform init
terraform apply -var="account_id=$TF_VAR_account_id"
Push the narrow token to SSM and GitHub, then revoke the bootstrap token:
NEW_TOKEN=$(terraform output -raw token_value)
aws ssm put-parameter \
--profile <SLUG>-terraform --region <REGION> \
--name /<SLUG>/cloudflare/api_token \
--type SecureString --value "$NEW_TOKEN" --overwrite
aws ssm put-parameter \
--profile <SLUG>-terraform --region <REGION> \
--name /<SLUG>/cloudflare/account_id \
--type String --value "$TF_VAR_account_id" --overwrite
gh secret set CLOUDFLARE_API_TOKEN --repo <GH_USER>/<DOMAIN> --body "$NEW_TOKEN"
gh secret set CLOUDFLARE_ACCOUNT_ID --repo <GH_USER>/<DOMAIN> --body "$TF_VAR_account_id"
Revoke the bootstrap token via API (automated — uses the new narrow token):
curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/user/api-tokens/$BOOTSTRAP_TOKEN_ID" \
-H "Authorization: Bearer $NEW_TOKEN"
Or revoke manually: https://dash.cloudflare.com/profile/api-tokens
Read and substitute templates/scripts/secrets-pull.sh and templates/scripts/secrets-bootstrap.sh, write to scripts/. Make both executable (chmod +x).
Also copy these scripts → scripts/ (no substitution needed):
templates/scripts/lighthouse-threshold.shtemplates/scripts/check-links.jsCopy and substitute these templates into the project root:
| Template | Destination | Notes |
|---|---|---|
templates/package.json | package.json | |
templates/astro.config.mjs | astro.config.mjs | |
templates/wrangler.toml | wrangler.toml | |
templates/tsconfig.json | tsconfig.json | |
templates/pnpm-workspace.yaml | pnpm-workspace.yaml | Replaces onlyBuiltDependencies in package.json (pnpm 11+) |
templates/Taskfile.yml | Taskfile.yml | |
templates/worker/index.ts | worker/index.ts | CSP + nonce already wired — update font-src if adding external font origins |
templates/src/styles/global.css | src/styles/global.css | Update @theme tokens for the new brand |
templates/src/layouts/Base.astro | src/layouts/Base.astro | Update title, colors, footer |
templates/src/pages/index.astro | src/pages/index.astro | |
templates/src/pages/404.astro | src/pages/404.astro | |
templates/src/pages/colophon.astro | src/pages/colophon.astro | Fill in typography section; update stack list as needed |
templates/.github/workflows/deploy.yml | .github/workflows/deploy.yml |
Create .nvmrc with 24.
Install:
task install
task build # verify locally
task publish # bumps patch tag → pushes → fires GitHub Actions deploy
If the zone isn't fully active yet, the first deploy may return "zone not active". Wait a few minutes and re-run from the Actions UI — no new tag needed.
Watch the deploy: https://github.com/<GH_USER>//actions
Use this path for placeholder or early-stage sites, OR to migrate an existing static repo to Cloudflare Pages. No Terraform, no AWS, no framework.
1. Domain — apex domain, e.g. foundrylinux.org
2. Slug — short kebab-case prefix, e.g. foundrylinux
3. GH org/repo — e.g. foundry-linux/foundrylinux.org
4. Deploy dir — path to serve (default: site/; use . for root-deployed repos)
5. Branch — production branch (default: main; use master for older repos)
Derive: <PROJECT_NAME> = domain with . → -, e.g. foundrylinux-org.
Existing repo variant: If the repo already has content (not a fresh bootstrap),
skip Step 2 (placeholder page) and set deploy dir to . (root) unless content
lives in a subdirectory.
On first run, bootstrap-site.sh prints step-by-step instructions for creating
a Cloudflare API token and prompts for it interactively (read -rsp). The token
is cached to .creds/bootstrap.env (gitignored) — subsequent runs load it
silently without prompting again.
Just run:
bash scripts/bootstrap-site.sh
Required token permissions: Zone | DNS | Edit (all zones), Account | Cloudflare Pages | Edit.
Account ID is derived from the zone — no Account Settings Read needed.
The operator token must include Account | Pages Write. bootstrap-site.sh
checks for this and exits 1 with instructions if the permission is missing.
templates/scripts/bootstrap-site.sh)Read templates/scripts/bootstrap-site.sh, substitute placeholders, write to scripts/bootstrap-site.sh, make executable.
Run:
bash scripts/bootstrap-site.sh [--dry-run]
This:
0. Removes any Cloudflare Page Rules that redirect <DOMAIN> to www.<DOMAIN>
(stale rules from previous hosting prevent Pages from taking over the apex)
<PROJECT_NAME>)<DOMAIN> as a custom domainCloudflare Pages:Edit-scoped CI token (<SLUG>-site-ci); falls back to manual prompt if permission lookup failsCF_PAGES_API_TOKEN and CF_PAGES_ACCOUNT_ID into GitHub Actions secretstemplates/site/index.html)Skip this step for existing repos. For new/empty repos only:
Read templates/site/index.html, substitute <DOMAIN>, write to site/index.html.
templates/.github/workflows/deploy-static.yml)Read templates/.github/workflows/deploy-static.yml, substitute <PROJECT_NAME>
and <BRANCH>, write to .github/workflows/site-deploy.yml (or deploy.yml if
no other deploy exists).
Set DEPLOY_DIR in the workflow env if not using site/ (e.g. . for root).
Tag and push:
git add ${DEPLOY_DIR} .github/workflows/ scripts/bootstrap-site.sh
git commit -m "feat: Cloudflare Pages hosting for <DOMAIN>"
git tag v0.1.0 && git push origin <BRANCH> v0.1.0
Watch: https://github.com/<GH_ORG>/<GH_REPO>/actions
curl -I https://<DOMAIN>/ # expect HTTP/2 200, content-type: text/html
curl -fsSL https://<DOMAIN>/ # expect body contains <DOMAIN>
When the site has a sticky header that shrinks on scroll, cross-page navigation via Astro's ClientRouter must be wired up so the header height animates rather than jumping.
global.css)/* Register as typed number so the value itself transitions */
@property --header-shrink {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
html {
transition: --header-shrink 220ms ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
html { transition: none; }
}
/* Use --header-shrink to drive padding (or font-size, scale, etc.) */
.header-fx {
/* Example: shrink py from 1.125rem → 0.125rem */
padding-top: calc(1.125rem - 1rem * var(--header-shrink, 0));
padding-bottom: calc(1.125rem - 1rem * var(--header-shrink, 0));
}
Base.astroimport { ClientRouter } from "astro:transitions";
<ClientRouter />
<script>
// --header-shrink: 0 → 1 as scrollY goes 0 → half viewport.
// Gate update() during view transitions so the var stays at its
// shrunk value through snapshot capture; re-run on astro:page-load
// so the CSS transition fires from old → new on the new page.
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
let pending = false;
let inTransition = false;
let firstLoad = true;
const update = () => {
pending = false;
if (inTransition) return;
const raw = Math.min(1, window.scrollY / (window.innerHeight / 2));
const eased = 1 - (1 - raw) * (1 - raw);
document.documentElement.style.setProperty("--header-shrink", eased.toFixed(3));
};
const tick = () => { if (pending) return; pending = true; requestAnimationFrame(update); };
window.addEventListener("scroll", tick, { passive: true });
window.addEventListener("resize", tick, { passive: true });
document.addEventListener("astro:before-preparation", () => { inTransition = true; });
document.addEventListener("astro:page-load", () => {
inTransition = false;
if (firstLoad) {
firstLoad = false;
if (window.scrollY === 0) return;
const run = () => requestAnimationFrame(update);
"requestIdleCallback" in window ? requestIdleCallback(run, { timeout: 250 }) : setTimeout(run, 0);
return;
}
requestAnimationFrame(update);
});
}
</script>
Add transition:persist to the header element so it survives page swaps:
<header transition:persist class="header-fx sticky top-0 ...">
Without inTransition, when the user navigates away from a scrolled page:
--header-shrink to 0 immediatelyThe gate holds the value through snapshot capture; astro:page-load triggers the transition on the now-visible new page.
ClientRouter re-applies inlined <style> tags on every page swap, restarting CSS @keyframe animations from t=0 — even on transition:persist elements. Fix with the Web Animations API: save currentTime values in astro:before-preparation, restore them in astro:page-load after the new CSS is live.
Filter animations by pseudoElement + target to be surgical — document.getAnimations() with {subtree:true} sweeps the whole page.
Base.astro<script>
// Persist body::before + header::after animations across ClientRouter swaps.
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
let savedBreathe: number[] = [];
let savedStripe: number[] = [];
const getBreatheAnims = () =>
document.getAnimations().filter((a) => {
const fx = a.effect as KeyframeEffect | null;
return fx?.pseudoElement === "::after" &&
(fx?.target as Element)?.matches?.(".header-fx");
});
const getStripeAnims = () =>
document.getAnimations().filter((a) => {
const fx = a.effect as KeyframeEffect | null;
return fx?.pseudoElement === "::before" && fx?.target === document.body;
});
document.addEventListener("astro:before-preparation", () => {
savedBreathe = getBreatheAnims().map((a) => (a.currentTime as number) ?? 0);
savedStripe = getStripeAnims().map((a) => (a.currentTime as number) ?? 0);
});
document.addEventListener("astro:page-load", () => {
getBreatheAnims().forEach((anim, i) => {
if (savedBreathe[i] != null) anim.currentTime = savedBreathe[i];
});
getStripeAnims().forEach((anim, i) => {
if (savedStripe[i] != null) anim.currentTime = savedStripe[i];
});
savedBreathe = [];
savedStripe = [];
});
}
</script>
For non-pseudo-element targets (e.g. an animated grid of cells): call element.getAnimations() per element and save as number[][] keyed by DOM position — stable across swaps for server-rendered elements. See worldfoundry.org commit 107cc9c for that variant.
wrangler.toml must match worker_name in global/variables.tf — Cloudflare custom domain bindings reference it by name[[routes]] must NOT appear in wrangler.toml — Terraform owns domain bindingsrun_worker_first = true is required — without it the www→apex redirect doesn't intercept asset pathsprevent_destroy = true on the zone and S3 bucketiam-self/token.tf are Cloudflare-managed and stable — don't look them up dynamicallypnpm approve-builds --all after install for native packages (esbuild, sharp, workerd) — task install handles thisnpx claudepluginhub wbniv/biohack-claude --plugin cf-static-siteCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.