Self-Host Imagor: Resize Images Straight from the URL
You upload a 4 MB photo from a decent camera. Someone opens the page on a phone over patchy 4G, and you send them all 4 MB of it so their browser can scale it down to 400 pixels wide.
The usual fix is to generate thumbnails at upload time. Small, medium, large, done. Then the design changes and you need a 600px variant, so you write a backfill script for 80,000 images. Then marketing wants WebP. Then someone asks for a square crop for the social cards.
Imagor takes the other approach. You keep one original, and every size you need is just a URL.
What It Is
Imagor is an image processing server written in Go on top of libvips, licensed Apache 2.0. Version 1.9.14 shipped on 24 October. It adopts the thumbor URL syntax, so transforms are expressed as path segments rather than query parameters or an API call.
Getting it running takes one command:
docker run -p 8000:8000 shumc/imagor -imagor-unsafe -imagor-auto-webp
Then a resize is a URL:
http://localhost:8000/unsafe/fit-in/200x200/filters:fill(white)/example.com/photo.jpg
Hold that unsafe in your head. We are coming back to it, and it is the whole point of this article.
The URL Grammar
Segments come in a fixed order, and every one of them is optional except the hash and the image:
| Segment | What it does |
|---|---|
HASH or unsafe |
URL signature, or the literal word unsafe when signing is off. |
trim |
Removes surrounding whitespace using a corner pixel's colour. |
AxB:CxD |
Manual crop, top-left to bottom-right. Pixels, or fractions between 0 and 1. |
fit-in or stretch |
Fit inside the box without cropping, or force exact dimensions and ignore aspect ratio. |
-ExF |
Width and height. A minus sign flips the image on that axis. |
smart |
Focal point detection, so crops keep the subject instead of the centre. |
filters:... |
Chained operations applied after resizing, such as fill(white):quality(80):format(webp). |
Padding and crop alignment sit between the dimensions and smart. The source image comes last, and can be URL-encoded or prefixed with b64:.
The practical consequence is that your frontend never calls an API. A srcset is five URLs that differ by one number.
The Part Everyone Gets Wrong
Every quickstart, including the official one, starts imagor with -imagor-unsafe. That flag turns off signature verification entirely, which is exactly what you want while you are experimenting on localhost.
Ship it that way and you have published an open image processing proxy on the internet.
Anyone who finds the endpoint can point it at any source URL they like and ask for any transform. That is your CPU and your bandwidth, on demand, for free, for whoever wants it. The imagor docs are blunt about the consequence: resource exhaustion and DDoS-style abuse through arbitrary image processing.
The fix is to set a secret and sign your URLs. Setting IMAGOR_SECRET makes a valid signature mandatory on every request.
The signature is an HMAC of the URL path, with the /unsafe/ prefix excluded, keyed on your secret and Base64 URL-encoded. It becomes the first path segment. The reference implementation from the docs is six lines:
import hmac
import hashlib
import base64
def sign(path: str, secret: str) -> str:
h = hmac.new(secret.encode(), path.encode(), hashlib.sha1)
hash = base64.urlsafe_b64encode(h.digest()).decode()
return hash + '/' + path
Which turns a path into something like this:
cST4Ko5_FqwT3BDn-Wf4gO3RFSk=/500x500/top/raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png
That hash belongs to that exact path. Change one character of the path and the signature no longer matches, which is the entire point.
Two things worth adding on top. The default signer is SHA-1, which is not cryptographically secure any more, so set IMAGOR_SIGNER_TYPE=sha256. And if a URL should not live forever, the expire filter embeds a Unix timestamp in milliseconds inside the signed path. Imagor rejects an expired request even when the signature is valid.
Separately, lock down what it is allowed to fetch. HTTP_LOADER_ALLOWED_SOURCES takes a comma-separated glob allowlist of hosts. Without it, a signed URL can still pull from anywhere on the internet.
Two Settings That Decide Your Bill
Result storage. By default imagor reprocesses on every request. Set FILE_RESULT_STORAGE_BASE_DIR for disk, or S3_RESULT_STORAGE_BUCKET for S3-compatible storage, and processed results get cached. Skipping this is the single most common reason a deployment feels slow and expensive.
VIPS_CONCURRENCY. This one reads like a throughput dial and is not. It controls libvips threads per operation, not how many requests imagor serves at once. The default is 1, and the docs advise keeping it there unless your own benchmarks say otherwise. Turning it up to "go faster" usually just multiplies memory use.
Troubleshooting
Every request returns 403 after you set a secret. Your signer and your server disagree. Check IMAGOR_SIGNER_TYPE matches the algorithm in your signing code, and that you are signing the path without the leading slash and without unsafe.
Images load locally and 404 in production. The HTTP loader is probably blocked by HTTP_LOADER_ALLOWED_SOURCES, or the source host is not in the allowlist.
Transforms are correct but slow under load. Result storage is not configured, so every request is reprocessing the original.
Memory climbs until the container is killed. Check VIPS_CONCURRENCY. Large source images plus raised concurrency is the usual combination.
The source URL has query parameters and breaks the path. URL-encode it, or use the b64: prefix.
Running It
Imagor on Elestio starts at $11/month fully managed, with SSL, automated backups, monitoring and updates handled, across Hetzner, DigitalOcean, Vultr, Linode, Scaleway, Netcup, AWS or your own VM.
Start it unsafe on your laptop and feel how fast the URL loop is. Then set a secret, set an allowlist and point result storage somewhere before it ever answers a request from the public internet. Those three settings are the difference between a useful service and someone else's free image CDN.
Thanks for reading ❤️ See you in the next one 👋