Why I wrote a 90-line image CDN instead of paying for a service
Between paying for infrastructure sized for massive user-generated content and writing something tailored to my case, I chose the second option. The result fits in a single AdonisJS controller.
What the controller actually does
The route intercepts a fixed list of asset folders (items, spells, monsters, stats, etc.) plus a virtual /images/ prefix, with everything else falling back to Adonis's regular static middleware. For a matching request, the logic is linear:
- 1
Resolve
Resolve the source file path. - 2
Read
Read the request parameters. - 3
Look up
Look up the disk cache. - 4
Transform
Otherwise, transform with sharp and write the result to cache.
const width = request.input("w") ? parseInt(request.input("w"), 10) : undefined;
const height = request.input("h") ? parseInt(request.input("h"), 10) : undefined;
const quality = request.input("q") ? parseInt(request.input("q"), 10) : 80;
const format = request.input("fm") || "webp";
const cacheKey = createHash("md5")
.update(`${filePath}-${width}-${height}-${quality}-${format}`)
.digest("hex");
const cachePath = join(cacheDir, `${cacheKey}.${format}`);
if (existsSync(cachePath)) {
const image = readFileSync(cachePath);
response.header("Content-Type", `image/${format}`);
response.header("Cache-Control", "public, max-age=31536000");
return response.send(image);
}The cache key is simply a hash of the path and parameters. If the file already exists, it's served directly from disk, without going through sharp again.
- w / h
- Target width / height, in pixels. Optional: sharp only resizes if one of the two is provided.
- q
- Encoding quality, default
80. - fm
- Output format, default
webp(jpegorpngoptional).
Otherwise, a single block applies a resize if width or height are provided, then encodes into the requested format at the desired quality. The resulting buffer is written to disk before being returned, so that the next request with the same parameters hits the fast path.
The Cache-Control: public, max-age=31536000 does the rest of the work: once a browser or an upstream CDN has fetched a variant, it won't ask for it again for a year. For an asset catalog that changes per deployment rather than continuously, that's exactly the right granularity.
Why this is enough for this load profile
A managed service like Cloudinary or imgix sells robustness for scenarios I don't have:
- Real-time purging (content changes under users' feet)
- Multi-region distribution (guaranteed global latency)
- Advanced transformations (smart cropping, watermarking, face detection)
- Fixed image corpus, known in advance
- Bounded variant space (a handful of widths, two or three formats)
- Sharp transforms in a few milliseconds, the disk cache absorbs repetition
No need to pay for capabilities I never use.
The limitations, without hiding them
This choice has a cost that I accept up front, rather than discovering it in production.
A cost, knowingly accepted
- No distributed purging: if I change a source image, I have to invalidate or clear the cache myself, nothing is automatic.
- No multi-region: everything comes from a single server, so latency depends on where the players are geographically.
- The cache is local to the server's disk: it disappears on every redeploy, with a burst of cache misses right after.
- Path safety relies entirely on the hardcoded list of allowed folders in the routes: simple, but it's on me to maintain it correctly if I add asset folders.
The threshold for switching
If Wakfuli grows to the point of having players spread across multiple continents with tight latency requirements, or if content starts changing often enough that the lack of distributed purging becomes a real operational problem, or if I need to add a second instance and therefore manage a cache shared across multiple servers, that will be the signal to switch back to a managed service.
Note
It's not a matter of principle, it's a matter of curve: the day maintaining these 90 lines costs more in time than a subscription, the subscription wins. For now, knowing my load profile precisely has kept me from paying for a problem I don't have.