Self-Hosted Image Hosting: GitHub Private Repo, Cloud Server, and Cloudflare DNS Proxy
Every blog hits the same wall eventually: your image hosting stops being "good enough." For me the wall was literal — half my readers couldn't load the images at all. This article covers the image hosting setup I ended up with on wuhuajin.com: a GitHub private repository as the source of truth, a mainland cloud server as the origin, and Cloudflare DNS proxying in front of it. It's a near-free architecture, it's fast for both Chinese and overseas visitors, and it has survived real traffic. Here's how it works and the exact pitfalls to avoid.
Why a GitHub CDN stopped being enough
My blog images originally lived in a GitHub repository and were served through GitHub's public CDN (jsDelivr / raw.githubusercontent.com). That's a classic zero-cost setup and it works fine — for overseas readers.
Two things broke it:
-
raw.githubusercontent.comis blocked in mainland China. Open the browser console on a page loading such images and you seeERR_CONNECTION_RESET. No retry, no fallback — the images simply vanish for a huge share of your audience. - The audience mix is real. Although the site's content is English-oriented, most of its current visitors are from mainland China. Any solution that sacrifices their experience to optimize for everyone else is the wrong trade.
The tempting fix — "just put Cloudflare's free CDN in front of it" — doesn't work either: the free Cloudflare plan has no mainland China edge nodes. Proxying a mainland origin through an overseas edge makes domestic visitors slower, not faster (their traffic leaves China and comes back). That's the exact opposite of what you want.
So the requirements were:
- Images served from a mainland origin for domestic visitors (fast, and the domain is already ICP-registered, so this is legal and clean).
- Overseas visitors still get an edge cache, so they don't hammer a tiny server's bandwidth.
- Git keeps being the source of truth — versioning, backup, and a clean sync path to the server.
The architecture
GitHub private repo (yourname/your-images)
│ git clone
▼
Aliyun ECS (mainland, low-spec) → /var/www/img-origin
│ Nginx: only /images/ is public
▼
img.yourdomain.com (subdomain)
│ Cloudflare DNS proxy (orange cloud)
▼
Visitors: domestic → mainland origin directly; overseas → Cloudflare edge cache
Each layer has exactly one job. GitHub stores and versions the images. The server serves them. Cloudflare proxies and caches for the non-mainland half of the world. HTTPS is handled with a certbot certificate validated through Cloudflare's DNS API.
Step 1: Keep images in a private GitHub repo
The repo is private on purpose. It contains private assets that aren't meant for public browsing — I don't want the raw directory browsable by anyone. GitHub gives me three things for free: version history, off-site backup, and an easy way to sync the server (git pull).
Practical rules that saved me pain:
- Keep one image directory (
/images/) and nothing else that matters in the web root. The repo also holds the deploy scripts, but Nginx must never serve them (more below). -
.gitignorethe things that must never be committed: API key files (.env), macOS metadata (._*,.DS_Store), and anything local.git statusis your friend — verify what actually lands in the repo before pushing.
Step 2: Clone to the server and lock Nginx down
SSH into the server, clone the repo, and point Nginx at the checkout:
git clone git@github.com:yourname/your-images.git /var/www/img-origin
The Nginx config is where "self-hosted" either becomes safe or becomes a liability. The whole trick is deny by default, allow one directory:
server {
listen 80;
server_name img.yourdomain.com;
location / { return 301 https://$host$request_uri; } # force HTTPS
}
server {
listen 443 ssl http2;
server_name img.yourdomain.com;
root /var/www/img-origin;
# deny hidden files and directories (.git, .env, ...)
location ~ /\. { deny all; }
# deny everything else in the repo root (scripts, README, ...)
location / { deny all; }
# the only public directory
location /images/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
server_tokens off;
autoindex off;
}
The details that matter:
-
Only
/images/is open. A repo checkout contains.git,.env, deploy scripts — all of it is denied.location ~ /\.catches hidden files before any prefix match can leak them. - 7-day cache on images. Your readers' browsers stop re-fetching; the origin's tiny bandwidth is used only for cache misses.
- Redirect 80 → 443 so no one ever loads the image host over plain HTTP.
-
Security headers and
server_tokens offare cheap hygiene for a public host.
Step 3: Cloudflare DNS proxy — orange cloud, on purpose
wuhuajin.com's DNS is hosted at Cloudflare. The decision I had to defend was whether the new img.yourdomain.com record should be proxied (orange cloud) or DNS-only (gray cloud).
- Gray cloud (DNS-only) is fastest for domestic visitors — they resolve straight to the mainland origin, no Cloudflare hop.
- Orange cloud (proxied) gives overseas visitors the Cloudflare edge cache, which keeps their traffic off the server's 1 Mbps uplink entirely.
For a mixed audience I chose orange cloud: A img.yourdomain.com → 203.0.113.10 with proxying enabled. Domestic users still reach a mainland origin quickly (Cloudflare routes them to the nearest available path), and overseas users get cached edge delivery. The 1 Mbps origin is protected from the global traffic. If the audience were 100% domestic, gray cloud would be right; if it were 100% overseas, a full CDN would be right. Mixed audience → proxied record.
Two Cloudflare-specific rules I learned the hard way:
-
Only add records; don't touch the existing ones. The domain already had a set of records for the main site, subdomains, and Workers. Add the new
imgrecord, verify withdig, and leave everything else alone. - Never flip the global SSL/TLS mode for one host. SSL mode (e.g. "Full (strict)") is a domain-level setting — changing it affects every proxied record, including your main site. Keep the recommended automatic mode and let Cloudflare upgrade traffic progressively. The origin still gets its own real certificate (next step), which is what actually matters.
Step 4: HTTPS with certbot, DNS-01 validation
The origin certificate is issued with certbot using DNS-01 validation through Cloudflare's API. Why DNS-01 instead of the usual HTTP-01?
- No open port or webroot needed — it validates by creating a TXT record.
- It works even when the record is behind Cloudflare's proxy.
- It's wildcard-ready if you ever need it.
Setup is three small pieces. First, a credentials file on the server (single line, no quotes):
dns_cloudflare_api_token=your_real_api_token_here
chmod 600 ~/.secrets/cloudflare-credentials.ini # the token is a secret, protect it
Then issue the certificate:
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials ~/.secrets/cloudflare-credentials.ini \
-d img.yourdomain.com
Point the Nginx SSL directives at the certbot output paths, run nginx -t, then reload. certbot's automatic renewal handles everything after that.
Step 5: Cut over the URLs
The last step is the one people forget: update every existing reference. My site keeps its navigation and link data in data.json files, and four URLs still pointed at the old path (/img/...). They had to become /images/... — sidebar entries, friend links, all of it — then the site had to be rebuilt.
Do the migration as a checklist, not a find-and-replace hope:
- [ ] Find all old image URL prefixes in your content/data (
grep -rn "/img/"is your friend) - [ ] Update them to the new path and rebuild
- [ ] Verify with
curl -I https://img.yourdomain.com/images/...— expect200and the cache headers - [ ] Check one page in the browser console to confirm zero
ERR_CONNECTION_RESETerrors
Bandwidth: the honest constraint
A low-spec mainland ECS — the kind that costs almost nothing — has a 1 Mbps uplink, roughly 128 KB/s. Static images barely touch the CPU, but bandwidth is a hard ceiling: a single 500 KB image takes about 4 seconds to transfer from the origin.
This is why the setup is a system, not a single decision:
- Compress at build time. WebP images at 100–200 KB each change the whole math.
- Long browser caching (the 7-day header above) so repeat visits cost nothing.
- Lazy loading so off-screen images aren't fetched at all.
- Keep the volume in control — the image set is reviewed, not accumulated.
- Cloudflare edge caching so overseas traffic rarely touches the origin at all.
Respect the bandwidth budget and the cheap server is fine; ignore it and no amount of Cloudflare will save you.
The checklist I wish I'd had
- [ ] Image repo is private,
.gitignoreverified withgit status - [ ] Nginx: only
/images/public; hidden files and repo root denied - [ ] 80 → 443 redirect; security headers on
- [ ] Cloudflare:
imgrecord added and proxied; global SSL mode untouched - [ ] certbot DNS-01 certificate issued and auto-renewing
- [ ] All old
/img/URLs cut over to/images/and rebuilt - [ ]
curl -Ireturns 200 with cache headers; console has no blocked-image errors
The bottom line
Self-hosted image hosting doesn't have to mean "server chugging under a global load." The stack here is a private GitHub repo for source control, a mainland cloud server as origin, Nginx serving exactly one public directory, and Cloudflare proxying DNS for the rest of the world — with certbot handling HTTPS. Total recurring cost is essentially the cheapest server tier you can rent.
The real lesson is architectural: choose your origin for your dominant audience, then use CDN and DNS to serve everyone else. My dominant audience is in mainland China, so images live on a mainland server; Cloudflare gives the overseas half an edge cache and never lets global traffic touch the 1 Mbps uplink. When the audience mix changes, the fix is one DNS toggle — not a migration.
This article documents the real image hosting architecture behind wuhuajin.com — a GitHub private repo, a mainland cloud server, and Cloudflare DNS proxying. If you're weighing the same trade-offs, I'm happy to answer questions.
Originally published on wuhuajin.com.
Top comments (0)