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

2026-03-01 23:35 (7 months ago)
Header Heist
Play a song themed on this article

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.png only)
  • 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') === undefined prevents 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 writeHead is called, so it reliably applies to responses returned by sirv too
  • With the .mjs extension, it runs as ESM without depending on the "type" field in package.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.

Categories

Archive