An educational, open-source demo of a GitHub Actions → Cloudflare Pages static-asset pipeline, with a branded 403 page, a live "edge dashboard," and full documentation of every moving part.
Important
This repository is published strictly for learning purposes — to show, in full public view, how a small automation pipeline (GitHub Actions + Python + Cloudflare Pages) can be assembled. It is not a commercial product, it is not an official Cloudflare or GitHub project, and it makes no claim to be a real CDN (see § This Pipeline vs. an Actual CDN).
Deep Dey (the author) is not responsible for any misuse of this code — including but not limited to: deploying it in production without understanding it, using it to host or distribute content that violates GitHub's or Cloudflare's terms of service, bypassing rate limits, disguising malicious payloads as "static assets," or any other use that breaks the law or a third party's terms. You use this at your own risk, "as is," with no warranty. See § License & Liability.
This repo is a static-asset pipeline that sits in front of Cloudflare Pages, built from four independent pieces that run every time you push a commit:
| Piece | File | Job |
|---|---|---|
| 🔔 Trigger | .github/workflows/normalize-static.yml | Fires on every push, on every branch. |
| 🐍 Worker | .github/scripts/normalize_static.py | Cleans filenames, quarantines oversized files, updates the ledger. |
| 📒 Ledger | config.json | JSON record of every file currently published under /static. |
| 🎨 Skin | index.html + dashboard/index.html | A fully custom, branded "edge network" 403 page and a password-gated file dashboard. |
None of these pieces are Cloudflare's CDN. They are automation and presentation built on top of Cloudflare's real infrastructure (Cloudflare Pages / global edge network). That distinction is spelled out fully in §18 — worth reading before you tell anyone this "is a CDN," because it isn't one on its own.
flowchart TD
A["👤 You push a commit\n(any branch, any file)"] --> B["⚙️ GitHub Actions\nnormalize-static.yml\n(disposable runner VM)"]
B --> C["🐍 normalize_static.py\n• walk /static\n• clean filenames\n• quarantine files > 25MB → /dumb\n• update config.json"]
C --> D{changed == true?}
D -- No --> E["🛑 Workflow ends\n(no commit, no noise)"]
D -- Yes --> F["📝 git commit + push\n'chore: normalize static assets [skip ci]'"]
F --> G["☁️ Cloudflare Pages build\n(separate system, Cloudflare-owned)"]
G --> G2["🔑 node scripts/build.js\ninjects CONFIG + PASSWORD\nsecrets into dashboard/index.html"]
G2 --> H["🧹 Cleanup deletes /dumb and /.github\n(keeps index.html, config.json,\nstatic/, dashboard/, scripts/)"]
H --> I["🌍 Remaining files uploaded to\nCloudflare's real global edge network\n(THIS is the actual CDN)"]
style A fill:#1c2430,stroke:#00f0c0,color:#fff
style I fill:#0a5c4d,stroke:#00f0c0,color:#fff
style E fill:#7a0f1f,stroke:#ff2b4d,color:#fff
Two systems, two owners, back-to-back:
- GitHub Actions (GitHub's infra) → does the bookkeeping: renaming, size-checking, metadata.
- Cloudflare Pages (Cloudflare's infra) → does the actual delivery: build, upload, edge caching, global distribution.
This repo's code only touches step 1. Step 2 is entirely Cloudflare's own infrastructure, triggered automatically once you connect the GitHub repo to a Cloudflare Pages project.
your-repo/
├── .github/
│ ├── workflows/
│ │ └── normalize-static.yml ← trigger (§6)
│ └── scripts/
│ └── normalize_static.py ← worker (§6)
│
├── static/ ← your real, public static assets
│ ├── logo.png
│ ├── banner-image.jpg
│ └── ...
│
├── dumb/ ← auto-created quarantine for oversized files
│ └── (anything > 25MB lands here, never served)
│
├── dashboard/
│ └── index.html ← password-gated live file dashboard (§12)
│
├── config.json ← auto-maintained metadata ledger (§13)
├── index.html ← branded 403 "edge network" page (§11)
├── scripts/
│ └── build.js ← build-time secrets injector, preserved through cleanup (§7, §9)
├── .assetsignore ← Cloudflare Pages asset-ignore hints
└── README.md ← this file
Everything under .github/ and dumb/ is meant to stay out of the live deployment — enforced at the Cloudflare build-command layer, not by .gitignore (see §7).
The front-end pages (index.html, dashboard/index.html) are hand-built with pure CSS + vanilla JS — no framework. Here's every animated/interactive effect actually implemented, for anyone studying or reusing the techniques:
| Effect | Where | How it works |
|---|---|---|
| Canvas particle field | index.html | A lightweight <canvas> loop (34 particles) drawn with requestAnimationFrame; pauses automatically when the tab is hidden to save CPU/battery. |
| CRT-style scanline + noise | index.html | @keyframes noiseShift / scanMove — an SVG feTurbulence filter animated via steps() for a retro terminal look. |
| Typewriter terminal text | index.html | @keyframes typing using steps(48, end) on a width transition, plus a separate caretBlink keyframe for the blinking cursor. |
| Glitch text layers | index.html | Two duplicated text layers (layer-r / layer-c) animated with mix-blend-mode:screen and offset steps() keyframes to fake a chromatic-aberration glitch. |
| Rotating shield rings | index.html | Two concentric SVG rings, spin / spinRev keyframes rotating opposite directions at different speeds (40s / 55s) around a shared transform-origin. |
| Animated dashed connection line | index.html | stroke-dashoffset animated via @keyframes dash to simulate a moving data packet along an SVG path. |
| Pulse rings / status dot | index.html | @keyframes pulseRing and pulseDot — scale + opacity fades that mimic a radar "ping." |
| Scrolling ticker | index.html (footer) | @keyframes tickerScroll — a transform:translateX() loop for the marquee-style status ticker. |
| Live network diagnostics | index.html | Real (not fake) call to Cloudflare's public /cdn-cgi/trace endpoint + a DNS-over-HTTPS lookup (cloudflare-dns.com/dns-query) to show your real IP / edge PoP / latency. |
| Reduced-motion & low-power fallback | index.html | @media (prefers-reduced-motion: reduce) and a JS lightMode feature-detect (touch / low-memory devices) disable all of the above automatically. |
| Password-gated dashboard reveal | dashboard/index.html | Simple gate before rendering; files rendered as cards (name, type badge, relative upload time converted to the viewer's local timezone). |
| File preview modal | dashboard/index.html | Clicking a card opens a modal; images/video/audio preview inline based on extension, everything else falls back to a generic icon + download link. |
| Live "Edge Status" strip | dashboard/index.html | Same /cdn-cgi/trace technique as the 403 page — visitor IP, edge location, latency, server vs. local clock, all genuinely live per-request data. |
Performance notes (from the project's own changelog): custom cursors, cursor-trails, mouse-glow effects, and
backdrop-filter: blur()were deliberately removed in a later pass because they caused jank on mobile/low-end devices — everything above only usestransform/opacityso the browser can composite it on the GPU without triggering layout thrashing.
- Fork or clone this repository into your own GitHub account.
- Create a
static/folder at the repo root (if it isn't already there) and drop your real public assets into it. - Do nothing else in GitHub Actions —
normalize-static.ymlalready haspermissions: contents: writeand uses the auto-issuedGITHUB_TOKEN, so no personal access token or secret is required for the normalizer itself. - Push a commit. The workflow runs automatically, cleans filenames, quarantines anything over 25MB into
/dumb, and updatesconfig.json. - Create a Cloudflare account (free tier is enough to start) and a Pages project, connecting it to this same GitHub repo. See Cloudflare's official Pages docs for the click-by-click flow.
- Add two Environment Variables (as Secrets) under Cloudflare Pages → Settings → Environment variables — see §9 for exactly what each one does.
- Set the Build command in Cloudflare Pages → Settings → Builds & deployments to the full
node scripts/build.js && ...one-liner in §7 — this both injects your secrets into the dashboard and strips/dumband.github/before anything goes live. - Set the Build output directory to
/(repo root), since this project serves flat, no separatedist/folder. - (Optional) Point a custom domain at the Pages project, and update
CDN_DOMAINinside.github/scripts/normalize_static.pyto match it, soconfig.jsongenerates correct public URLs. - Push again — Cloudflare will auto-deploy on every push once the connection is live. Watch the Actions tab and the Cloudflare Pages deployment log at the same time to see both halves of the pipeline run back-to-back.
- Verify using the checks in §16 Troubleshooting — confirm
/dumb/and/.github/both 404 on the live domain, and that the dashboard/403 page load correctly.
.github/workflows/normalize-static.yml runs on every push, on every branch, with a concurrency guard so rapid pushes don't race each other, and only commits back to the repo when the Python worker actually changed something (if: steps.normalize.outputs.changed == 'true'). The worker itself (normalize_static.py) is fully idempotent — running it twice with nothing changed produces changed=false both times — uses atomic file moves (os.replace, never copy+delete), never makes a network call, and preserves each file's original, first-seen filename forever in config.json even after the on-disk name is cleaned/renamed many times over.
Cloudflare Pages → Settings → Builds & deployments → Build configuration → Build command:
node scripts/build.js && T0=$(date +%s.%N); FILES=$(find . -mindepth 1 -maxdepth 1 ! -name index.html ! -name config.json ! -name static ! -name dashboard ! -name scripts);if [ -n"$FILES" ];then BYTES=$(du -cb $FILES2>/dev/null | tail -1 | awk '{print $1}');else BYTES=0;fi; COUNT=$(printf '%s\n'"$FILES"| grep -c .); MB=$(awk -v b="${BYTES:-0}"'BEGIN{printf "%.2f", b/1048576}'); GB=$(awk -v b="${BYTES:-0}"'BEGIN{printf "%.3f", b/1073741824}'); NAMES=$(printf '%s\n'"$FILES"| xargs -n1 basename 2>/dev/null | paste -sd, -); rm -rf $FILES; T1=$(date +%s.%N); DUR=$(awk -v a="$T0" -v b="$T1"'BEGIN{printf "%.2f", b-a}');echo"✅ Deleted $COUNT item(s) [$NAMES] — freed ${MB}MB (${GB}GB) in ${DUR}s"This is two things chained with &&:
node scripts/build.jsruns first — a small secrets-injector (see §9 and §12) that reads thePASSWORDandCONFIGenvironment variables, hashes/encodes them, and writes the result intodashboard/index.htmlin place of two placeholder tokens (__PASSWORD_HASH__and__CONFIG_SRC_B64__). If either variable is missing, this step fails loudly and the whole build stops — which is intentional, so a misconfigured project can't silently deploy with a broken dashboard.- The
rm -rfcleanup then runs inside Cloudflare's temporary, disposable build clone — never your real GitHub repo — deleting everything at the repo root exceptindex.html,config.json,static/,dashboard/, andscripts/. That specifically preservesdashboard/(so the file dashboard still deploys) while still wiping.github/and/dumbbefore upload. Neither.gitignore(only affects new/untracked files) nor.assetsignore(only honored by Wrangler/Workers-style deploys, not the dashboard git-integration pipeline) reliably solves this on their own, which is why the build-command deletion exists.
⚠️ Order matters:build.jsmust run before the cleanup, since it writes intodashboard/index.html— reversing the order, or forgetting the&&, would either inject secrets after the folder no longer needs them or (ifscripts/weren't preserved) deletebuild.jsbefore it ever runs.
This project runs entirely on top of two other companies' free-tier infrastructure — using it means you've agreed to their rules, not just this repo's README. Read these before deploying anything real:
- GitHub Terms of Service:https://docs.github.com/en/site-policy/github-terms/github-terms-of-service
- GitHub Actions usage limits & policy:https://docs.github.com/en/actions/reference/limits
- GitHub Acceptable Use Policies:https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies
- Cloudflare Terms of Use:https://www.cloudflare.com/website-terms/
- Cloudflare Self-Serve Subscription Agreement:https://www.cloudflare.com/terms/
- Cloudflare Pages platform limits (file size, project count, builds/month):https://developers.cloudflare.com/pages/platform/limits/
- Cloudflare Service-Specific Terms (CDN large-file/video restrictions):https://www.cloudflare.com/service-specific-terms-application-services/
Cloudflare's free CDN is meant for normal website assets (HTML/CSS/JS/images) — it explicitly restricts using the free CDN as a video-hosting or bulk-large-file service. This pipeline's 25MB quarantine step exists partly because of that policy, not despite it.
- No custom secrets required for the normalizer — it uses GitHub's own short-lived, auto-rotated
GITHUB_TOKEN, scoped only to this repo, only for the duration of the run. permissions: contents: writeis explicitly escalated in the workflow because the default token is read-only; without it, the finalgit pushfails with a GitHub-side 403 (nothing to do with Cloudflare).
Set both of these under Cloudflare Pages → Settings → Environment variables, as Secret type (not plaintext), for whichever environment(s) you deploy (Production/Preview):
| Type | Name | Value | What it's for |
|---|---|---|---|
| 🔒 Secret | CONFIG | The full URL of your deployed config.json (e.g. https://cdn.example.com/config.json) | Read by scripts/build.js at build time, base64-encoded (obfuscated, not encryption — just kept off a plain view-source scan), and written into dashboard/index.html, where the dashboard atob()-decodes it at runtime to know where to fetch the file ledger from. |
| 🔒 Secret | PASSWORD | Your real, plaintext dashboard password | Read by scripts/build.js, hashed with SHA-256, and written into dashboard/index.html — only the hash ever lands in the deployed HTML, never the plaintext password. |
Both show as Secret type with the value permanently masked (Value encrypted) in the Cloudflare dashboard once saved — there's no way to view a saved secret's plaintext again through the UI, only overwrite or delete it.
Because these are build-time-only variables consumed by
node scripts/build.js(see §7), the build will fail on purpose if eitherCONFIGorPASSWORDis missing — this is a deliberate fail-safe so you can't accidentally ship a dashboard with a broken config source or no password gate at all.
Never commit the real values of CONFIG or PASSWORD into git, into .assetsignore, or into any comment/placeholder in dashboard/index.html — only the two placeholder tokens (__PASSWORD_HASH__, __CONFIG_SRC_B64__) should ever appear in the committed source; the real values live exclusively in Cloudflare's encrypted environment-variable store.
| Limit | Value | Source |
|---|---|---|
| Max single asset size (Cloudflare Pages) | 25 MiB | Cloudflare Pages limits |
| Files per site (Free plan) | 20,000 | Cloudflare Pages limits |
| Custom domains per project (Free plan) | 100 | Cloudflare Pages limits |
| GitHub Actions workflow runs | Subject to plan-based minutes/concurrency caps | GitHub Actions limits |
This is also why the pipeline's MAX_BYTES constant is set to 25MB in normalize_static.py — it mirrors Cloudflare's own real ceiling, defined in exactly one place so it's a single-line change if that number ever changes.
index.html is a fully custom "edge network access denied" themed page (see §4 for every effect used inside it). It reads real, live data from Cloudflare's public /cdn-cgi/trace endpoint (visitor IP, edge PoP, latency) rather than hardcoding any of it — meaning two visitors from two different networks will see genuinely different values.
dashboard/index.html is a password-gated viewer for whatever is currently tracked in config.json: it renders each file as a card (name, type badge, relative upload time converted into the viewer's local timezone), and clicking a card opens a preview modal (inline image/video/audio where applicable, a generic icon + download link otherwise). It shares the same live Cloudflare edge-diagnostics strip as the 403 page.
Before deploy, the committed source only contains two placeholder tokens — __PASSWORD_HASH__ and __CONFIG_SRC_B64__. Neither the real dashboard password nor the real config.json URL is ever committed to git. At build time, scripts/build.js (see §7 and §9) swaps those two tokens for a SHA-256 password hash and a base64-encoded config URL, respectively, and only that built version is what actually gets deployed to Cloudflare's edge.
Every tracked file gets one entry keyed by its cleaned filename:
{
"logo.png": {
"filename": "Logo (Final Final v2).PNG",
"path": "/static/logo.png",
"pushed_at": "22nd Aug 2026 | 8:16 pm",
"uploaded_at": "1755856560",
"url": "https://cdn.example.com/static/logo.png"
}
}filename is frozen at its first-seen value forever (a durable audit trail of the original messy upload name); path/url/timestamps update whenever the file actually changes. Entries that haven't changed are left byte-for-byte identical run over run, so git diff on this file always shows exactly what changed in a given push — nothing more.
Files over 25MB are moved, not deleted, into /dumb — atomically, so nothing is ever lost or duplicated mid-move. This happens on GitHub's infrastructure, before Cloudflare ever sees the file, so an oversized asset can't silently break an entire deploy. /dumb itself never reaches the live site because of the Cloudflare build-command cleanup in §7.
# Run the normalizer locally (no network calls, safe to test)
python3 .github/scripts/normalize_static.py
# Force the 25MB quarantine branch to fire, for testing
dd if=/dev/zero of=static/big-test-file.bin bs=1M count=26
python3 .github/scripts/normalize_static.py
ls dumb/ # big-test-file.bin should now be here# Confirm the workflow triggers on a non-main branch
git checkout -b test-branch
echo"test"> static/probe-file.txt
git add static && git commit -m "test: probe workflow trigger"
git push -u origin test-branch| Symptom | Likely cause | Fix |
|---|---|---|
git push step fails with 403 | Missing permissions: contents: write | Add it to the workflow YAML. |
| Deploy fails on Cloudflare with "file too large" | A file slipped past normalization (e.g. added directly through the Cloudflare dashboard, not git) | Re-run the normalizer, or manually move the file into /dumb. |
Build fails immediately with ERROR: PASSWORD environment variable is not set (or CONFIG) | One or both secrets are missing from Cloudflare Pages → Settings → Environment variables | Add both as Secret type exactly as shown in §9, then retry the deployment. |
| Dashboard loads but never shows any files | CONFIG secret points to the wrong URL, or config.json isn't actually reachable at that URL | Confirm the CONFIG value resolves to your real, deployed config.json, not a local path. |
config.json diff is huge/noisy every run | Timestamps regenerating for untouched files | Confirm you're on the version of the script that only rewrites entries when path/url actually changed. |
/dumb/ or /.github/ are publicly reachable | Build command cleanup step missing/misconfigured | Re-check the exact Build command in Cloudflare Pages settings against §7. |
| Two files collide to the same clean name | e.g. Photo 1.PNG and photo-1.png both clean to photo-1.png | Known sharp edge — rename one of the source files before pushing; a hash-suffix fallback is on the roadmap. |
Is this an official Cloudflare or GitHub product? No. It's a third-party, independent, educational automation script built on top of their public infrastructure.
Can I use this in production? You can, but you're expected to read and understand every part of it first (that's the whole point of this README) and to comply with both platforms' terms linked in §8.
Does the normalizer ever talk to the network? No — it's pure local filesystem + JSON. Only the front-end pages (index.html, dashboard/index.html) make live calls, and only to Cloudflare's own public diagnostic endpoints.
What happens to a file over 25MB? It's moved (not deleted) to /dumb, and any config.json entry claiming it's still live under /static is removed in the same run.
| This repo | An actual CDN (Cloudflare's network) | |
|---|---|---|
| Owns physical edge servers? | ❌ No | ✅ Yes, globally distributed |
| Does request routing / edge caching? | ❌ No | ✅ Yes |
| Renames files, tracks metadata? | ✅ Yes | ❌ Not its job |
| What it actually is | A pre-processing + bookkeeping layer that runs before your files reach Cloudflare | The real delivery network that Cloudflare operates |
In short: this project automates getting files ready for a CDN. It is not, and does not claim to be, the CDN itself.
This project is open source and welcomes contributions from anyone — bug fixes, new features, better docs, translated versions, accessibility improvements, all of it.
- Fork the repo.
- Create a branch:
git checkout -b fix/short-description. - Make your change. Keep the normalizer's design properties intact (idempotent, atomic, no network calls) unless your PR is specifically about changing that.
- Test locally using the Command Cheatsheet above.
- Open a Pull Request against
main, describing what changed and why. - Be patient — this is a community/education project, reviewed on a best-effort basis.
Issues, feature requests, and "this section of the README is unclear" reports are all welcome too — open a GitHub Issue.
This project is released under the MIT License (add/keep a LICENSE file with the full text in the repo root — see choosealicense.com/licenses/mit for the exact wording). In short:
- ✅ You can use, copy, modify, merge, publish, and distribute this code, for free, including commercially.
- ✅ You must keep the original copyright/credit notice in copies or substantial portions of the code.
- ❌ It comes with no warranty of any kind. Use it at your own risk.
- ❌ The author (Deep Dey) is not liable for any damages or consequences arising from the use, misuse, or inability to use this software — including violations of GitHub's or Cloudflare's own terms of service, data loss, downtime, or misuse for anything illegal or malicious.
This repository is shared strictly for educational and learning purposes — to demonstrate CI/CD automation, static-site pipelines, and front-end animation techniques in the open. It is not intended as a turnkey production CDN replacement, and it is not to be used to host, mirror, or distribute content that violates any applicable law or any third party's terms of service (including GitHub's and Cloudflare's, linked in §8).
Developed by Deep Dey.
- 📷 Instagram: https://www.instagram.com/deepdey.official/
- 🔗 Linktree (all links): https://linktr.ee/deepdey
Automation scripting, documentation drafting, and debugging assistance provided with Claude (Anthropic) — included here for transparency, not as a claim of authorship over the project's direction, branding, or purpose, all of which belong to Deep Dey.
Badges via shields.io (open source, free to use/embed in any README).
⭐ If this helped you learn something, consider starring the repo — and remember: education first, misuse never.