Skip to content

Kuanary - Custom Media CDN

Custom-built media API for uploading, storing, and serving images via S3 + imgproxy.

Formerly known as: kavicloud (renamed during infrastructure migration)


Overview

Purpose: Provide a simple API for uploading images to S3 Object Storage and serving them through imgproxy with custom transformation presets.

Architecture:

Client Upload → Kuanary API → Hetzner S3 Object Storage
Client Request → Kuanary API → imgproxy → S3 → Transformed Image

Access: - URL: https://media.kua.cl - Port: 5001 - Container: kuanary - Location: /opt/kuanary


Features

  • Upload images to S3 Object Storage
  • Generate signed imgproxy URLs for transformations
  • Custom transformation presets (resize, crop, format conversion)
  • S3-backed storage (scalable, durable)
  • Integration with imgproxy for on-the-fly image optimization

Architecture

Components

  1. Kuanary API: Node.js/Express API for upload and URL generation
  2. S3 Object Storage: Hetzner Object Storage for image storage
  3. imgproxy: Image transformation service
  4. Presets: Custom transformation presets stored in /opt/kuanary/data/presets.json

Data Flow

Upload Flow

1. Client sends image to Kuanary API (POST /upload)
2. Kuanary uploads image to S3 with unique filename
3. Kuanary returns signed imgproxy URL with preset
4. Client receives URL for accessing transformed image

Retrieval Flow

1. Client requests image via imgproxy URL
2. imgproxy validates signature
3. imgproxy fetches original from S3
4. imgproxy applies transformations (resize, crop, format)
5. imgproxy returns optimized image
6. Client caches transformed image

Installation & Configuration

Directory Structure

/opt/kuanary/
├── data/
│   └── presets.json          # Image transformation presets
├── .env                      # Environment variables
├── docker-compose.yml        # Docker Compose configuration
└── Dockerfile (if custom build)

Environment Variables

Located in /opt/kuanary/.env:

# S3 Configuration
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=fsn1
S3_BUCKET=kuanary-media
S3_ACCESS_KEY=<stored in Infisical>
S3_SECRET_KEY=<stored in Infisical>

# imgproxy Configuration
IMGPROXY_URL=http://cdn.kua.cl
IMGPROXY_KEY=<stored in Infisical>
IMGPROXY_SALT=<stored in Infisical>

# API Configuration
PORT=5001
NODE_ENV=production

Security Note: S3 and imgproxy credentials are stored in Infisical (secrets.kua.cl)

Docker Compose

version: '3.8'

services:
  kuanary:
    image: kuanary:latest
    container_name: kuanary
    restart: unless-stopped
    ports:
      - "5001:5001"
    env_file:
      - .env
    volumes:
      - ./data:/app/data
    networks:
      - kuanary-network

networks:
  kuanary-network:
    driver: bridge

API Endpoints

POST /upload

Upload an image to S3 and get a signed imgproxy URL.

Request:

curl -X POST https://media.kua.cl/upload \
  -F "image=@photo.jpg" \
  -F "preset=thumbnail"

Response:

{
  "success": true,
  "url": "https://cdn.kua.cl/<signature>/rs:fit:300:300/plain/s3://kuanary-media/image-uuid.jpg",
  "filename": "image-uuid.jpg"
}

GET /presets

List available transformation presets.

Request:

curl https://media.kua.cl/presets

Response:

{
  "thumbnail": {
    "resize": "fit",
    "width": 300,
    "height": 300,
    "format": "webp"
  },
  "hero": {
    "resize": "fill",
    "width": 1920,
    "height": 1080,
    "format": "webp"
  }
}


Transformation Presets

Presets are defined in /opt/kuanary/data/presets.json:

{
  "thumbnail": {
    "resize": "fit",
    "width": 300,
    "height": 300,
    "format": "webp",
    "quality": 80
  },
  "hero": {
    "resize": "fill",
    "width": 1920,
    "height": 1080,
    "format": "webp",
    "quality": 85
  },
  "og-image": {
    "resize": "fill",
    "width": 1200,
    "height": 630,
    "format": "jpg",
    "quality": 90
  }
}

Adding a New Preset

  1. Edit /opt/kuanary/data/presets.json
  2. Add new preset definition
  3. Restart Kuanary: docker-compose restart

Example:

{
  "avatar": {
    "resize": "fill",
    "width": 200,
    "height": 200,
    "format": "webp",
    "quality": 85,
    "crop": "face"
  }
}


Backup & Recovery

What to Backup

  1. Data directory: /opt/kuanary/data (presets.json)
  2. Configuration: /opt/kuanary/.env
  3. Docker Compose: /opt/kuanary/docker-compose.yml
  4. S3 data: Managed separately (Hetzner Object Storage)

Backup Strategy

  • Data directory: Daily backup to Storage Box
  • S3 credentials: Stored in Infisical
  • S3 data: Hetzner manages durability (not backed up separately)

Recovery Process

# 1. Restore data directory
rsync -avz /mnt/storagebox/backups/daily/kuanary/ /opt/kuanary/data/

# 2. Restore configuration
cp /mnt/storagebox/backups/configs/kuanary/.env /opt/kuanary/

# 3. Get S3 credentials from Infisical
# (manually update .env with credentials from secrets.kua.cl)

# 4. Rebuild container (if needed)
cd /opt/kuanary && docker-compose up -d --build

# 5. Verify service
curl https://media.kua.cl/presets

Recovery Time: 10 minutes


Integration with imgproxy

Kuanary generates signed imgproxy URLs to prevent unauthorized transformations.

imgproxy URL Structure

https://cdn.kua.cl/<signature>/<processing_options>/plain/<source_url>

Example:

https://cdn.kua.cl/abc123def456/rs:fit:300:300/g:ce/plain/s3://kuanary-media/image.jpg
                    ^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                    signature   resize      gravity   source URL

Processing Options

  • rs:fit:300:300 - Resize to fit within 300x300
  • rs:fill:1920:1080 - Resize and crop to fill 1920x1080
  • g:ce - Gravity center (crop from center)
  • g:sm - Gravity smart (use ML to detect subject)
  • f:webp - Convert format to WebP
  • q:85 - Quality 85%

Signature Generation

imgproxy uses HMAC-SHA256 signatures to validate requests:

const crypto = require('crypto');

function generateSignature(path) {
  const key = Buffer.from(process.env.IMGPROXY_KEY, 'hex');
  const salt = Buffer.from(process.env.IMGPROXY_SALT, 'hex');

  const hmac = crypto.createHmac('sha256', key);
  hmac.update(salt);
  hmac.update(path);

  return hmac.digest('base64url');
}

Maintenance

View Logs

ssh production "docker logs -f kuanary"

Restart Service

ssh production "cd /opt/kuanary && docker-compose restart"

Update Presets

# 1. Edit presets on MacBook
vim /Users/kavi/kavi-infra/kuanary/data/presets.json

# 2. Copy to VPS
scp presets.json production:/opt/kuanary/data/

# 3. Restart Kuanary
ssh production "cd /opt/kuanary && docker-compose restart"

Check S3 Storage Usage

# Using AWS CLI (requires credentials)
aws s3 ls s3://kuanary-media --recursive --summarize --human-readable

Troubleshooting

Issue: Upload fails with "S3 credentials invalid"

Solution: 1. Check Infisical for current S3 credentials 2. Update /opt/kuanary/.env with correct credentials 3. Restart Kuanary: docker-compose restart

Issue: imgproxy returns "Invalid signature"

Solution: 1. Verify IMGPROXY_KEY and IMGPROXY_SALT match between Kuanary and imgproxy 2. Check /opt/kuanary/.env and /opt/imgproxy/.env 3. Restart both services

Issue: Images not loading

Solution: 1. Check imgproxy is running: docker ps | grep imgproxy 2. Check S3 connectivity: curl https://fsn1.your-objectstorage.com 3. Verify S3 bucket exists and is accessible


Performance Optimization

Client-Side Caching

imgproxy sets appropriate cache headers:

Cache-Control: public, max-age=31536000

Clients should cache transformed images for 1 year.

CDN Integration (Future)

Consider adding Cloudflare CDN in front of imgproxy:

Client → Cloudflare CDN → imgproxy → S3

Benefits: - Reduced imgproxy load - Faster global delivery - DDoS protection



Migration Notes

Renamed from kavicloud

During the December 2025 infrastructure migration, this service was renamed from "kavicloud" to "kuanary".

Migration checklist: - [x] Renamed container from kavicloud to kuanary - [x] Updated directory from /opt/kavicloud to /opt/kuanary - [x] Updated DNS from kavicloud.kua.cl to media.kua.cl - [x] Updated documentation references - [x] Updated navigation in mkdocs.yml

No data migration required - S3 bucket and data unchanged.