Skip to content

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

0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4

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:

GET /image/<s3_key>?w=500&h=300&q=90&f=webp&rt=fill

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

GET /image/<s3_key>?preset=medium
X-API-Key: <your-api-key>

Response:

{
  "url": "https://cdn.kua.cl/...",
  "key": "uploads/2026/01/02/abc123.jpg",
  "params": {"width": 800, "quality": 85}
}

3. List Images

GET /list/<folder>
X-API-Key: <your-api-key>

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

DELETE /delete/<s3_key>
X-API-Key: <your-api-key>

Response:

{
  "success": true,
  "deleted": "uploads/2026/01/02/abc123.jpg"
}


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:

@app.route('/upload', methods=['POST'])
@require_api_key
def upload():
    ...

2. Update /opt/custom-apps/docker-compose.yml

kuanary:
  environment:
    - API_KEYS=0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4

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

  1. Never commit API keys to Git
  2. Store in environment variables
  3. Validate file types before upload
  4. Set file size limits (max 20MB by default)
  5. Consider rate limiting for production

Example .env:

KUANARY_API_KEY=0854b22c7d2d5a00091fcf95155d6ff0f8fe7e81cc3492efdbd8c15caf2060b4


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

# Fix data directory permissions
sudo chown -R 1000:1000 /opt/kuanary/data

Check Health

curl http://localhost:5001/health

Expected response:

{
  "status": "ok",
  "service": "KaviCloud",
  "version": "1.0.0"
}