---
slug: "sveltekit-adapter-node-static-cache-control-header"
title: "Adding Cache-Control headers to static files when running SvelteKit with adapter-node in Docker"
description: "SvelteKit's adapter-node doesn't set Cache-Control headers for files in the static/ directory. This post explains how to use Node.js --import hooks to patch http.ServerResponse.prototype.writeHead and add cache headers to specific paths."
url: "https://www.ytyng.com/en/blog/sveltekit-adapter-node-static-cache-control-header"
publish_date: "2026-03-01T23:35:10Z"
created: "2026-03-01T23:35:10.974Z"
updated: "2026-03-02T00:49:19.373Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260301/028e821db5a4408390ec8db6b6e8c9fb.png.webp?width=768"
has_video: true
has_music: true
video_urls: ["https://media.ytyng.net/ytyng-blog/337/featured-video-1.mp4", "https://media.ytyng.net/ytyng-blog/337/featured-video-2.mp4", "https://media.ytyng.net/ytyng-blog/337/featured-video-3.mp4"]
music_urls: ["https://media.ytyng.net/ytyng-blog/337/featured-music-337-3.mp3", "https://media.ytyng.net/ytyng-blog/337/featured-music-337-4.mp3"]
lang: "en"
---

# 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](https://github.com/lukeed/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:

```js
// 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.)

```js
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

```dockerfile
# 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.
