When you run a website with screenshots, diagrams, and article images, you need image hosting. I tried all the free options — Imgur, SM.MS, GitHub issues — and every single one had limitations. Either they compress images, limit bandwidth, or disappear without notice.
So I built my own. Here's exactly how.
Why Self-Host?
| Solution | Cost | Limitations | Control |
|----------|------|-------------|---------|
| Imgur | Free | Compresses images, may delete inactive content | None |
| SM.MS | Free/$5/yr | 10MB limit, occasionally unreliable | None |
| GitHub Issues | Free | Public repo, rate limited | Limited |
| Self-hosted (EasyImage) | ~$5/mo VPS | Your bandwidth | Full |
For a content site, full control over your images is worth the setup effort. Once it's running, it costs practically nothing.
What We're Building
User upload → EasyImage (Docker) → Cloudflare Tunnel → Public URL
- **EasyImage** — Simple, lightweight image hosting PHP app
- **Docker** — Clean deployment with auto-restart
- **Cloudflare Tunnel** — Secure public access without opening ports
Step 1: Deploy EasyImage with Docker
docker run -d \
--name easyimage \
--restart unless-stopped \
-p 8080:80 \
-v /path/to/images:/app/web/i \
-v /path/to/config:/app/web/config \
icy2003/easyimage:latest
The application runs on port 8080 locally. `/app/web/i` stores uploaded images, and `/app/web/config` holds the configuration.
Step 2: Configure Cloudflare Tunnel
Instead of exposing port 8080 to the internet (which invites attacks), we use Cloudflare Tunnel:
# config.yaml
tunnel: your-tunnel-id
credentials-file: /path/to/credentials.json
ingress:
- hostname: images.yourdomain.com
service: http://localhost:8080
- service: http_status:404
cloudflared tunnel route dns your-tunnel-id images.yourdomain.com
cloudflared tunnel run your-tunnel-id
Now your image hosting is accessible at `https://images.yourdomain.com` with:
- ✅ No open firewall ports
- ✅ DDoS protection
- ✅ Free SSL/TLS
- ✅ Global CDN caching
Step 3: Security Hardening
After deploying, I found several security issues that needed fixing:
Disable Anonymous Uploads
EasyImage allows anonymous uploads by default. Any bot that finds your URL can upload arbitrary files:
# In EasyImage config
'allow_anonymous' => false # Require upload token
Set File Size Limits
client_max_body_size 10M; # Per-image limit
Enable Image Validation
Restrict uploads to image MIME types only — no PDF, no ZIP, no PHP files disguised as images.
Step 4: Integrate with Your Site
Once your image hosting is running, uploading from scripts is straightforward:
curl -X POST https://images.yourdomain.com/api/upload \
-F "image=@screenshot.png" \
-F "token=your-upload-token"
The response includes the public URL, which you can directly embed in your articles:

Real-World Performance
After 3 months of production use:
| Metric | Value |
|--------|-------|
| Images stored | 200+ |
| Total size | ~1.2 GB |
| Average load time | ~200ms (with Cloudflare cache) |
| Uptime | 99.9% |
| Cost | $0 additional (shared VPS) |
Pitfalls I Hit
- **PHP opcache** — After updating config, changes weren't reflected because opcache cached the old file. Clear opcache after config changes.
2. Dual-upload vulnerability — The admin panel and API both accept uploads. If you lock one but not the other, it's not secure.
3. Image compression — EasyImage doesn't compress by default. For large screenshots, add a compression step before upload.
Verdict
Self-hosted image hosting is one of those projects that feels like overkill until you've used it for a week. The convenience of having your own image CDN, with no file size limits, no compression, and no third-party dependency, is hard to give up once you have it.
Total setup time: ~1 hour
Ongoing maintenance: ~0 hours
Worth it: Absolutely.