The Problem: A Gallery Full of 3MB PNGs

When we built the gallery views for Kaizin — our AI tools for LinkedIn covers, LinkedIn post images, and YouTube thumbnails — we made the obvious mistake first. The gallery API returned S3 URLs pointing straight to the original generated images, and the frontend loaded them as-is. Simple, works fine in dev.

In production it was painful. AI-generated images are full-resolution PNGs. A LinkedIn cover comes out at 1584×396 pixels. A YouTube thumbnail at 1280×720. File sizes regularly hit 2–4MB each. Load a gallery page showing 20 past generations and you're pulling 40–80MB just to show card-sized thumbnails. Page loads were slow, bandwidth was being wasted, and mobile users were staring at spinners.

The fix isn't complicated in concept — you need thumbnails. The question is how.

Why imgproxy

The standard options are pre-generating thumbnails at upload time or using a proxy that resizes on demand. We went with the latter: imgproxy, a self-hosted Go binary backed by libvips.

The reasons were practical:

  • No pre-generation plumbing. No worker queue, no resize step in the upload flow, no extra storage for thumbnail variants. imgproxy handles it at request time and caches the result.
  • libvips is fast. It processes images in a streaming, tile-based fashion that's significantly more memory-efficient than ImageMagick or Pillow. Handles concurrent requests without issue on a modest server.
  • It reads directly from MinIO via s3:// scheme. We point imgproxy at our internal MinIO endpoint with S3 credentials and it fetches source images by bucket path. No public bucket URLs, no extra HTTP hops.
  • Auto WebP conversion. imgproxy serves WebP to browsers that support it based on the Accept header. Zero extra code — compatible browsers get smaller files automatically.

MinIO Integration via s3://

imgproxy has first-class support for S3-compatible sources. You configure it with your endpoint and credentials:

IMGPROXY_USE_S3=true
IMGPROXY_S3_ENDPOINT=http://minio:9000
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...

From there, a source path like s3://kaizin-images/covers/abc123.png is all imgproxy needs to fetch and resize the original. MinIO stays internal — only imgproxy ever talks to it directly. Browsers only see the imgproxy URL.

Signed URLs in Production

In production, imgproxy requires a valid HMAC-SHA256 signature on every request. Without it, anyone who understood the URL pattern could request arbitrary resizes from your server.

We generate signed URLs in Python on the backend:

IMGPROXY_URL = "https://imgproxy.dev.iron-mind.ai"

def imgproxy_url(s3_key: str, width: int, height: int = 0) -> str:
    source  = f"s3://{S3_BUCKET}/{s3_key}"
    encoded = base64.urlsafe_b64encode(source.encode()).rstrip(b"=").decode()
    path    = f"/rs:fit:{width}:{height}/plain/{encoded}"
    sig     = base64.urlsafe_b64encode(
                  hmac.new(KEY, SALT + path.encode(), hashlib.sha256).digest()
              ).rstrip(b"=").decode()
    return f"{IMGPROXY_URL}/{sig}{path}"

The key and salt are random hex strings stored as environment variables on both the app and imgproxy sides. The signature covers the full resize path — tamper with the dimensions and the signature breaks, returning a 403.

Three URL Tiers

The gallery API now returns three URLs per image instead of one:

# Gallery card thumbnails — fast to load, small
thumb_url   = imgproxy_url(key, width=400)

# Modal / detail previews — higher quality for inspecting the image
preview_url = imgproxy_url(key, width=900)

# Full-res original — only generated when user explicitly clicks Download
full_res_url = s3_presigned_url(key)

Gallery cards use the 400px thumbnail. Clicking into a preview modal loads the 900px version. The full-resolution PNG is only fetched on an explicit download action. The frontend doesn't need any special logic — it just uses whichever URL the API hands it for each context.

The Result

Gallery thumbnails now land at roughly 20–40KB as WebP, down from 2–4MB as raw PNG. That's a bandwidth reduction of around 98% for the gallery view. Page loads are noticeably faster, the experience on mobile is actually usable, and the server isn't straining to serve data the user never needed at full resolution.

imgproxy has been running without issues. No resizing queue to maintain, no thumbnail variants cluttering storage, no CDN configuration to manage. It sits in front of MinIO, does its job, and stays out of the way. Because it operates at the infrastructure level, every Kaizin tool — LinkedIn covers, post images, YouTube thumbnails — benefits automatically. New tools we add will inherit it without any extra work.

If you're serving AI-generated or user-generated images from S3 and still loading raw originals in your UI, this is one of the highest-leverage infrastructure changes you can make in an afternoon.