A lightweight, self-hosted file CDN powered by GitHub Repositories + jsDelivr.
Upload images, videos, audio and documents through a clean web UI or a public API — get a permanent jsDelivr CDN link back instantly. No database required.
- What is GitHub CDN?
- Features
- Tech Stack
- How It Works
- Demo
- Prerequisites
- Step-by-Step Setup (Total Beginner Friendly)
- Running the App
- Deploying to Production (PM2)
- API Reference
- Web Interface
- Project Structure
- Environment Variables Reference
- Storage Repo Rotation
- Rate Limits & Security
- Troubleshooting / FAQ
- Roadmap
- Contributing
- License
- Author & Contact
GitHub CDN is a zero-database, open-source file hosting service that uses GitHub repositories as storage and jsDelivr as a global CDN layer.
You (or your users) upload a file, the server commits it to a designated GitHub repo via the GitHub API, and instantly returns a permanent, globally-cached jsDelivr CDN URL — no AWS, no Cloudflare R2, no paid storage needed.
It ships with:
- A polished web upload UI (drag-and-drop, progress feedback, copy-link)
- A clean JSON REST API for programmatic uploads / deletes from scripts, bots, or apps
- Cloudflare Turnstile CAPTCHA protection on the web interface
- Automatic file-type routing into
image/,video/,audio/,docs/folders
It's perfect for:
- 📷 Hosting media for blogs, bots, or small web apps
- 🎬 Sharing videos and audio without platform restrictions
- 🤖 Powering media uploads from WhatsApp bots, Telegram bots, or scripts
- 📦 Free, permanent file hosting that scales up to 1 GB per repo
Storage tip: A single GitHub repo holds up to 1 GB before warnings appear. When you're running low, just create a new storage repo and update
GITHUB_REPOin your.env— no code changes needed.
| Category | Capabilities |
|---|---|
| Storage | GitHub repository via GitHub REST API, auto-routing by file type (image/, video/, audio/, docs/) |
| CDN | jsDelivr global CDN serves every file with zero config — permanent URLs with no egress fees |
| Uploads | Drag-and-drop UI, REST API, multer memory-buffer pipeline, 50 MB max per file |
| File Types | Images, videos, audio, documents — 100+ MIME types whitelisted out of the box |
| Security | Cloudflare Turnstile CAPTCHA on the web UI, CORS headers, rate-limiting |
| Deduplication | Checks if file already exists before uploading; returns existing URL if found |
| Deletion | Delete files directly from the repo via the web UI or API |
| No Database | Zero MongoDB / SQL setup — files and metadata live entirely in GitHub |
| Deployment | First-class PM2 support |
| Mobile | Fully responsive UI (dark/light mode) |
| Layer | Technology |
|---|---|
| Runtime | Node.js 18+ |
| Framework | Express.js |
| Storage | GitHub REST API (via axios) |
| CDN | jsDelivr (cdn.jsdelivr.net/gh) |
| Uploads | multer (memory storage) |
| CAPTCHA | Cloudflare Turnstile |
| Security | CORS, express-rate-limit |
| Process Manager | PM2 |
| Frontend | Vanilla HTML + CSS Custom Properties + Font Awesome + SweetAlert2 |
┌─────────────────────────────┐
│ Browser / │
│ External Client │
└──────────────┬──────────────┘
│ HTTPS upload
▼
┌─────────────────────────────────────────────────────┐
│ Express API (Node.js) │
│ • Turnstile CAPTCHA check (UI only) │
│ • MIME-type whitelist + 50 MB cap │
│ • Generates short random prefix: e.g. "Xy3" │
│ • Routes file into folder by type (image/video…) │
│ • Checks if file already exists in GitHub repo │
└────────────────────────┬────────────────────────────┘
│
GitHub REST API PUT /contents/:path
│
▼
┌────────────────────────────────────────────────────┐
│ GitHub Storage Repository │
│ (mauricegift/ghbcdn or yours) │
│ image/Xy3-photo.jpg │
│ video/Ab1-clip.mp4 │
│ audio/Cd2-track.mp3 │
└────────────────────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ jsDelivr Global CDN │
│ https://cdn.jsdelivr.net/gh/ │
│ mauricegift/ghbcdn@main/image/Xy3-photo.jpg │
└────────────────────────────────────────────────────┘
| Page | URL |
|---|---|
| Upload UI | https://ghbcdn.giftedtech.co.ke |
| Storage Repo | https://github.com/mauricegift/ghbcdn |
Before you start, make sure you have:
- ✅ Node.js 18 or newer — download here
- ✅ npm (comes with Node.js) or yarn
- ✅ Git — download here
- ✅ A GitHub account — signup
- ✅ A free Cloudflare account (only for Turnstile CAPTCHA) — signup
- ✅ Basic familiarity with the terminal/command line
💡 New to all this? Just follow each step in order. Don't skip — every variable matters.
Open your terminal and run:
git clone https://github.com/mauricegift/github-cdn.git
cd github-cdnnpm installThis installs every package listed in package.json (Express, axios, multer, rate-limit, etc.).
This is the repo where your uploaded files will actually be stored (separate from this server code repo).
- Go to github.com/new.
- Give it a name, e.g.
mycdnorfiles. - Set visibility to Public (required so jsDelivr can serve the files).
- Click Create repository.
- Note the repository name — you'll use it as
GITHUB_REPOin your.env.
⚠️ Each GitHub repo holds up to 1 GB of files before you receive storage warnings. See Storage Repo Rotation for how to switch repos without downtime.
The server needs a token to commit files to your storage repo via the GitHub API.
- Go to GitHub → Settings → Developer Settings → Personal access tokens → Tokens (classic).
- Click Generate new token (classic).
- Give it a descriptive name, e.g.
github-cdn-token. - Set Expiration to
No expiration(or a date of your choosing). - Under Scopes, check:
- ✅
repo→ Full control of private repositories (this covers public repos too)
- ✅
- Click Generate token.
- Copy the token immediately — it is shown only once.
- Save it as
GITHUB_TOKENin your.env.
🔒 Treat your token like a password. Never commit it to any public repository.
Turnstile protects the public upload and delete pages from bots. It's completely free.
💡 Only the web UI uses Turnstile. The
/api/upload.phpendpoint skips it entirely — perfect for scripts and bots.
You will need two Turnstile widgets — one for the upload form and one for the delete form — each with its own Site Key. You only need one Secret Key (reuse it for both, or create separate ones).
Create Widget 1 — Upload form:
- In the Cloudflare Dashboard → left sidebar → Turnstile → Add Site.
- Site name:
github-cdn-upload - Domains: add your domain (e.g.
ghbcdn.yourdomain.com) ANDlocalhost. - Widget mode: Managed.
- Click Create → copy the Site Key → paste it into
public/index.htmlwhereUPLOAD_SITEKEYis defined in the<script>block.
Create Widget 2 — Delete form:
- Click Add Site again.
- Site name:
github-cdn-delete - Same domains as above.
- Click Create → copy the Site Key → paste it into
public/index.htmlwhereDELETE_SITEKEYis defined.
Secret Key (shared):
Copy either widget's Secret Key → save it as CF_TURNSTILE_SECRET_KEY in your .env.
In the project root create a file named .env and paste:
# ─── Server ─────────────────────────────────────────────────
PORT=5000
# ─── GitHub Storage ─────────────────────────────────────────
GITHUB_USERNAME=your_github_username
GITHUB_REPO=your_storage_repo_name
GITHUB_TOKEN=your_github_personal_access_token
GITHUB_API_URL=https://api.github.com
REPO_BRANCH=main
COMMIT_MESSAGE=Github Cdn:Upload
# ─── jsDelivr CDN ───────────────────────────────────────────
CDN_API_URL=https://cdn.jsdelivr.net/gh
# ─── Cloudflare Turnstile (CAPTCHA) ─────────────────────────
CF_TURNSTILE_SECRET_KEY=your_turnstile_secret_key
CF_TURNSTILE_API_URL=https://challenges.cloudflare.com🔒 NEVER commit
.envto git. Make sure it's listed in.gitignore.
npm startnpm install -D nodemon
npx nodemon ./api/index.jsVisit http://localhost:5000 in your browser. You should see the GitHub CDN upload page. 🎉
PM2 keeps your app running 24/7 and restarts it automatically if it crashes.
npm install pm2 -g| Action | Command |
|---|---|
| Start | npm start (uses PM2 internally) |
| Stop | npm run stop |
| Restart | npm run restart |
| View live logs | pm2 logs ghbcdn |
| Status dashboard | pm2 status |
| Auto-start on boot | pm2 startup then follow the printed command |
npm start
pm2 save
pm2 startup # follow the printed command to enable boot startupAdd a reverse proxy + HTTPS:
server {
listen 80;
server_name ghbcdn.yourdomain.com;
client_max_body_size 55M; # slightly above the 50 MB upload cap
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then issue a free SSL cert with Certbot:
sudo certbot --nginx -d ghbcdn.yourdomain.comBase URL (your deployment):
https://ghbcdn.yourdomain.comLive demo base URL:https://ghbcdn.giftedtech.co.ke
Public endpoint — no CAPTCHA required. Use it from scripts, bots, or mobile apps.
| Field | Type | Required | Description |
|---|---|---|---|
file |
multipart/form-data |
✅ | The file to upload (max 50 MB) |
Rate limit: 10 requests per IP per 5 minutes.
curl -X POST https://ghbcdn.giftedtech.co.ke/api/upload.php \
-F "file=@/path/to/photo.jpg"const form = new FormData();
form.append('file', fileInput.files[0]);
const res = await fetch('https://ghbcdn.giftedtech.co.ke/api/upload.php', {
method: 'POST',
body: form
});
const data = await res.json();
console.log(data.rawUrl); // ← your permanent CDN linkimport requests
with open('photo.jpg', 'rb') as f:
r = requests.post(
'https://ghbcdn.giftedtech.co.ke/api/upload.php',
files={'file': f}
)
print(r.json()['rawUrl']){
"success": true,
"rawUrl": "https://cdn.jsdelivr.net/gh/mauricegift/ghbcdn@main/image/Xy3-photo.jpg"
}{
"success": true,
"rawUrl": "https://cdn.jsdelivr.net/gh/mauricegift/ghbcdn@main/image/Xy3-photo.jpg",
"message": "File already exists, returning existing URL"
}| Status | Reason |
|---|---|
400 |
No file uploaded |
400 |
File type not allowed |
413 |
File exceeds 50 MB limit |
429 |
Rate limit exceeded — try again in 5 minutes |
500 |
GitHub API error |
Removes the file from your GitHub storage repo. Requires a valid Cloudflare Turnstile token.
| Field | Type | Required | Description |
|---|---|---|---|
filename |
string |
✅ | The file path in the repo (e.g. image/Xy3-photo.jpg) |
turnstileResponse |
string |
✅ | Valid Turnstile token from the web widget |
curl -X DELETE https://ghbcdn.giftedtech.co.ke/giftedDelete.php \
-H "Content-Type: application/json" \
-d '{"filename":"image/Xy3-photo.jpg","turnstileResponse":"<token>"}'{
"success": true,
"message": "File image/Xy3-photo.jpg deleted successfully"
}| Status | Reason |
|---|---|
400 |
filename missing or CAPTCHA token missing/invalid |
404 |
File not found in the storage repo |
500 |
GitHub API error |
Same as /api/upload.php but requires a valid Cloudflare Turnstile token in the request body. Used exclusively by the built-in upload UI.
| Field | Type | Required | Description |
|---|---|---|---|
file |
multipart/form-data |
✅ | The file to upload |
turnstileResponse |
string |
✅ | Valid Turnstile token |
| Route | What it does |
|---|---|
/ |
Drag-and-drop upload UI (Turnstile-protected) — returns jsDelivr CDN URL |
/ (Delete tab) |
File deletion form — enter file path and complete CAPTCHA to delete |
Both pages are fully responsive (mobile + desktop) and support dark / light mode toggle.
github-cdn/
├── api/
│ └── index.js # Express app — routes, multer, Turnstile verify, GitHub API calls
│
├── public/
│ └── index.html # Upload + Delete UI (single-page with tab navigation)
│
├── config.js # Reads env vars + sane defaults (MIME whitelists, repo config)
├── package.json # Dependencies + npm scripts (PM2)
├── .env # Secrets (you create this — never commit)
└── README.md # ← this file
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
❌ | 5000 |
Port the Express server listens on |
GITHUB_USERNAME |
✅ | mauricegift |
Your GitHub username |
GITHUB_REPO |
✅ | ghbcdn |
The name of your GitHub storage repository |
GITHUB_TOKEN |
✅ | — | GitHub Personal Access Token (needs repo scope) |
GITHUB_API_URL |
❌ | https://api.github.com |
GitHub API base URL — don't change this |
REPO_BRANCH |
❌ | main |
Branch to commit files to |
COMMIT_MESSAGE |
❌ | Github Cdn:Upload |
Commit message used when uploading files |
CDN_API_URL |
❌ | https://cdn.jsdelivr.net/gh |
jsDelivr CDN base URL — don't change this |
CF_TURNSTILE_SECRET_KEY |
✅* | — | Turnstile secret key (*required for web UI CAPTCHA) |
CF_TURNSTILE_API_URL |
❌ | https://challenges.cloudflare.com |
Turnstile verification endpoint |
IMAGE_MIMETYPES |
❌ | built-in list | Override the allowed image MIME types |
VIDEO_MIMETYPES |
❌ | built-in list | Override the allowed video MIME types |
AUDIO_MIMETYPES |
❌ | built-in list | Override the allowed audio MIME types |
DOC_MIMETYPES |
❌ | built-in list | Override the allowed document MIME types |
A single GitHub repository holds up to 1 GB of data. When you start approaching that limit:
- Create a new public GitHub repository (e.g.
mycdn2). - Update
GITHUB_REPO=mycdn2in your.env. - Restart the server — new uploads go to the new repo instantly.
- Old URLs from the previous repo remain permanently valid (jsDelivr keeps serving them).
This lets you scale storage horizontally with zero downtime.
- Upload rate limit: 10 requests / 5 minutes / IP (configurable in
api/index.js). - File size cap: 50 MB (set in
multerlimits). - MIME whitelist: uploads outside the configured image/video/audio/doc lists are rejected with
400. - CAPTCHA: the web upload and delete pages use Cloudflare Turnstile so bots can't spam your repo.
- API endpoint (
/api/upload.php): intentionally CAPTCHA-free for programmatic use. Rate-limit it more aggressively if you see abuse. - CORS: open by default (
*). Lock it down inapi/index.jsif you only want specific origins to call your API. - No delete key needed: deletion goes through Turnstile CAPTCHA on the web UI; the API delete route also requires a valid Turnstile token.
"File type not allowed"
The uploaded file's MIME type isn't in any of the four whitelists. Either add it to the relevant *_MIMETYPES environment variable in .env, or convert the file to a supported format.
Example — add support for .webp if it's missing:
IMAGE_MIMETYPES=["image/jpeg","image/png","image/webp"]Upload succeeds but the jsDelivr URL returns a 404
jsDelivr caches GitHub content and can take a few minutes to propagate a new file. Wait 2–3 minutes and try the URL again. If it still fails:
- Confirm the file actually exists in your GitHub storage repo.
- Make sure the repo is public — jsDelivr cannot serve files from private repositories.
"CAPTCHA Response is Required" or "CAPTCHA Already Used"
- Each Turnstile token can only be verified once. After a successful or failed upload, the widget resets automatically.
- If you see this on a fresh page load, make sure both
UPLOAD_SITEKEYandDELETE_SITEKEYconstants inpublic/index.htmlmatch the Site Keys from your Cloudflare Turnstile dashboard. - If you only use the API (
/api/upload.php), you do not need Turnstile.
GitHub API returns 401 Unauthorized
Your GITHUB_TOKEN is invalid, expired, or lacks the required repo scope. Generate a new token at GitHub → Settings → Developer Settings → Personal access tokens and update your .env.
GitHub API returns 422 Unprocessable Entity
This usually means a file with the same name already exists at the same path. The server checks for duplicates and returns the existing URL, so this shouldn't reach your client. If it does, it's a race condition from simultaneous uploads — it's safe to retry.
Rate limit hit — "Too many upload attempts"
The default rate limit is 10 uploads per IP per 5 minutes. Wait for the window to reset, or adjust the windowMs and max values in the uploadLimiter config inside api/index.js.
My storage repo is nearly full (approaching 1 GB)
See Storage Repo Rotation. Create a new repo, update GITHUB_REPO in .env, and restart the server. Old CDN links remain valid permanently.
- Optional delete key per upload (like Gifted CDN) for API-based deletion without CAPTCHA
- File listing / dashboard UI
- WebSocket upload progress for large files
- Docker / Docker Compose support
- Optional MongoDB integration for file metadata tracking
- Multi-repo round-robin to automatically balance storage across repos
Contributions are welcome and appreciated! 💚
- Fork the repo
- Create your feature branch:
git checkout -b feature/awesome-thing - Commit your changes:
git commit -m 'Add awesome thing' - Push to the branch:
git push origin feature/awesome-thing - Open a Pull Request
Please test your changes locally before submitting.
This project is licensed under the MIT License.
MIT © 2024 - Present Maurice Gift / GiftedTech
Maurice Gift — Founder, GiftedTech
- 🌐 Website: https://me.giftedtech.co.ke
- 🚀 Live CDN: https://ghbcdn.giftedtech.co.ke
- 💼 GitHub: @mauricegift
- 📧 Email: founder@giftedtech.co.ke
- 💬 WhatsApp Channel: Follow for updates
⭐ If this project helped you, please give it a star! ⭐
Made with ❤️ in Kenya 🇰🇪