Rust · libvips · S3

Images, resized on the fly. Cached in S3.

imgrust is a self-hosted image proxy. Point it at your S3 bucket or any HTTP origin, describe the image you want in the URL, and get it back resized, converted and optimized. Every processed variant is written back to S3, so it is computed exactly once — across restarts and across replicas.

# 400×300 cover crop of an S3 object, served as WebP
curl "https://img.example.com/{signature}/rs:fill:400:300/q:80/plain/s3://media/cats/tom.jpg@webp"

# animated GIF in, animated WebP out, at half the width
curl "https://img.example.com/{signature}/w:320/plain/https://cdn.example.com/party.gif@webp"

Built for load, not for demos

A small Rust server around libvips, the same image engine that powers the fastest image services. No garbage collector, no per-request processes, predictable memory.

S3 in, S3 out

Read originals with s3://bucket/key and persist processed variants to a cache bucket. Works with AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces and other S3-compatible stores.

Animated WebP & GIF

Animations stay animations. Every frame is resized and cropped, timing and looping are preserved, and GIFs are transparently served as much smaller animated WebP to browsers that accept it.

Two cache layers

A byte-bounded in-memory cache answers hot variants without touching the network. The S3 layer makes results durable and shared between every replica you run.

No thundering herd

Concurrent requests for the same uncached variant are coalesced into a single computation. CPU work runs on a bounded worker pool, and overload is shed with 503 instead of eating memory.

Signed URLs

HMAC-SHA256 signatures stop anyone from using your server as a free resize farm. Source allow-lists and limits on file size, resolution and frame count guard against abuse.

Automatic modern formats

Leave the format out of the URL and imgrust negotiates WebP or AVIF from the Accept header, with correct Vary, ETag and Cache-Control so your CDN does the rest.

imgproxy & thumbor URLs

Both URL dialects are served side by side, each with its own signature scheme: /sig/rs:fit:0:210/plain/s3://… and /sig/372x372/path.jpg. Existing URLs and client libraries keep working.

One container

A single binary in a slim Debian image, configured entirely through environment variables. Built-in health check and Prometheus metrics at /metrics.

What happens to a request

The expensive path runs once per variant. Everything after that is a lookup.

VerifySignature and options are checked. A matching If-None-Match returns 304 immediately.
MemoryHot variants are served straight from RAM. X-Cache: HIT
S3 cacheOtherwise the variant is looked up in the cache bucket. X-Cache: HIT-S3
ProcessOn a miss the original is fetched and transformed by libvips. X-Cache: MISS
Write backThe response goes out right away; the upload to the cache bucket happens in the background.

The URL is the API

A signature, any number of processing options, and the source — either plain or base64url-encoded. Coming from thumbor? /{signature}/fit-in/600x400/smart/path/to/image.jpg works too.

https://img.example.com/oKfUtW34Dvo2BGQe…/rs:fill:400:300/g:sm/q:80/plain/s3://media/cats/tom.jpg@webp
  • signature — HMAC-SHA256 of the rest of the path
  • options — what to do with the image
  • source — s3://, http:// or https://
  • format — optional; negotiated when omitted

          
        
OptionMeaning
c:w:h:gravityCrop the source before resizing.
rs:type:w:h:enlargeResize in one go. Type is fit, fill, force or auto.
w:400 h:300Width / height. 0 keeps the aspect ratio.
s:400:300Width and height together.
dpr:2Multiply the dimensions for high-density screens.
el:1Allow upscaling. Off by default.
g:smCrop gravity: ce, no, so, ea, we, corners, or sm (smart).
OptionMeaning
q:80Output quality, 1–100.
f:webpOutput format: jpg, png, webp, avif, gif.
bl:5Gaussian blur with the given sigma.
sh:1Sharpen with the given sigma.
bg:ffffffBackground used when flattening transparency.
cb:v2Cache buster: forces a fresh variant.

Run it

Build the image from the repository and start it with your bucket details. That is the whole deployment.

git clone https://github.com/noneym/imgrust.git && cd imgrust
docker build -t imgrust .

docker run -d -p 8080:8080 \
  -e IMGRUST_KEY=$(openssl rand -hex 32) \
  -e IMGRUST_SALT=$(openssl rand -hex 32) \
  -e IMGRUST_S3_REGION=eu-central-1 \
  -e IMGRUST_S3_ACCESS_KEY=… \
  -e IMGRUST_S3_SECRET_KEY=… \
  -e IMGRUST_CACHE_S3_BUCKET=my-image-cache \
  imgrust

# or try the full local stack (imgrust + a local S3 server)
docker compose up --build

Configuration

Environment variables only. These are the ones you will most likely touch; the README lists all of them.

VariableDefaultPurpose
IMGRUST_KEY / IMGRUST_SALT—Hex-encoded signing key and salt. Without a key, signatures are not checked.
IMGRUST_THUMBOR_KEY / _BASE_URL—Security key and base location (e.g. s3://media/) for thumbor-style URLs.
IMGRUST_S3_ENDPOINTAWSCustom endpoint for R2, MinIO, Spaces and friends.
IMGRUST_S3_REGIONus-east-1Bucket region.
IMGRUST_S3_ACCESS_KEY / _SECRET_KEYAWS chainCredentials. Falls back to the standard AWS credential chain (env, IAM role…).
IMGRUST_CACHE_S3_BUCKET—Bucket for processed variants. Unset disables the S3 cache layer.
IMGRUST_CACHE_S3_PREFIX—Key prefix inside the cache bucket.
IMGRUST_MEM_CACHE_MB256Size of the in-memory cache.
IMGRUST_WORKERSCPU countImages processed in parallel.
IMGRUST_AUTO_WEBP / IMGRUST_AUTO_AVIFtrue / falseFormat negotiation from the Accept header.
IMGRUST_ALLOWED_SOURCESanyComma-separated URL prefixes, e.g. s3://media/.
IMGRUST_MAX_ANIMATION_FRAMES300Frames kept from an animation.