Adding Cache-Control headers to static files when running SvelteKit with adapter-node in Docker

Background
When running SvelteKit with @sveltejs/adapter-node as a Docker container in production, you may want to enable browser caching for image files placed in the static/ directory (e.g., /images/hero.jpg) and favicon.png.
The Problem
adapter-node internally uses sirv to serve static files. Build-generated assets under _app/immutable/ (hashed JS/CSS) automatically get Cache-Control: public, max-age=31536000, immutable, but files from the static/ directory don't get any Cache-Control header.
In adapter-node's handler.js, sirv is called without a maxAge option, and the setHeaders callback only responds 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
});
}
This means accessing static/images/hero.jpg supports conditional requests via ETag (304), but without a Cache-Control header, the browser makes a request to the server every time.
Approaches Considered
Setting in hooks.server.ts → Not possible
SvelteKit's handle function in hooks.server.ts is not called for static file serving. The sirv middleware in adapter-node returns the response first.
nginx ingress server-snippet → Not possible
Adding location blocks via server-snippet annotation in a Kubernetes + nginx ingress controller environment was considered, but since nginx ingress controller v1.9.0+, allow-snippet-annotations is disabled by default, causing the admission webhook to reject it.
Custom server entry point → Risk of feature loss
Creating a custom server to replace build/index.js generated by adapter-node. However, the original index.js contains many features including graceful shutdown, KEEP_ALIVE_TIMEOUT / HEADERS_TIMEOUT environment variable support, and socket activation. Re-implementing and maintaining these carries high maintenance costs.
Node.js --import hook to patch writeHead → Adopted
Without modifying build/index.js at all, this approach patches the global http.ServerResponse.prototype.writeHead using Node.js's --import option before startup. It maintains all original features while adding cache headers only to specific paths.
Implementation
cache-headers.mjs
The .mjs extension ensures the file is always interpreted as ESM, even when package.json is not copied to the Docker production stage. (While build/index.js is also ESM, being explicit is safer than relying 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);
};
Key points:
CACHE_PREFIXES: Patterns ending with/use prefix matching (everything under/images/)CACHE_EXACT: Exact match only (/favicon.png)- Cache headers are only added for status codes 200-399 (no caching on 404, etc.)
getHeader('Cache-Control') === undefinedguard prevents overwriting existing headers. Safe even if adapter-node or sirv adds Cache-Control in future updates- Path checking at
writeHeadcall time ensures it applies to sirv responses .mjsextension ensures ESM interpretation regardless ofpackage.json"type"field
Dockerfile Changes
# Static asset cache header hook
COPY docker/cache-headers.mjs ./cache-headers.mjs
# Start SSR server with --import hook for static asset cache headers
CMD ["node", "--import", "./cache-headers.mjs", "build/index.js"]
Only 2 lines changed. Since build/index.js is used as-is, all adapter-node features including graceful shutdown and timeout settings are preserved.
Summary
| Approach | Verdict |
|---|---|
| hooks.server.ts | Not possible — static/ requests don't pass through |
| nginx ingress server-snippet | Disabled by default in v1.9.0+, rejected by admission webhook |
| Custom server (replace index.js) | Difficult to maintain feature parity |
| --import hook with writeHead patch | Adopted. Minimal changes while preserving all features |
The prototype patch via --import hook may seem tricky, but it doesn't depend on adapter-node internals and is resilient to updates. Adding new cache target paths is as simple as adding elements to the arrays.
We look forward to discussing your development needs.