A self-hosted, WEBP-optimised CDN for your manga reader, running on Raspberry Pi
with Cloudflare Tunnel, USB Drive storage, and a full dark-theme management panel.
mangacdn/
βββ server.js β Express CDN + Management API
βββ package.json
βββ ecosystem.config.js β PM2 process config
βββ cloudflared-config.yml β Cloudflare Tunnel config
βββ setup.sh β One-shot install script
βββ public/
βββ panel/index.html β Management GUI (dark theme)
βββ reader/index.html β Test manga reader frontend
# Clone / copy project to your Pi
cd /home/pi
git clone <your-repo> # or scp from your machine
cd mangacdn
chmod +x setup.sh
bash setup.shThe script:
- Installs Node.js 20 LTS
- Installs PM2 (process manager)
- Installs cloudflared (Cloudflare Tunnel daemon)
- Detects and mounts your USB drive
- Generates random credentials
- Starts the server and enables boot autostart
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejscd /home/pi/mangacdn
npm install# Find your USB device
lsblk
# Mount it (example: /dev/sda1, ext4)
sudo mkdir -p /mnt/usb/manga
sudo mount /dev/sda1 /mnt/usb
# Make it permanent (replace UUID)
UUID=$(sudo blkid -s UUID -o value /dev/sda1)
echo "UUID=$UUID /mnt/usb ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstabFolder structure on USB:
/mnt/usb/manga/
βββ chainsaw-man/
β βββ cover.webp β Series cover (optional)
β βββ chapter-001/
β β βββ 001.webp
β β βββ 002.webp
β β βββ 003.webp
β βββ chapter-002/
β βββ ...
βββ one-piece/
βββ ...
Edit ecosystem.config.js:
env: {
PORT: 3000,
STORAGE_ROOT: '/mnt/usb/manga', // β USB mount path
API_TOKEN: 'your-secret-token', // β Used by API clients
PANEL_PASS: 'your-panel-pass', // β Login to panel GUI
}npm install -g pm2
mkdir -p logs
pm2 start ecosystem.config.js
pm2 save
# Enable auto-start on Pi boot:
pm2 startup
# Copy+run the command it outputs (starts with "sudo env PATH=...")# Install cloudflared (ARM64)
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 \
-o /tmp/cloudflared
sudo install -m 755 /tmp/cloudflared /usr/local/bin/cloudflared
# Authenticate with Cloudflare
cloudflared tunnel login
# Create tunnel
cloudflared tunnel create mangacdn
# Note the Tunnel ID it gives you!
# Edit cloudflared-config.yml:
# Replace <TUNNEL_ID> and yourdomain.com
sudo cp cloudflared-config.yml /etc/cloudflared/config.yml
sudo nano /etc/cloudflared/config.yml
# Create DNS route
cloudflared tunnel route dns mangacdn cdn.yourdomain.com
# Install as system service
sudo cloudflared service install
sudo systemctl enable --now cloudflared
# Verify
cloudflared tunnel info mangacdn| URL | Description |
|---|---|
https://cdn.yourdomain.com/files/<path> |
Public CDN file endpoint |
https://cdn.yourdomain.com/panel/ |
Management panel (password) |
https://cdn.yourdomain.com/reader/ |
Test manga reader |
https://cdn.yourdomain.com/api/list?path=/ |
List directory (API) |
https://cdn.yourdomain.com/health |
Health check |
All API calls require the header X-Api-Token: <your-token> (or ?token= in URL).
GET /api/list?path=/chainsaw-man{
"path": "/chainsaw-man",
"items": [
{ "name": "chapter-001", "type": "folder", "path": "/chainsaw-man/chapter-001", "url": null },
{ "name": "cover.webp", "type": "file", "path": "/chainsaw-man/cover.webp", "url": "/files/chainsaw-man/cover.webp" }
]
}POST /api/folder
Content-Type: application/json
{ "path": "/chainsaw-man/chapter-003" }DELETE /api/delete
Content-Type: application/json
{ "path": "/chainsaw-man/old-chapter" }PUT /api/rename
Content-Type: application/json
{ "oldPath": "/chainsaw-man/ch-1", "newPath": "/chainsaw-man/chapter-001" }POST /api/upload?path=/chainsaw-man/chapter-001
X-Api-Token: your-token
Content-Type: multipart/form-data
files: [file1.webp, file2.webp, ...]GET /api/stats{ "total": 64000000000, "used": 12000000000, "available": 52000000000 }const CDN = 'https://cdn.yourdomain.com';
const TOKEN = 'your-api-token';
// Fetch list of series
const { items } = await fetch(`${CDN}/api/list?path=/&token=${TOKEN}`).then(r => r.json());
// Fetch chapters in a series
const chapters = await fetch(`${CDN}/api/list?path=/chainsaw-man&token=${TOKEN}`).then(r => r.json());
// Fetch pages in a chapter
const pages = await fetch(`${CDN}/api/list?path=/chainsaw-man/chapter-001&token=${TOKEN}`).then(r => r.json());
// Load image directly (no token needed for /files)
const img = new Image();
img.src = `${CDN}/files/chainsaw-man/chapter-001/001.webp`;- Name pages numerically:
001.webp,002.webpfor correct sort order - Add
cover.webpto each series folder β the reader shows it as the thumbnail - Convert to WEBP:
cwebp -q 80 input.jpg -o output.webp(install:sudo apt install webp) - Batch convert:
for f in *.jpg; do cwebp -q 80 "$f" -o "${f%.jpg}.webp"; done - Cloudflare automatically serves compressed, cached responses from edge nodes
- Change
API_TOKENandPANEL_PASSfrom defaults before going public - The
/filesendpoint is public (no auth) β designed for direct CDN access - The
/api/*and/panelroutes require auth - Panel password is only used to issue the API token (stored in
sessionStorage) - Consider adding Cloudflare Access (zero-trust) to
/panelfor extra protection
pm2 status # check if running
pm2 logs mangacdn # view logs
pm2 restart mangacdn # restart server
pm2 stop mangacdn # stop
# Check Cloudflare tunnel
sudo systemctl status cloudflared
sudo journalctl -u cloudflared -f
# Check USB mount
df -h /mnt/usb
lsblkFormat your USB drive as ext4 for best Linux performance:
# β This erases all data on /dev/sda
sudo mkfs.ext4 -L "mangacdn" /dev/sda1
sudo mkdir -p /mnt/usb/mangaOr exFAT if you need to also read it on Windows/Mac:
sudo apt install exfatprogs
sudo mkfs.exfat -n "mangacdn" /dev/sda1