Adding Cache-Control headers to static files when running SvelteKit with adapter-node in Docker
Background
I run SvelteKit built with @sveltejs/adapter-node as a Docker container in production, and I want browser caching to work for image files placed in the static/ directory (such as /images/hero.jpg) and for favicon.png.
The Problem
adapter-node uses sirv internally to serve static files. The hashed assets (JS/CSS) under _app/immutable/, which are build output, automatically get Cache-Control: public, max-age=31536000, immutable, but files in the static/ directory get no Cache-Control header.
Specifically, when handler.js in adapter-node calls sirv, no maxAge option is passed, and the setHeaders callback reacts only to _app/immutable/ paths.
// Inside adapter-node's handler.js (simplified)
function serve(path, client = false) {
return sirv(path, {
etag: true,
setHeaders: client
? (res, pathname) => {
if (pathname.startsWith(`/_app/immutable/`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
}
: undefined
});
}
So when static/images/hero.jpg is requested, conditional requests by ETag (304) work, but because there is no Cache-Control header, the browser asks the server every time.
Approaches Considered
Setting in hooks.server.ts → Not possible
The handle function in SvelteKit's hooks.server.ts is not called when files in static/ are served, because the sirv middleware in adapter-node returns the response first.
nginx ingress server-snippet → Not possible
In a Kubernetes + nginx ingress controller environment, I also considered adding a location block with the server-snippet annotation, but in nginx ingress controller v1.9.0 and later allow-snippet-annotations is disabled by default, so the admission webhook rejects it.
Custom server entry point → Risk of feature loss
This approach builds a custom server that replaces the build/index.js generated by adapter-node. But the original index.js includes many features such as graceful shutdown, support for the KEEP_ALIVE_TIMEOUT / HEADERS_TIMEOUT environment variables, and socket activation, and reimplementing and maintaining them myself has a high maintenance cost.
Node.js --import hook to patch writeHead → Adopted
This approach does not change build/index.js at all. It uses the Node.js --import option to patch the global http.ServerResponse.prototype.writeHead before startup. It keeps all the original features and adds the cache header only to specific paths. A prototype patch looks tricky at first, but it does not depend on adapter-node's internal implementation and holds up against updates.
Implementation
cache-headers.mjs
With the .mjs extension, the file is reliably interpreted as ESM even if package.json is not copied into the Docker production stage. (build/index.js is also ESM, but being explicit is safer than depending on Node.js 22's auto-detect.)
import http from 'node:http';
/** 7 days in seconds */
const STATIC_MAX_AGE = 604800;
const CACHE_PREFIXES = ['/images/'];
const CACHE_EXACT = ['/favicon.png'];
const originalWriteHead = http.ServerResponse.prototype.writeHead;
http.ServerResponse.prototype.writeHead = function (statusCode, ...args) {
if (statusCode >= 200 && statusCode < 400) {
const req = this.req;
if (req) {
const pathname = (req.url || '').split('?')[0];
if (
CACHE_PREFIXES.some((p) => pathname.startsWith(p)) ||
CACHE_EXACT.includes(pathname)
) {
if (this.getHeader('Cache-Control') === undefined) {
this.setHeader('Cache-Control', `public, max-age=${STATIC_MAX_AGE}`);
}
}
}
}
return originalWriteHead.call(this, statusCode, ...args);
};
The points are as follows.
CACHE_PREFIXES: patterns ending in/are prefix matches (everything under/images/)CACHE_EXACT: exact match (/favicon.pngonly)- To add a path to cache, you only need to add an element to the array
- The cache header is added only for status codes 200 to 399 (no caching for 404 and the like)
getHeader('Cache-Control') === undefinedprevents overwriting an existing header. It stays safe even if adapter-node or sirv starts setting Cache-Control in the future- The path is checked when
writeHeadis called, so it reliably applies to responses returned by sirv too - With the
.mjsextension, it runs as ESM without depending on the"type"field inpackage.json
Dockerfile Changes
# Hook that adds cache headers to static assets
COPY docker/cache-headers.mjs ./cache-headers.mjs
# Add cache headers to static assets with the --import hook and start the SSR server
CMD ["node", "--import", "./cache-headers.mjs", "build/index.js"]
The change is only 2 lines. build/index.js is used as is, so all adapter-node features such as graceful shutdown and the timeout settings are kept.
Verification
After deploying, check the response headers of /images/ and /favicon.png in the browser's developer tools, and you can see that Cache-Control: public, max-age=604800 is added.
Related posts
We look forward to discussing your development needs.