When you self-host Supabase, image transformations aren't just a nice-to-have—they're essential for building performant applications. Serving a 4MB JPEG when a 100KB WebP would do wastes bandwidth, slows down your app, and frustrates users on mobile connections.
Supabase Cloud includes image transformations out of the box. Self-hosters need to configure imgproxy themselves. This guide walks through the complete setup: from deploying the container to production optimization and troubleshooting common issues.
Why Image Transformations Matter
Before diving into configuration, let's understand what we're solving.
The problem: Users upload images in whatever format and resolution their devices produce. Modern phones capture 48MP images. A product catalog page displaying 20 products doesn't need 20 full-resolution images—it needs thumbnails optimized for the user's screen.
The solution: Transform images on-the-fly. When a client requests an image, resize it, convert it to an efficient format (WebP, AVIF), and cache the result. Subsequent requests serve the cached version.
Supabase uses imgproxy under the hood—a fast, secure image processing server written in Go. It's battle-tested and handles millions of transformations daily across Supabase's infrastructure.
Basic imgproxy Setup
The Supabase Docker Compose stack includes imgproxy by default, but it requires explicit configuration to enable image transformations.
Step 1: Verify imgproxy Is Running
First, check your Docker Compose file includes the imgproxy service:
imgproxy:
image: darthsim/imgproxy:latest
container_name: supabase-imgproxy
restart: unless-stopped
environment:
- IMGPROXY_BIND=:8080
- IMGPROXY_LOCAL_FILESYSTEM_ROOT=/
- IMGPROXY_USE_ETAG=true
- IMGPROXY_ENABLE_WEBP_DETECTION=true
volumes:
- ./volumes/storage:/var/lib/storage
networks:
- supabase-network
Verify it's running:
docker ps | grep imgproxy
Step 2: Configure Storage API
The Storage API service needs to know where imgproxy lives. Add these environment variables to your storage service:
ENABLE_IMAGE_TRANSFORMATION=true IMGPROXY_URL=http://imgproxy:8080
In your docker-compose.yml:
storage:
image: supabase/storage-api:latest
environment:
# ... existing config ...
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: "http://imgproxy:8080"
Restart both services:
docker compose restart storage imgproxy
Step 3: Test the Setup
Upload a test image to any bucket, then request it with transformation parameters:
# Original image curl "https://your-supabase-url/storage/v1/object/public/bucket/test.jpg" # Resized to 200px width curl "https://your-supabase-url/storage/v1/render/image/public/bucket/test.jpg?width=200"
If you get a transformed image, the basic setup is working. If not, check the Storage API logs:
docker logs supabase-storage-api 2>&1 | grep -i imgproxy
Essential Configuration Options
The default imgproxy configuration works, but production deployments need tuning. Here are the environment variables that matter.
Image Resolution Limits
By default, imgproxy limits source images to 16.8 megapixels. Modern phone cameras easily exceed this. Increase the limit:
environment: - IMGPROXY_MAX_SRC_RESOLUTION=50 # 50 megapixels
The Supabase docs claim 50MP support, but this requires explicit configuration. Without it, users uploading high-resolution photos will get errors.
Output Quality
Balance quality against file size:
environment: - IMGPROXY_QUALITY=80 # JPEG quality (1-100) - IMGPROXY_AVIF_SPEED=5 # AVIF encoding speed (0=slowest, 8=fastest) - IMGPROXY_PNG_QUANTIZATION_COLORS=256 # PNG color palette size
Quality 80 is a reasonable default—visually indistinguishable from 100 for most images, but significantly smaller files.
Memory Management
imgproxy uses memory proportional to image dimensions, not file size. A 4000x3000 pixel image uses the same memory whether it's a 200KB JPEG or a 20MB TIFF.
environment: - IMGPROXY_MAX_ANIMATION_FRAMES=100 # Limit GIF frames processed - IMGPROXY_DOWNLOAD_BUFFER_SIZE=0 # Stream instead of buffering
For containers with limited memory:
imgproxy:
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 256M
Caching Configuration
imgproxy generates ETag headers by default, enabling client-side caching. For server-side caching, consider adding a reverse proxy cache:
environment: - IMGPROXY_USE_ETAG=true - IMGPROXY_CACHE_CONTROL_PASSTHROUGH=true
If you're using Nginx or Traefik as a reverse proxy, configure caching there for transformed images.
Security Hardening
imgproxy should never be directly exposed to the internet. The Storage API proxies requests and handles authentication.
Network Isolation
Ensure imgproxy is only accessible from the Storage API:
imgproxy:
networks:
- supabase-internal
# No port mapping - not exposed to host
Signed URLs
When using private buckets, the Storage API validates JWTs before forwarding to imgproxy. This prevents unauthorized image access while still allowing transformations.
const { data } = await supabase.storage
.from('private-bucket')
.createSignedUrl('image.jpg', 3600, {
transform: {
width: 200,
height: 200,
resize: 'cover'
}
});
Rate Limiting
Add rate limiting at the reverse proxy level to prevent abuse:
# Nginx example
limit_req_zone $binary_remote_addr zone=imgproxy:10m rate=10r/s;
location /storage/v1/render/ {
limit_req zone=imgproxy burst=20 nodelay;
proxy_pass http://supabase-storage:5000;
}
Using Transformations in Your App
Supabase provides two ways to request transformed images.
Public Buckets
For public buckets, use the render endpoint:
const { data: { publicUrl } } = supabase.storage
.from('public-bucket')
.getPublicUrl('image.jpg', {
transform: {
width: 400,
height: 300,
resize: 'contain' // or 'cover', 'fill'
}
});
Private Buckets with Signed URLs
For private buckets, include transformations when creating signed URLs:
const { data } = await supabase.storage
.from('private-bucket')
.createSignedUrl('image.jpg', 3600, {
transform: {
width: 400,
quality: 75,
format: 'webp'
}
});
Available Transform Options
| Parameter | Values | Description |
|---|---|---|
width | 1-2500 | Output width in pixels |
height | 1-2500 | Output height in pixels |
resize | cover, contain, fill | Resize mode |
quality | 20-100 | Output quality (default: 80) |
format | webp, avif, origin | Output format |
Format detection: When you don't specify a format, Supabase automatically serves WebP to browsers that support it. This reduces bandwidth without any code changes.
Performance Optimization
CDN Integration
Transformed images are perfect for CDN caching. Each URL with specific transform parameters produces a deterministic result:
/storage/v1/render/image/public/bucket/photo.jpg?width=200&quality=80
Put a CDN like Cloudflare or Bunny in front of your Supabase instance. The first request transforms the image; subsequent requests serve from CDN cache.
Pregenerate Common Sizes
For frequently accessed images (profile photos, product thumbnails), consider pregenerating common sizes during upload using Edge Functions:
import { createClient } from '@supabase/supabase-js';
const sizes = [100, 200, 400, 800];
Deno.serve(async (req) => {
const { bucket, path } = await req.json();
const supabase = createClient(/* ... */);
// Warm the cache by requesting each size
for (const width of sizes) {
const url = supabase.storage
.from(bucket)
.getPublicUrl(path, { transform: { width } });
await fetch(url.data.publicUrl);
}
return new Response('OK');
});
Monitor Memory Usage
High-resolution images can spike memory usage. Monitor imgproxy container metrics:
docker stats supabase-imgproxy
If you see memory spikes causing OOM kills, either:
- Increase container memory limits
- Reduce
IMGPROXY_MAX_SRC_RESOLUTION - Add
IMGPROXY_CONCURRENCYto limit parallel processing
Troubleshooting Common Issues
"Source image resolution is too big"
Cause: Image exceeds IMGPROXY_MAX_SRC_RESOLUTION (default 16.8MP).
Fix: Increase the limit in imgproxy environment:
- IMGPROXY_MAX_SRC_RESOLUTION=50
Transformations Return 404
Cause: Storage API can't reach imgproxy.
Check:
- Both services on same Docker network?
IMGPROXY_URLcorrectly configured in Storage API?- imgproxy container running?
docker exec supabase-storage-api curl -v http://imgproxy:8080/health
Slow Transformation Times
Cause: Large images, complex operations, or resource constraints.
Fix:
- Resize before upload when possible
- Increase imgproxy container resources
- Add caching layer (CDN or reverse proxy cache)
Format Not Converting
Cause: Client browser doesn't advertise WebP/AVIF support.
Check: Ensure IMGPROXY_ENABLE_WEBP_DETECTION=true is set.
When Not to Use On-the-Fly Transformations
Real-time transformations add latency (typically 50-200ms for the first request). Consider alternatives when:
- Serving static assets: Pregenerate during build time
- Extremely high traffic: Pregenerate and serve from CDN
- Complex operations: Use a dedicated image pipeline
For most applications, on-the-fly transformation with CDN caching provides the best balance of flexibility and performance.
What Supascale Handles
Setting up imgproxy correctly involves Docker configuration, network isolation, security hardening, and performance tuning. Supascale automates this infrastructure:
- imgproxy comes preconfigured with production-ready settings
- Network isolation handled automatically
- Automated backups include Storage files
- Custom domains work seamlessly with image URLs
For teams that want image transformations working out of the box without the infrastructure overhead, check out our pricing for a one-time purchase that includes unlimited projects.
Further Reading
- Storage Backup Guide - Don't forget to backup your images
- Docker Compose Best Practices - Production-ready configuration
- Reverse Proxy Setup - Add CDN caching
- imgproxy Documentation - Advanced configuration options
