imgsrv: Serving images the lazy way
imgsrv is a small open source Go service that resizes and transcodes images on demand, sitting behind nginx. There’s nothing revolutionary about the idea: nginx serves everything it can straight from disk, and only a cache miss ever reaches the Go service. When that happens it generates the requested version of the image, writes it to disk, and returns it. Every request after that is a plain static file serve that never touches the Go code at all.
If you’ve read this blog recently you’ve already used it. The screenshots in the Wakuwi post are served from images.stut.dev, which is an imgsrv instance.
The problem
The original itch was a photography portfolio. Camera originals can be tens of megabytes each, even as JPGs, and I never want to serve one of those to a browser. They’re also full of metadata (camera settings, serial numbers, sometimes GPS coordinates) that nobody browsing a portfolio needs to see. Everything needs to come down to a sensible size, format, and quality, with all of that stripped out, before it goes anywhere near the public internet. Ideally I’d also like to decide the size at the point of use, in the URL, rather than pre-generating a fixed set of thumbnails and hoping I guessed right.
There are plenty of hosted services that will do this for you, and some fairly heavyweight self-hosted options too. What I wanted was a single container running on my own infrastructure with no database, no Redis, no object storage, and nothing to look after beyond two directories on disk. I couldn’t find it, so I built it.
How it works
The URL is the cache key. A request looks like this:
GET https://images.stut.dev/holiday/photo-800x800.webp
Everything from the last dash in the filename to the extension is a size token, and the extension selects the output format (webp, jpeg, or avif). nginx tries the exact path under the cache directory first, and if the file exists that’s the whole request. If it doesn’t, the request gets proxied to the Go service, which finds the original (holiday/photo.* in a separate, read-only originals directory), runs it through libvips, writes the result to a temporary file, renames it into place, and serves it. From then on nginx never asks again.
Rather than named presets, the size tokens are a small grammar, and it’s easier to show than to describe. All of the images below are the same original, imgsrv-example.jpg, served live from this blog’s imgsrv instance with nothing changing but the suffix. Here it is via the original token, which serves the image at its native dimensions but still re-encoded through the full pipeline; the raw file is never served:

-original: native dimensions, full re-encode (shown here as -1600w, because inlining a 4875×3250 image in a post about not serving oversized images would rather miss the point — click through for the real thing)And here it is resized with various options:

-600: fits inside 600×600 (3:2 original, so 600×400)
-600x600g: fitted and centred, 600×600 grey canvas
-1600x800z: exactly 1600×800, cover and centre-crop
-1600x800s: same box, cropped around the focal pointThere are more variants of both kinds: -600h and -800w constrain a single edge, -WxH fits an arbitrary box (they all produce the same uncropped image as -600, just at different resolutions, so there’s no point showing them here), and the pad mode comes in transparent, black, and white as well as grey. See the README.md file for full details.
The s mode you can see above uses libvips’s attention-based smart crop, which looks for edges, saturation, and skin tones to work out which part of the picture matters. Compare it with the centre-crop version to see it earning its keep. That went into version 1 because libvips reduces it to a single parameter, so it would have been silly not to.
Keeping the scanners out
An on-demand resizer with unbounded parameters is an invitation to fill your disk. Left unchecked, a scanner requesting photo-1.webp, photo-2.webp, photo-3.webp and so on would get a freshly generated file for every request. I didn’t want to rely on rate limiting for this, so imgsrv makes it impossible instead:
- A dimensions allowlist. Every number in a size token must appear in the config’s
dimensionslist, or the request gets a400. That puts a hard bound on the cache: dimensions² × mods × formats × originals, and no more. - nginx checks URL shape. A regex
locationonly admits URLs that look like valid derivative requests. Scanner noise,.phpprobes, dotfiles, and general junk get an immediate404from nginx without any backend work. nginx checks the shape, Go enforces the allowlist and path containment, so there are two layers to get through. - Singleflight and a concurrency cap. Concurrent requests for the same derivative trigger exactly one generation, and the total number of libvips jobs is capped at roughly the CPU count. A burst of cache misses queues up rather than eating all the memory.
- Atomic writes. Derivatives are written to a randomly-named temp file and renamed into place, so a partially-written image can never be served. This holds even when two instances share the cache mount during a rolling update.
There’s deliberately no cache eviction. Originals are immutable (replacing one means giving it a new filename), which means derivatives are immutable too. They’re served with Cache-Control: public, max-age=31536000, immutable, and the allowlist means the cache can only ever get so big.
The processing pipeline
Every generated image goes through the same fixed pipeline: orient, resize, convert to sRGB, strip all metadata, encode. None of it is configurable, and the order matters more than you might think.
EXIF auto-rotation has to happen before the orientation tag is stripped, otherwise portrait shots get served sideways. The sRGB conversion has to happen before the ICC profile is stripped, otherwise Adobe RGB and ProPhoto originals (i.e. most photography originals) come out looking washed out. And because metadata is always stripped, a generated file can never leak GPS coordinates, camera serial numbers, or anything else the original was carrying around.
Multiple domains
One deployment serves any number of hostnames. The request’s hostname is simply the top-level directory under both roots:
/originals
images.stut.dev/wakuwi/1.png
images.example.com/portfolio/photo.jpg
/cache
images.stut.dev/wakuwi/1-800w.webp
images.example.com/portfolio/photo-800.webp
Originals can be JPEG, PNG, or TIFF; the configured input extensions are tried in order when resolving one.
The mapping lives entirely in nginx, which validates the hostname’s shape and prefixes it onto the path, so the Go service doesn’t know or care which domain it’s serving. Adding a domain means creating a DNS entry and a directory of originals. Nothing gets redeployed and nothing gets reconfigured.
The one URL that isn’t an image gets similar treatment: the root. There’s nothing sensible to serve at / on an image host, so it returns a 302, and where it goes is decided per domain by a .root-redirect file in that host’s originals directory containing nothing but a URL. The file is read on every request, so adding or changing a redirect is just a file edit with no restart required, which fits nicely with a domain being nothing more than a directory. Hosts without a file fall back to a ROOT_REDIRECT environment variable, and if neither is set then / gets a 404 like everything else that isn’t a well-formed image URL.
Running it
The published image bundles nginx and the Go binary as a single deployment unit:
docker run \
-v /srv/images/originals:/originals:ro \
-v /srv/images/cache:/cache \
-v /srv/images/config.yaml:/etc/imgsrv/config.yaml:ro \
-p 80:80 \
ghcr.io/stut/imgsrv:latest
The two mounted directories are the only state. Config is a short YAML file containing the dimensions allowlist and quality settings, and not much else.
One note on trust: HTTP clients can only ever select from files that already exist in the originals directory, and they never supply image bytes. However, those originals do get decoded by libvips and its codec stack, which is a lot of native code, so don’t point the originals mount at a directory that untrusted parties can write to. SECURITY.md covers the full trust model.
Boring, in the best way
The best thing I can say about imgsrv is that I haven’t had a reason to think about it since it went live. It serves this blog’s images, it’ll serve the portfolio it was built for, and the most interesting thing in its logs is the occasional scanner bouncing off nginx’s 404. Which is exactly what I want from this sort of thing.
As with Wakuwi, Claude did the typing while I did the product managing and code reviewing. The difference this time is that most of the effort went into arguing about the design before any code existed: the URL grammar, the allowlist, the order of the pipeline. By the time we started writing code there wasn’t much left to decide.
If imgsrv is useful to you, issues and pull requests are welcome. And if not, well, thanks for reading this far anyway.
stut.dev