Kuanary (KaviCloud) - Image CDN API¶
Kuanary is a self-hosted Cloudinary alternative that uploads images to S3 and serves them through imgproxy with automatic transformations and presets.
Service Status
- Service: Kuanary (KaviCloud)
- Endpoint:
http://localhost:5001(internal) - CDN:
https://cdn.kua.cl - Version: 1.0.0
- Location:
/opt/kuanary/on production VPS
API Key¶
Security
Keep this API key secret! Store in environment variables, never commit to Git.
Quick Start¶
HTML Form Upload¶
<!DOCTYPE html>
<html>
<head>
<title>Upload to Kuanary</title>
</head>
<body>
<h1>Image Upload</h1>
<form id="uploadForm">
<input type="file" id="imageFile" accept="image/*" required>
<button type="submit">Upload</button>
</form>
<div id="result"></div>
<script>
const API_KEY = '0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4';
const KUANARY_URL = 'http://localhost:5001'; // Change to public URL when ready
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('imageFile');
const file = fileInput.files[0];
if (!file) {
alert('Please select a file');
return;
}
// Create form data
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'my-website'); // Optional: organize by project
formData.append('project', 'my-website'); // Optional: use project-specific presets
try {
const response = await fetch(`${KUANARY_URL}/upload`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY // API key in header
},
body: formData
});
const result = await response.json();
if (result.success) {
displayResults(result);
} else {
alert('Upload failed: ' + (result.error || 'Unknown error'));
}
} catch (error) {
alert('Upload error: ' + error.message);
}
});
function displayResults(result) {
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = `
<h2>Upload Successful!</h2>
<p><strong>S3 Key:</strong> ${result.key}</p>
<h3>Available URLs:</h3>
<ul>
<li><strong>Original:</strong> <a href="${result.urls.original}" target="_blank">View</a></li>
<li><strong>Thumbnail:</strong> <a href="${result.urls.thumbnail}" target="_blank">View</a></li>
<li><strong>Medium:</strong> <a href="${result.urls.medium}" target="_blank">View</a></li>
<li><strong>Large:</strong> <a href="${result.urls.large}" target="_blank">View</a></li>
<li><strong>WebP:</strong> <a href="${result.urls.webp}" target="_blank">View</a></li>
</ul>
<h3>Preview:</h3>
<img src="${result.urls.medium}" alt="Uploaded image" style="max-width: 600px;">
`;
}
</script>
</body>
</html>
React/Next.js Component¶
'use client';
import { useState } from 'react';
const KUANARY_API_KEY = '0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4';
const KUANARY_URL = 'http://localhost:5001'; // Change to public URL
interface UploadResult {
success: boolean;
key: string;
urls: {
original: string;
thumbnail: string;
small: string;
medium: string;
large: string;
webp: string;
};
}
export default function ImageUploader() {
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'my-website');
formData.append('project', 'my-website');
const response = await fetch(`${KUANARY_URL}/upload`, {
method: 'POST',
headers: {
'X-API-Key': KUANARY_API_KEY,
},
body: formData,
});
const data = await response.json();
if (data.success) {
setResult(data);
} else {
alert(`Upload failed: ${data.error}`);
}
} catch (error) {
console.error('Upload error:', error);
alert('Upload failed');
} finally {
setUploading(false);
}
};
return (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">Upload Image</h2>
<input
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
className="mb-4"
/>
{uploading && <p>Uploading...</p>}
{result && (
<div className="mt-4">
<h3 className="text-xl font-semibold">Upload Successful!</h3>
<p className="text-sm text-gray-600 mb-2">Key: {result.key}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-medium">Thumbnail</h4>
<img src={result.urls.thumbnail} alt="Thumbnail" className="w-full" />
</div>
<div>
<h4 className="font-medium">Medium</h4>
<img src={result.urls.medium} alt="Medium" className="w-full" />
</div>
</div>
<details className="mt-4">
<summary className="cursor-pointer font-medium">All URLs</summary>
<ul className="mt-2 space-y-1 text-sm">
{Object.entries(result.urls).map(([preset, url]) => (
<li key={preset}>
<strong>{preset}:</strong>{' '}
<a href={url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
{url}
</a>
</li>
))}
</ul>
</details>
</div>
)}
</div>
);
}
Node.js/Express Backend¶
const express = require('express');
const multer = require('multer');
const FormData = require('form-data');
const fetch = require('node-fetch');
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
const KUANARY_API_KEY = '0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4';
const KUANARY_URL = 'http://localhost:5001';
// Upload endpoint
app.post('/api/upload', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
// Create form data for Kuanary
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
contentType: req.file.mimetype,
});
formData.append('folder', 'my-website');
// Upload to Kuanary
const response = await fetch(`${KUANARY_URL}/upload`, {
method: 'POST',
headers: {
'X-API-Key': KUANARY_API_KEY,
...formData.getHeaders(),
},
body: formData,
});
const result = await response.json();
if (result.success) {
res.json({
success: true,
imageUrl: result.urls.medium,
thumbnailUrl: result.urls.thumbnail,
allUrls: result.urls,
});
} else {
res.status(500).json({ error: result.error || 'Upload failed' });
}
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Server error' });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Python/Django Integration¶
import requests
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.http import JsonResponse
from django.views.decorators.http import require_POST
KUANARY_API_KEY = '0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4'
KUANARY_URL = 'http://localhost:5001'
def upload_to_kuanary(file: InMemoryUploadedFile, folder='my-website'):
"""Upload file to Kuanary CDN"""
files = {
'file': (file.name, file.read(), file.content_type)
}
data = {
'folder': folder,
'project': 'my-website'
}
headers = {
'X-API-Key': KUANARY_API_KEY
}
response = requests.post(
f'{KUANARY_URL}/upload',
files=files,
data=data,
headers=headers
)
result = response.json()
if result.get('success'):
return result
else:
raise Exception(f"Upload failed: {result.get('error')}")
@require_POST
def upload_image(request):
file = request.FILES.get('image')
if not file:
return JsonResponse({'error': 'No file provided'}, status=400)
try:
result = upload_to_kuanary(file)
return JsonResponse({
'success': True,
'imageUrl': result['urls']['medium'],
'thumbnailUrl': result['urls']['thumbnail'],
'allUrls': result['urls']
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
Available Presets¶
Global Presets¶
| Preset | Dimensions | Quality | Format | Use Case |
|---|---|---|---|---|
original |
- | - | Original | No transformation |
thumbnail |
150x150 | 80% | - | Small thumbnails |
small |
400px | 85% | - | Cards, previews |
medium |
800px | 85% | - | Content images |
large |
1200px | 85% | - | Hero sections |
og |
1200x630 | 85% | - | Social media |
avatar |
200x200 | 90% | - | User avatars |
webp |
800px | 80% | WebP | Modern browsers |
avif |
800px | 70% | AVIF | Smallest size |
Custom Transformations¶
Use query parameters for custom transformations:
Parameters:
w- Width (pixels)h- Height (pixels)q- Quality (1-100)f- Format (webp,avif,jpg,png)rt- Resizing type (fit,fill,crop)
API Reference¶
1. Upload Image¶
POST /upload
Content-Type: multipart/form-data
X-API-Key: <your-api-key>
Body:
- file: <image file>
- folder: <optional folder path>
- project: <optional project name>
Response:
{
"success": true,
"key": "uploads/2026/01/02/abc123.jpg",
"urls": {
"original": "https://cdn.kua.cl/...",
"thumbnail": "https://cdn.kua.cl/...",
"small": "https://cdn.kua.cl/...",
"medium": "https://cdn.kua.cl/...",
"large": "https://cdn.kua.cl/...",
"webp": "https://cdn.kua.cl/..."
}
}
2. Get Image URL¶
Response:
{
"url": "https://cdn.kua.cl/...",
"key": "uploads/2026/01/02/abc123.jpg",
"params": {"width": 800, "quality": 85}
}
3. List Images¶
Response:
{
"folder": "my-website",
"count": 42,
"images": [
{
"key": "uploads/2026/01/02/abc123.jpg",
"size": 123456,
"modified": "2026-01-02T10:30:00",
"thumbnail": "https://cdn.kua.cl/..."
}
]
}
4. Delete Image¶
Response:
Implementation Guide¶
Add API Key Authentication¶
1. Update /opt/kuanary/app.py
Add after imports:
from functools import wraps
# API Key Authentication
API_KEYS = os.environ.get('API_KEYS', '').split(',')
def require_api_key(f):
"""Decorator to require API key"""
@wraps(f)
def decorated_function(*args, **kwargs):
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
if not API_KEYS or not API_KEYS[0]:
return f(*args, **kwargs)
if not api_key or api_key not in API_KEYS:
return jsonify({'error': 'Invalid or missing API key'}), 401
return f(*args, **kwargs)
return decorated_function
Add decorator to protected routes:
2. Update /opt/custom-apps/docker-compose.yml
3. Rebuild and restart:
cd /opt/kuanary && docker build -t docker-kuanary:latest .
cd /opt/custom-apps && docker-compose restart kuanary
Expose Publicly (Optional)¶
To access from other websites, add Traefik labels:
kuanary:
labels:
- "traefik.enable=true"
- "traefik.http.routers.kuanary.rule=Host(`img-api.kua.cl`)"
- "traefik.http.routers.kuanary.entrypoints=websecure"
- "traefik.http.routers.kuanary.tls.certresolver=cloudflare"
- "traefik.http.services.kuanary.loadbalancer.server.port=5000"
networks:
- custom-apps
- proxy
Add DNS: img-api.kua.cl → Production server IP
Architecture¶
┌─────────────────┐
│ Your Website │
│ (catalog) │
└────────┬────────┘
│ Upload image
↓
┌─────────────────┐
│ Kuanary │
│ (Flask API) │
└────────┬────────┘
│ Store original
↓
┌─────────────────┐
│ S3 Storage │
│ (Hetzner) │
└────────┬────────┘
│ Fetch & transform
↓
┌─────────────────┐
│ imgproxy │
│ (Image resize) │
└────────┬────────┘
│ Serve via CDN
↓
┌─────────────────┐
│ cdn.kua.cl │
│ (Cloudflare) │
└─────────────────┘
Security Best Practices¶
Important
- Never commit API keys to Git
- Store in environment variables
- Validate file types before upload
- Set file size limits (max 20MB by default)
- Consider rate limiting for production
Example .env:
Troubleshooting¶
Worker Timeouts¶
If you see "WORKER TIMEOUT" errors:
# Increase timeout in Dockerfile
CMD ["gunicorn", "--timeout", "120", ...]
# Rebuild
cd /opt/kuanary && docker build -t docker-kuanary:latest .
docker-compose restart kuanary
Permission Errors¶
Check Health¶
Expected response: