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

2026-03-01 23:35 (107 minutes ago)
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') === undefined guard prevents overwriting existing headers. Safe even if adapter-node or sirv adds Cache-Control in future updates
  • Path checking at writeHead call time ensures it applies to sirv responses
  • .mjs extension ensures ESM interpretation regardless of package.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.

Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive