Back to writing

2026-07-24

Why Next.js ISR does not fit this read-only Docker runtime

A static portfolio moved its GitHub-backed home route to dynamic rendering with a six-hour process-local stale-good cache.

  • nextjs
  • docker
  • caching
  • platform-engineering

The portfolio homepage reads public GitHub data on the server. It originally looked like a good candidate for Incremental Static Regeneration, but the production container has a read-only root filesystem and no writable mount. That is an intentional deployment constraint, so the homepage now renders dynamically and keeps a small, process-local cache instead.

This is about the route in this repository, not a general claim that ISR is unsafe. The Next.js caching documentation explains the available cache layers and revalidation behavior. The Docker run documentation documents --read-only, which makes the container root filesystem read-only.

The constraint in this repository

The home route exports dynamic = "force-dynamic". The production Docker image runs the standalone server as a non-root user, and the deployment is tested with --read-only without a writable application mount. That means application code cannot depend on writing a durable cache below the image filesystem.

The prior ISR cache-write failure message is not present in the reachable repository history, so there is no log line to quote. The reproducible evidence is the current route mode, the Dockerfile's non-root standalone runtime, and the cache behavior tested in src/lib/github/orchestrator.test.ts. A read-only container run should be performed by the deployment environment using Docker's documented option.

The replacement: dynamic rendering plus stale-good data

The GitHub fetch is server-only. createGithubContentCache receives both a loader and a clock, which keeps its behavior testable. It has a six-hour TTL, shares an in-flight request, merges partial fresh data into the last good result, and uses a deterministic fallback when a refresh fails before any good value exists.

const getGithubContent = createGithubContentCache(loader, now, GITHUB_CACHE_TTL_MS)
 
const content = await getGithubContent()

The important detail is that a later failed refresh does not erase a useful result:

content = { ...(content ?? staticFallback), ...update }
refreshedAt = now()

This is a process-local cache. A restart begins again with the deterministic fallback and a new refresh. It is not shared across instances, and it does not promise cross-instance freshness. That trade-off is appropriate here because the homepage can remain useful without GitHub and the deployment must stay read-only.

What to reproduce

Check Expected result
GITHUB_FETCH_DISABLED=1 pnpm build The home route remains dynamic while this article, the blog index, feed, and sitemap are generated from tracked Markdown.
pnpm test --run src/lib/github/orchestrator.test.ts The six-hour TTL, request deduplication, fallback, and stale-good behavior pass with an injected clock and loader.
Read-only container request The standalone server serves prebuilt /blog, article, feed, and sitemap routes, while the force-dynamic homepage reads published article metadata from the read-only content/blog directory without a writable runtime or cache directory.

The blog index, article routes, feed, and sitemap are prebuilt from Markdown at build time. The homepage is different: its force-dynamic request reads published article metadata from the immutable content/blog files so Latest writing advances automatically. The runtime image intentionally copies that directory, reads it only, and never writes article data or cache files.

Reproducibility notes

Use Node 22 and pnpm 10, then run the commands above. The Dockerfile is the source of truth for the production image. For the runtime constraint, use Docker's documented --read-only option and do not add a writable application mount. If a future deployment adds a durable shared cache, re-evaluate the route strategy against the current Next.js ISR documentation.