When a storage abstraction returns URLs that the app’s static-file handler is supposed to serve, the two must agree on the path prefix. They drift the moment one is changed without the other, and the data they wrote to your DB outlives the fix.
The trap
// Storage factory writes "/uploads/images/foo.png" into every URL it returns.
createLocalImageStorage({
baseDir: "storage/images",
publicPath: "/uploads/images",
});
// Static handler serves "/images/*" — a different prefix.
fastify.get("/images/*", serveLocalImage);
Every URL stored in the DB is a 404. Worse, fixing the handler tomorrow doesn’t fix the rows yesterday: any Book.coverImageUrl or markdown  already persisted carries the broken prefix.
The fix: one constant, two consumers
const IMAGES_PUBLIC_PATH = "/images";
const imageStorage = createLocalImageStorage({
baseDir: env.IMAGE_DIR,
publicPath: IMAGES_PUBLIC_PATH,
publicOrigin: env.IMAGES_PUBLIC_BASE_URL ?? "",
});
fastify.get(`${IMAGES_PUBLIC_PATH}/*`, serveLocalImage);
If you do nothing else, do this. One source for the prefix, both callers import it.
Where audits miss the alignment
A common code audit looks for “the static handler” by grepping route files for the URL prefix the storage layer returns. If your API is mounted under /api/v1 and the static handler is mounted at root (so it serves /images/* directly, not /api/v1/images/*), the grep for "/images" will hit API endpoints first and conclude there is no static handler.
Confirm the handler exists with one of:
# Hit the URL the storage layer claims to produce.
curl -I https://${HOST}/images/some/key.png
// Or dump the Fastify route table at boot.
console.log(fastify.printRoutes());
Why this matters beyond the immediate 404
- Stored URLs are forever.
Book.coverImageUrl, embedded markdown image refs, exported docs all freeze whatevergetUrl()returned at write time. Changing the prefix on the storage layer rescues new uploads but never the existing rows. - CDN migration is the same alignment problem at a different layer. Pointing
publicOriginat a CDN means the CDN bucket layout must match the path suffix the storage layer writes. Misalignment surfaces as broken images only in production, only for users who happen to look at old content. - Same-origin assumptions break. Mixed environments (dev with relative URLs, prod with an absolute base) need the path suffix to match against both. Set
publicOriginfrom an env (IMAGES_PUBLIC_BASE_URL); leave the path constant in code.
Gotchas
- Don’t put the static handler inside the API prefix unless you mean to. A handler at
/api/v1/images/*lives alongside the API namespace, gets the API’s auth middleware by default, and collides if the API also exposes a/api/v1/imagesresource. Root-mounted (/images/*) is the safer default for public assets. - SVG served from the same path is XSS-able. Inline SVGs can execute scripts in the serving origin. Set
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandboxon SVG responses andX-Content-Type-Options: nosniffon all responses. - A single integration test pays for itself forever. Call
imageStorage.store(key, buf), then HTTP-fetchimageStorage.getUrl(key)and assert 200. If that test passes, alignment holds. If you ever change either side, the test fails before the data does.