Deep Reader is meant to sit behind a reverse proxy that terminates TLS (HTTPS is
required for Service Workers and PWA installation). The Go binary binds only to
127.0.0.1:8080 and serves both the API and the embedded SvelteKit build from a
single origin.
This document explains how to configure HTTP caching correctly. Getting it wrong does not just waste bandwidth — it breaks PWA updates.
The embedded static server (Fiber's static middleware in
internal/api/static.go) stamps every static response with:
Cache-Control: public, max-age=31536000
A one-year cache is correct for content-hashed build assets, but it is wrong for the two files that drive updates:
index.html— the SPA entry point. It is not fingerprinted and it references the hashed/_app/immutable/*bundles of the current build.service-worker.js— its body embeds the build version (shell-<version>) and the precache manifest.
When index.html is pinned in the browser's HTTP cache for a year, deploying a
new build does not help: the browser keeps serving the old HTML, which
points at the old asset hashes, so the app never actually advances. The new
service worker installs, the banner appears, the user taps Обновить, the
page reloads — and the proxy hands back the stale cached index.html again. The
banner reappears on the next check. Clearing the Cache Storage or unregistering
the worker does not fix it, because the stale copy lives in the HTTP cache.
The fix is to revalidate the HTML shell and the service worker on every request, while keeping the long cache only for fingerprinted assets.
no-cache only helps if revalidation can actually return fresh bytes. The
frontend is embedded via go:embed, which reports a zero modtime
(year 0001) for every file. Fiber's static middleware turns that into a
Last-Modified: Mon, 01 Jan 0001 00:00:00 GMT and then answers any
If-Modified-Since with 304 Not Modified — because every real date is newer
than year 0001. So even with no-cache and a correctly-configured proxy, the
browser revalidates, gets a 304, and keeps the stale index.html /
service-worker.js across deploys. Same visible symptom: the update banner
reappears after every reload and never sticks.
internal/api/static.go fixes this at the origin by stripping
If-Modified-Since / If-None-Match from requests for the no-cache shell, so
those paths always return a full 200. Content-hashed /_app/immutable/*
assets are untouched and still revalidate to 304 (correct — their URL changes
when their content does). Guarded by TestStaticRevalidationServesFresh.
| Path | Cache-Control |
Why |
|---|---|---|
/_app/immutable/* |
public, max-age=31536000, immutable |
Content-hashed by SvelteKit; the URL changes when the content changes, so it is safe to cache forever. |
/service-worker.js |
no-cache |
Must be revalidated so a new version is detected promptly. |
/manifest.webmanifest |
no-cache |
Small, infrequently changed, must not go stale. |
/icons/*, /robots.txt |
public, max-age=3600 |
Not fingerprinted; a short cache is fine. |
/api/* |
(pass upstream through) | The backend sets its own headers — /api/articles/:id is immutable, /api/config is uncached. Do not override. |
| everything else (the HTML shell / SPA routes) | no-cache |
index.html must always reflect the latest build's asset hashes. |
no-cache means "store it, but revalidate with the server before reuse" — not
"do not store". The HTML and worker are tiny, so the revalidation cost is
negligible.
The Go app already emits Cache-Control, so the proxy must replace that
header per location with proxy_hide_header + add_header. (add_header alone
would append a second, conflicting value.)
upstream deep_reader {
server 127.0.0.1:8080;
}
# Shared proxy settings — keep these consistent across every location that
# proxies to the app so TRUST_PROXY / client-IP logging work correctly.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
server {
listen 443 ssl;
http2 on;
server_name dr.tinyops.ru;
# --- TLS ---------------------------------------------------------------
ssl_certificate /etc/letsencrypt/live/dr.tinyops.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dr.tinyops.ru/privkey.pem;
# Service Workers need a generous body size for article payloads.
client_max_body_size 10m;
# --- Content-hashed build assets: cache forever ------------------------
location ^~ /_app/immutable/ {
proxy_pass http://deep_reader;
proxy_hide_header Cache-Control;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# --- Service worker: always revalidate ---------------------------------
location = /service-worker.js {
proxy_pass http://deep_reader;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-cache" always;
}
# --- PWA manifest: always revalidate -----------------------------------
location = /manifest.webmanifest {
proxy_pass http://deep_reader;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-cache" always;
}
# --- Non-fingerprinted static assets: short cache ----------------------
location ~* ^/(icons/|robots\.txt$|favicon) {
proxy_pass http://deep_reader;
proxy_hide_header Cache-Control;
add_header Cache-Control "public, max-age=3600" always;
}
# --- API: preserve the backend's own caching headers -------------------
location /api/ {
proxy_pass http://deep_reader;
# No override: /api/articles/:id is immutable, /api/config is uncached.
}
# --- HTML shell and all SPA routes: always revalidate ------------------
location / {
proxy_pass http://deep_reader;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-cache" always;
}
}
# Redirect plain HTTP to HTTPS.
server {
listen 80;
server_name dr.tinyops.ru;
return 301 https://$host$request_uri;
}Notes:
- The
alwaysflag onadd_headerensures the header is also set on non-2xx responses (e.g. the304 Not Modifiedthat revalidation produces). - Location matching priority handles the overlap automatically: exact (
=) and prefix (^~) matches win over the regex block, which wins over the catch-alllocation /. So/service-worker.jsand/_app/immutable/*are never caught by theno-cachefallback. - If you front nginx with a CDN, apply the same matrix at the CDN tier and make
sure it forwards (does not collapse) the
Cache-Controlyou set here.
After reloading nginx (nginx -t && systemctl reload nginx), confirm each class
of file carries the right header:
# HTML shell — must revalidate
curl -sI https://dr.tinyops.ru/ | grep -i cache-control
# -> cache-control: no-cache
# Service worker — must revalidate
curl -sI https://dr.tinyops.ru/service-worker.js | grep -i cache-control
# -> cache-control: no-cache
# Hashed asset — cached for a year
curl -sI https://dr.tinyops.ru/_app/immutable/entry/start.*.js | grep -i cache-control
# -> cache-control: public, max-age=31536000, immutableThen, in the browser: open DevTools → Application → Service Workers, tick
Update on reload, hard-reload once to flush the stale index.html, and the
update banner should clear and stay cleared across subsequent deploys.
As of the per-path fix in internal/api/static.go (cacheControlFor), the Go
server emits exactly the matrix above on its own: /_app/immutable/* is
immutable, while index.html and service-worker.js are no-cache. So a
plain reverse proxy that forwards upstream headers untouched is already correct —
the proxy_hide_header / add_header overrides in the config above are
optional.
Keep the overrides only if you want the edge to be authoritative regardless of
the backend (e.g. a shared nginx template, or a CDN tier that must not inherit a
surprising Cache-Control). If you drop them, make sure nginx does not add
its own expires / Cache-Control for these locations, and that any CDN
forwards the backend's Cache-Control rather than overriding it.