Deployment Guide

A Swift Web applier (Nuxt 4 SSR) has two deployment scenarios with the same process model and different edge layers:

AspectTestProduction
ServerOne VPS hosting multiple test sitesOne VPS may host several sites
Process modelSingle process fork / instances: 1Single process fork / instances: 1 (same)
Edge layerNo CDN, direct nginx; HTML cache optionalCloudflare as pure CDN / reverse proxy + nginx HTML cache (standard)
Deploy methodManual by a maintainer/operatorManual pnpm deploy:prod by a maintainer/operator
Process configecosystem.dev.config.cjs (repo root)ecosystem.config.cjs (repo root)

Both environments run a single process (standards#86). Production does not use pm2 cluster: nitro's ISR cache lives in process memory, every instance holds its own snapshot, and the same URL flips between old and new versions within a TTL (the residual root cause of cinebay#59); throughput comes from the nginx HTML cache anyway (hits never reach Node), so extra instances buy no coverage. With a single process there is exactly one ISR cache per machine, so the framework raised its cap from 50 MB to 256 MB (1000 entries / 2 MB per entry unchanged). If a site truly needs multiple cores, switch to a shared cache driver first (Redis etc., P0 approval) — never run cluster on the in-memory cache.

The body of this page is organized for the operator actually running deployments: only follow-the-steps instructions, reference configs, acceptance checks and troubleshooting. The reasoning behind each step lives in the "Appendix: rationale & design decisions" at the end — operators can skip it; maintainers (or AI) must read it before changing the deployment machinery.

Test deployment (manual · single process · no CDN)

The starter CI runs type, lint, unit/coverage and build checks only. Run full browser E2E locally and record evidence for the commit. CI must not deploy, manage service processes/caches or publish npm packages; merging is not deployment. For test deployment, a maintainer/operator checks out the verified commit outside CI, confirms the environment configuration, then performs these steps manually:

  1. pnpm install + pnpm build in the separate build directory (for example /srv/build/<project>), not in the serve dir;
  2. rsync only .output to /var/www/html/<project>, without --delete;
  3. apply the process shape via deploy/pm2-apply.sh ecosystem.dev.config.cjs (startOrReload only when the shape matches what is running, otherwise delete + start; pm2 save at the end);
  4. a health check against the local Nitro server on the port from the ecosystem file: a curl loop on 127.0.0.1:<port> plus verifying the responder is actually us (this app is online in pm2 with a stable pid) — startOrReload returns 0 even when the app can't start, and when a neighbour app squats the port our app dies on EADDRINUSE while curl happily gets the neighbour's 200, so curl alone is a false green; only the combination turns port collisions / crash-on-boot red on the spot (on failure it prints the squatter via ss -tlnp plus the pm2 logs).

🔴 Every applier sharing the test box must get a unique PORT: set it in your own repo's ecosystem.dev.config.cjs (repo root) so it ships with the code, and keep it in sync with that site's nginx proxy_pass port on the test machine. The template default 3000 is only a placeholder — change it to the assigned value in the very first commits after scaffolding.

ecosystem.dev.config.cjs has the same shape as production (single-process fork; memory parameters are explained in the production section):

module.exports = {
  apps: [
    {
      name: require('node:path').basename(process.cwd()), // name follows the serve dir (the service directory name); one machine hosts many projects — hardcoded generic names hijack each other (starter#56)
      script: '.output/server/index.mjs',
      interpreter: 'node',
      instances: 1,
      exec_mode: 'fork', // single process; changing these two is NOT applied by startOrReload — apply changes manually through deploy/pm2-apply.sh
      max_memory_restart: '1280M', // must exceed --max-old-space-size + native overhead; same tier as production — don't downgrade the test site to the small-site tier just because the box is shared
      watch: false,
      env: { PORT: 3000, HOST: '0.0.0.0', NODE_OPTIONS: '--expose-gc --max-old-space-size=1024' },
    },
  ],
}

Manual deployment does not run a bare startOrReload; it runs bash deploy/pm2-apply.sh ecosystem.dev.config.cjs. The script first compares the running process's exec_mode / count (via pm2 jlist) with what the ecosystem declares; on a mismatch (e.g. an old site still on cluster) it does pm2 delete + start — startOrReload never applies topology changes, it just hot-reloads the old shape — and only reloads when they match. It ends with pm2 save, so pm2 resurrect after a reboot brings up the current shape.

The Nuxt .output is self-contained — the serve dir needs no source / node_modules. Moving just .output + ecosystem is faster and cleaner than "sync the whole source then install/build in place".

Production deployment (single process · nginx HTML cache · Cloudflare)

Production is deployed by running the template-provided pnpm deploy:prod (i.e. deploy/deploy-prod.sh) outside CI; never call this script from a pipeline; the process model is the same single process as test, and throughput comes from the nginx HTML cache (standard in production).

Two distinct scenarios: the first launch needs a one-time checklist in order; every deploy after that is a single command.

Nth deploy (routine release): one command

cd /srv/build/<project> && pnpm deploy:prod        # branch defaults to main; needs root / sudo

The script runs seven steps automatically (internals in the "Deploy script" section below): pull → build in a separate dir → rsync .output → pm2 restart (in fork mode reload is a restart, ~1–3 s switch window) → health check (curl + pm2 self-identity) → purge the nginx HTML cache + reload → print the acceptance commands. Then run the three printed curls from the go-live checklist. Root (or sudo) is required: the script's chown, cache purge under /var/cache/nginx and nginx reload all need it.

First-launch checklist (execute in order)

#StepWhy this order / notes
1Prerequisite self-check: node 20+/22+, pnpm 11+, pm2, nginx, git, rsync installed; the service run user exists (matching the script's RUN_USER, default www — useradd -r www or change the param if missing); repo credentials configured on the VPS (an SSH deploy key is recommended)An existing production VPS usually has all of this — just verify item by item; a missing RUN_USER makes the script abort at the chown step (hit in a real run); without credentials the clone — and every later fetch — fails
2git clone <repo> /srv/build/<project>Step 1 of the deploy script checks this dir is a git repo and aborts otherwise
3Verify the params at the top of deploy/deploy-prod.sh: PROJECT (= dir name under /var/www/html), RUN_USER; sites without the nginx HTML cache set NGINX_CACHE_DIR empty. Also verify PORT and max_memory_restart in ecosystem.config.cjs (repo root)The params ship with the code — usually pre-filled by the devs; ops just verifies. PORT must not clash with other services on the box and must match the nginx proxy_pass port (the most common shared-box collision); don't leave max_memory_restart at low values like 500M (see next section)
4cp .env.prod.example .env and verify every value is the real production config🔴 Must happen before the first script run. The values are pre-filled by the devs in .env.prod.example before merging to main; if you see placeholders (example.com etc.) stop and check with the devs — don't guess. A wrong NUXT_PUBLIC_SITE_URL raises no error — it silently bakes wrong canonical / og values. Multi-domain / ban-resistant sites leave it empty — but sites with prerender pages cannot (the build throws without it, and the starter template ships 8 prerendered landing pages); for multi-domain setups override per page via useSeo({ siteUrl }) on those pages instead. The script echoes the key values at runtime — glance at them
5Configure nginx: ⓪ point the domain's DNS at this VPS first (with Cloudflare, DNS-only / grey cloud at this stage — certbot's HTTP challenge needs a direct connection) ① cache zone (conf.d) ② the site server block (exclusion list + page-cache block + Host / X-Forwarded-* passthrough), copied from the full reference config below ③ for a new domain, start 80-only, run certbot, then add the full 443 config ④ nginx -t && nginx -s reload🔴 Must happen before the first script run: the cache-purge step fails fast on a missing cache dir (by design — prevents "thought it was purged but wasn't"). nginx being ready before pm2 starts just means a temporary 502, harmless
6Run pnpm deploy:prod (the first run is also the pm2 first start)—
7Run pm2 startup once, then execute the command it printspm2 save is already done by pm2-apply.sh at the end of every deploy, but the boot-time startup script must be registered once via pm2 startup, which the script does not do; skip it and the site won't auto-start after a VPS reboot
8Run the three curls of the go-live checklist"The site feels fast" is not evidence caching works
9Cloudflare: flip the DNS record from step 5 to proxied (orange cloud); confirm no Cache Everything-style rules and that _nuxt/* is cached normallyCache Everything would bake HTML into edge nodes worldwide (purging there is far harder than clearing local nginx)

Only two ordering dependencies matter (the 🔴 in steps 4 and 5) — getting those wrong does not fail loudly; every other ordering mistake is caught by the script itself.

Deploy script

The core is just the golden rules: build in a separate dir → rsync .output without delete → deploy/pm2-apply.sh applies the shape and reloads → health check → purge nginx cache → probe.

# 1. Pull + build in a separate build dir (never touch the serve dir; call pnpm via npx, no global bin writes)
cd /srv/build/<project> && git fetch origin main && git reset --hard origin/main
cp .env.prod.example .env    # the script keeps an existing .env instead of overwriting it
npx --yes pnpm@12.16.1 install --frozen-lockfile
npx --yes pnpm@12.16.1 build

# 2. Move only .output to the serve dir, no --delete (keep old-hash assets) + copy the prod pm2 config
rsync -a /srv/build/<project>/.output/ /var/www/html/<project>/.output/
cp /srv/build/<project>/ecosystem.config.cjs /var/www/html/<project>/   # ecosystem lives at the repo root; copy to serve dir root so the next step can use the bare name

# 3. Apply the pm2 shape and restart: unchanged shape = startOrReload (a restart in fork mode); changed (e.g. an old site still
#    on cluster) = delete + start. Both have a ~1–3 s switch window that nginx proxy_cache_use_stale covers for cacheable pages; ends with pm2 save
cd /var/www/html/<project> && bash /srv/build/<project>/deploy/pm2-apply.sh ecosystem.config.cjs

# 4. Health check: curl succeeds AND our own pm2 app is online with a stable pid (a neighbour
#    squatting the port would answer 200 on our behalf — curl alone is a false green);
#    on failure abort without purging the cache — the old cache keeps serving

# 5. 🔴 Clear the nginx HTML proxy cache (skip it and users stay on the old version for up to the outbound s-maxage cap, see below)
rm -rf /var/cache/nginx/nuxt-html/* && nginx -s reload

# 6. Probe: GET <site>/ and check for an x-cache-status header — missing means ⚠️ "nginx HTML cache not wired up"
#    (warn only, never blocks; HIT/MISS is not judged because the first hit through CF + nginx is indeterminate)
bash /srv/build/<project>/deploy/nginx-cache-probe.sh https://<domain>

Before first use, set PROJECT at the top of the script and clone the repo into the build dir; sites without nginx proxy_cache should set NGINX_CACHE_DIR empty to skip steps 5 and 6 explicitly (the cache is standard in production; an empty value should only appear on test sites).

Why step 5 is mandatory: the in-memory application cache is wiped on pm2 reload, but the nginx disk cache is not (inactive only evicts entries nobody visits, so hot pages stay fresh forever). The script has this step built in — don't skip it manually.

The outbound s-maxage of every cacheable page is capped at 60 seconds (framework#394; the internal ISR TTL stays what the page declares; the two are decoupled on purpose). The outbound header means "how often the reverse proxy asks nitro again", not a mirror of the page TTL: when nitro's entry expires it serves the stale copy first (SWR) and re-renders in the background; if the proxy then re-caches that stale page for a full TTL, staleness stacks into two TTLs; production also used to run pm2 cluster, where each worker held its own cache and staleness compounded to "workers × TTL" (cinebay#59: an isr: 3600 home page served a 4h44m-old banner — fixed for good by the single-process model of standards#86). With the cap, nginx asks once a minute (proxy_cache_lock lets one request per page per minute through; nitro answers from memory, the backend is not hit). It does not replace purging — worst-case lag is still one ISR TTL + 60s; "visible immediately" only comes from a purge.

ecosystem.config.cjs (production, single process)

module.exports = {
  apps: [
    {
      name: require('node:path').basename(process.cwd()), // name follows the serve dir (= project name); a hardcoded generic name gets hijacked by startOrReload on shared machines (starter#56)
      script: '.output/server/index.mjs',
      interpreter: 'node',
      instances: 1,
      exec_mode: 'fork', // single process (standards#86); changing these two is NOT applied by startOrReload — deploy goes through deploy/pm2-apply.sh
      max_memory_restart: '1280M', // must exceed --max-old-space-size + native overhead; 768M for small sites (admin / brochure)
      watch: false,
      env: { PORT: 3000, HOST: '0.0.0.0', NODE_OPTIONS: '--expose-gc --max-old-space-size=1024' },
    },
  ],
}

What the two memory parameters mean and how to size them:

  • --max-old-space-size=<MB> (NODE_OPTIONS): the V8 heap cap; exceeding it is an OOM crash. Content sites use 1024 — the 256 MB ISR cache is roughly 256–512 MB of real heap, and the other half is headroom for render peaks.
  • max_memory_restart: pm2 restarts the process when its RSS (heap + native memory + Buffers) exceeds it. It is a per-process runaway guard, not a memory allocation: its value depends only on this process — ≥ heap cap + ~200 MB native overhead — regardless of how many sites share the machine; below that pm2 kills the process before the heap is even full, and every false kill is a deploy hiccup. Content sites use 1280M (1024 + 200 = 1224 ≤ 1280 ✓; write 1280M, not 1.25G — the pm2 schema only accepts an integer + G/M/K, a decimal is rejected, pm2 start fails outright, and on the delete + start path the site ends up deleted; the unit cannot be omitted either — a bare number is interpreted by pm2 as bytes, so 1280 = 1280 bytes ⇒ the process starts but gets killed over and over. The starter's pm2-apply.sh rejects both forms against ^\d+(G|M|K)$ before touching pm2); do not squeeze it to 1G (1024 + 200 > 1024 ✗). Small sites (admin consoles, brochure sites with few pages / rare updates, whose ISR cache never fills) use heap 512 / restart 768M (512 + 200 = 712 ≤ 768 ✓). It is a per-process ceiling, not a resident footprint — normal RSS sits well below it. When changing tiers, change heap and restart as a pair (the template is the content-site tier: heap 1024 / 1280M; when deriving a small site, lower --max-old-space-size to 512 and restart to 768M at the same time — changing only restart is the 768 < 1024 false-kill shape, and the starter's tests/unit/deploy-topology.spec.ts goes red); instances: 1 must be written explicitly, never rely on the pm2 default (repos derived from the old template lack that line and the topology contract test goes red).
  • Does the machine have room? Read the resident headroom in the deploy log; do not add up per-site ceilings. Every deploy path that restarts pm2 prints one line of free -m + nproc after pnpm build and before the pm2 restart (machine spec and headroom land in every deploy log, no hand-copied number that goes stale). The template retains independent manual deployment scripts; every manual path running pm2-apply.sh must record that line (standards#88). Look only at that line's available: it must be enough for the next nuxt build (currently about 1.5 GB; roughly 20% of the total that free prints — a box sold as 8 GB reports a total of 7680M because firmware / kernel reserve about 512M, so never convert from the nominal spec; when the spec or the build changes, judge by "enough for one build" instead of revisiting a percentage. Ops measured two 4-core / 7680M boxes on 2026-09-11: 6 fork processes each, RSS totalling 1.1–1.2 GB, available 4.5–5.8 GB — the old "Σ per-site ceilings ≤ machine memory" formula would have judged this setup as over budget against the real total, which is exactly why it was dropped). ⚠️ That line measures resident headroom, not the build peak — pnpm build is a separate step, its process has exited and returned its memory by the time free runs, so the transient is gone; and available counts reclaimable page cache as free, so the reading is optimistic. It does not cover build-time OOM; it only answers "is the resident set already at the limit". When headroom is short, find the runaway process first (a single process with RSS ≥ 3× the median of its peers), then consider moving a site — never by squeezing the per-site ceilings.

In fork mode, pm2 reload is a restart: every single-process deploy has a ~1–3 s process switch window (not only the one where the shape changes). nginx proxy_cache_use_stale covers cacheable pages with the old cache, while non-cacheable requests may fail once. Acceptable for content sites — it is the price of "content no longer flips".

Cloudflare configuration

With CF as a pure CDN / reverse proxy, this architecture is a bonus for deploys, but configure it right:

  • Don't cache HTML, pass to origin: every HTML request hits the origin's live SSR, always getting the current version (referencing current hashes) — no "stale HTML at the edge". Keep HTML uncached.
  • Cache _nuxt/* (hashed + immutable): the edge caches and absorbs traffic; new versions are new filenames, pulled from origin once then cached. Make sure CF doesn't exclude _nuxt/* from caching (it caches by extension by default).
  • Don't purge static cache: hashing + immutable make purging pointless or harmful (a needless origin stampede). ⚠️ But the HTML cache must be cleared — don't conflate the two: once nginx proxy_cache is in play, every deploy must run rm -rf /var/cache/nginx/<zone>/* && nginx -s reload (built into deploy-prod.sh as step 6/7), otherwise users stay on the old version for up to "page ISR TTL + 60s".
  • Never enable "Cache Everything"-style rules: once CF caches HTML it gets baked into edge nodes worldwide — purging spans every node and is far harder than clearing the local nginx cache. Keeping HTML uncached avoids the problem entirely.

Put the HTML cache in nginx (standard in production)

Bottom line first: the HTML cache belongs primarily in nginx proxy_cache; the application-level nitro ISR cache is only a safety floor.

Six safety points (getting any one wrong can hurt)

  • The actual API prefix (including login; /api/ by default), /track/ and Nuxt internal routes must be excluded via a regex location — the full list: the actual API path prefix (/api/ by default), /track/, /_nuxt/, /_ipx/, /__sitemap__/, /__og-image__/, /__nuxt_island/. The first two are nitro routeRules proxies (caching the login endpoint is a disaster); _nuxt already ships content hashes + immutable, no second cache layer needed. Regex locations take priority over the prefix location /
  • No proxy_cache_valid, no proxy_ignore_headers Cache-Control — the single source of truth for TTL is the upstream s-maxage (ISR pages emit it automatically on framework ≥0.92.0). "Responses without cache-control are never cached" is by design: ssr: true dynamic pages / CSR shells / prerendered pages / 404s naturally stay out of the cache. ⚠️ A fixed-TTL fallback would instead sweep ssr: true dynamic pages into the shared cache (content-mismatch risk) and mask missing routeRules — one site's /movie silently fell back to full SSR because of a missing zero-segment parent rule, hidden for a long time by the fixed TTL; removing it exposed the bug instantly. ⇒ The first question for "this page suddenly isn't cached" is always: is the upstream still emitting s-maxage?
  • proxy_cache_methods GET HEAD — POST is uncacheable anyway; narrow it explicitly once more
  • proxy_cache_lock on + proxy_cache_use_stale error timeout updating http_5xx + proxy_cache_background_update on — prevents stampede; serves stale while revalidating in the background (≈ stale-while-revalidate); falls back to stale content on upstream 5xx
  • proxy_ignore_headers Set-Cookie + proxy_hide_header Set-Cookie + proxy_cache_bypass $http_authorization $cookie_nocache — defense in depth; identity must never enter a shared cache, and authenticated requests bypass it entirely
  • add_header X-Cache-Status $upstream_cache_status always — the only way to observe HIT/MISS; don't skip it

Use a path-only cache key

proxy_cache_key "$scheme$host$uri" (all query strings ignored). This depends on the "cacheable pages must not read query" hard rule — see Rendering Strategies §4.2.

TTL is decided in one place, the application: ISR pages emit cache-control: s-maxage=<min(TTL, 60)> (outbound cap 60s, framework#394; the internal ISR TTL is unchanged), which nginx honours by default ⇒ do not add proxy_ignore_headers Cache-Control plus a fixed TTL in nginx, or you will be maintaining the TTL in two places.

Full reference config (copy and adapt)

The cache zone (one global entry under conf.d/) plus the cache-related parts of a site's server block:

# conf.d/proxy-cache-zones.conf — cache zone (size to your site; inactive evicts the cold long tail)
proxy_cache_path /var/cache/nginx/nuxt-html
                 levels=1:2 keys_zone=nuxt_html:20m
                 max_size=2g inactive=24h use_temp_path=off;

Match the application's API configuration: starter uses the API_PREFIX project constant in nuxt.config.ts for both the nitro.routeRules proxy paths and runtimeConfig.public.apiPrefix; it is not an environment variable. The example below uses the default /api. After changing the constant to const API_PREFIX = '/h5api', use location ~ ^/(h5api|track|_nuxt|_ipx|__sitemap__|__og-image__|__nuxt_island)/; excluding only /api/ is insufficient.

# Site config skeleton (80 → 443 redirect + the 443 site; adjust cert paths to certbot's actual output)
server {
  listen 80;
  server_name example.com;
  location /.well-known/acme-challenge/ { root /var/www/certbot; }
  location / { return 301 https://$host$request_uri; }
}

server {
  listen 443 ssl http2;
  server_name example.com;
  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  client_max_body_size 100m;

  # ↓↓ place the two location blocks below in here ↓↓
}

# API_PREFIX is a nuxt.config.ts project constant; for /h5api, replace api with h5api below
# ① exclusion list (regex locations beat the prefix location /)
location ~ ^/(api|track|_nuxt|_ipx|__sitemap__|__og-image__|__nuxt_island)/ {
  proxy_pass http://127.0.0.1:3000;
  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;
  proxy_http_version 1.1;
}

# ② Page HTML — goes through the proxy cache
location / {
  proxy_pass http://127.0.0.1:3000;
  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;
  proxy_http_version 1.1;

  proxy_cache            nuxt_html;
  proxy_cache_methods    GET HEAD;
  proxy_cache_lock       on;
  proxy_cache_lock_timeout 15s;
  proxy_cache_key        "$scheme$host$uri";          # path-only (see section above)
  proxy_cache_use_stale  error timeout updating http_500 http_502 http_503 http_504;
  proxy_cache_background_update on;
  proxy_ignore_headers   Set-Cookie;
  proxy_hide_header      Set-Cookie;
  proxy_cache_bypass     $http_authorization $cookie_nocache;
  # Note: deliberately no proxy_cache_valid / proxy_ignore_headers Cache-Control —
  # TTL is decided entirely by the upstream s-maxage (see safety point 2)
  add_header X-Cache-Status $upstream_cache_status always;
}

Production go-live checklist (run on first launch / after every major release)

⚠️ "The site feels fast" is not evidence that caching works — proxies / CDNs can mask problems. curl each of these:

#WhatCommandExpect
1ISR translation chain works (app layer emits s-maxage)On the VPS: curl -sI http://127.0.0.1:<port>/<isr-page> | grep -i cache-controls-maxage=<min(TTL, 60)>, stale-while-revalidate; pages with TTL ≥ 60 show the cap (60) rather than the page TTL — that is expected (framework#394); no such header at all = absent = the page's routeRule isn't effective (check build-time guard output / missing zero-segment parent rule)
2nginx cache workscurl -sI https://<domain>/<isr-page> | grep -i x-cache-status, twiceMISS first, HIT second; a constant MISS → check #1 first (not caching responses without s-maxage is by design — don't "fix" it with a fixed TTL)
3canonical / origin correctcurl -s https://<domain>/<isr-page> | grep -o 'rel="canonical"[^>]*'Correct domain, never localhost (if so: NUXT_PUBLIC_SITE_URL / varies chain issue). Multi-domain / mirror-domain sites: repeat with -H "Host: <another-domain>" — each canonical must follow its own request domain (all converging to one domain = a fallback has defeated the "leave it empty" setting)
4sitemap domain correctcurl -sL https://<domain>/sitemap.xml | grep -o '<loc>[^<]*' | head -3<loc> domains match the request domain (per-domain on multi-domain sites). 🔴 Correct canonical ≠ correct sitemap — they resolve through different chains (useSeo vs nuxt-site-config); we have seen "canonical correct, sitemap stuck on a wrong env-fallback domain" in the wild, so checking #3 alone misses it

Also: excluded paths (/_nuxt/ and endpoints under the actual API prefix) should carry no X-Cache-Status header; after one deploy, re-run #2 to confirm the cache was purged (back to MISS).

Troubleshooting

SymptomRoot causeFix
Content still stale after a deploynginx HTML cache not purged (if it was, check the CF edge)Step 6/7 of deploy-prod.sh must run; manually: rm -rf /var/cache/nginx/nuxt-html/* && nginx -s reload
Upstream sends s-maxage but nginx stays MISSResponse carries Set-Cookie — nginx refuses to cache it unless told to ignore. framework strips Set-Cookie at render time on cacheable pages (#374), so this cause only comes from a framework that predates that fix or a custom server route writing cookies on a cacheable pathCheck the framework version first; then copy the full sample config: proxy_ignore_headers Set-Cookie + proxy_hide_header Set-Cookie (the sample includes both; hand-rolled legacy nginx configs miss these two lines most often)
Page body unstyled, fixed by refresh / navBuilt in serve dir / --delete removed old-hash assetsFollow golden rules: build in a separate dir + rsync no --delete
Occasional unstyled after deployThe old process in the switch window references deleted chunksSame (don't delete old assets); CF caching _nuxt also cushions the window
Frequent restarts, CPU jittermax_memory_restart below heap cap + native overhead, false-killedRaise it per the formula above (1280M for content sites); do not set it equal to --max-old-space-size
Content flips on refreshStill running pm2 cluster; each instance holds its own ISR cacheSwitch to a single process (pm2 jlist shows exec_mode; deploy via pm2-apply.sh switches automatically); for strong consistency add a Redis shared cache
Deploy script ends with ⚠️ no x-cache-statusThis site's nginx is not wired to proxy_cache / the location split missesCheck the nginx site config against the sample on this page (standard in production)
CF still serves the old version after deployMistaken purge, or HTML got cachedKeep HTML uncached; don't purge _nuxt (hashing gives multi-version coexistence)
Deploy succeeded but one page returns 500 (response carries cache-control: no-cache)That page's SSR data fetch failed (backend unreachable / endpoint missing) — not a deployment problem; error responses deliberately never enter the cachecurl the backend from the VPS first, then trace the page's data calls; use a backend-free page (terms / privacy) to validate the deploy chain itself

Appendix: rationale & design decisions (operators can skip)

Core principle: why "the page body is unstyled on first load, fixed after refresh"

This is the most common symptom of a broken deploy — understand the root cause.

Nuxt's built static assets (_nuxt/entry.<hash>.css, per-component chunks) have content-hashed filenames, served by the Nitro process with long-lived immutable cache headers. And nuxt build wipes .output first, then regenerates it.

If you run pnpm build directly in the directory that is actively serving traffic:

build start ──────────────────────── build end
   │  old .output wiped               │  new .output ready
   │  ← this window (tens of sec~min) →│
   │  old process still serving HTML  │
   │  that references old-hash assets │
   │  but the files are already gone  │
   │  → browser 404s those .css/.js   │
   │  → page body unstyled            │

It recovers on refresh / client-side navigation because the new .output is ready by then / client navigation loads current chunks via JS. The process switch window, CF origin pulls, and users with an old page still open all run into it.

The golden rules (two)

  1. Never build in the directory that is actively serving traffic. Build in a separate directory (local/operator build directory), then move .output to the serve dir once the artifact is ready — the serve dir never has an "assets missing" window.
  2. Sync .output without --delete. Keep old-hash assets so the old process in the switch window, CF origin pulls, and users with an old page still loaded can hit them. Orphan chunks are harmless (Nitro only reads the current manifest); clean them up periodically if disk is tight.
# ❌ Wrong: build in serve dir + --delete; all old assets 404 during the build
rsync -a --delete src/ /var/www/html/app/ && cd /var/www/html/app && pnpm build

# ✅ Right: build in a separate dir, then sync only .output without --delete
cd /srv/build/app && pnpm build
rsync -a /srv/build/app/.output/ /var/www/html/app/.output/    # no --delete

Why the application layer cannot carry it (measured)

Nitro's ISR cache lives in process memory, capped at 1000 entries. Measured on one site:

  • Detail pages are 67 KB each, across 19,000 titles ⇒ 1.27 GB to hold them all
  • The framework default cap is 256 MB / 1000 entries ⇒ room for 1000 pages = ~5% coverage
  • Adding processes buys no coverage either: production used to run an 8-worker cluster, each with its own copy ⇒ 400 MB of RAM for zero extra coverage (requests are distributed randomly, so a page cached by worker A is a miss on worker B) — one more reason for the single-process model

Three decisive advantages of the nginx layer

All workers share one copy, content lands on disk (GB-scale capacity), and a cache hit never reaches Node at all (a bigger CPU win than any application-level cache). Measured in a POC (connecting from inside the VPS to exclude network latency):

PageUncachedHitSpeedup
List page0.213–0.294s0.0043s~50×
Detail page0.176–0.184s0.0040s~44×
Home page (4.7 MB)4.29–5.70s0.0066s~650×

⚠️ Measure the gain from inside the VPS. From the public internet the TTFB is dominated by the round trip (0.9–1.1s either way), the difference is invisible, and you will wrongly conclude "it did nothing".

🔴 Never use the fs driver for the application cache

This guide used to recommend "single machine → prefer Nitro's fs driver (zero deps)". That recommendation is withdrawn: the fs driver maps cache keys directly onto file paths, and a cacheable parent path plus a cacheable child path necessarily collide:

/scandals      → cache entry becomes the FILE   …/payload/scandals.json
/scandals/97   → cache entry would need         …/payload/scandals/97.json   ← requires a DIRECTORY
⇒ ENOTDIR, the child path 500s

And since "move the parameter into a path segment" is the preferred fix for cacheable pages, this shape is now the norm. Restructuring the URL does not help.

  • framework ≥0.92.0 ships a bounded LRU in-memory driver (1000 entries / 256 MB / 2 MB per entry, mounted with ??= so an explicit config still wins) — safe with zero configuration
  • framework ≥0.93.0 rejects the fs-driver-plus-cacheable-parent-and-child combination at build time
  • Leave capacity concerns to the nginx layer (disk, GB-scale); the application layer only needs "bounded + KV semantics"

Only multi-machine setups warrant Redis (requires ioredis, subject to P0 approval); a single VPS is well served by nginx's disk cache.

⚠️ ISR cache inconsistency is a freshness issue, unrelated to first-load unstyled pages (static asset 404) — unstyled pages are solved by the golden rules, not by a cache backend.

Video transcoding: pair H.265 (HEVC) with H.264 dual-codec variants

When your backend transcodes to HLS and you want H.265/HEVC for bandwidth savings (30-50% smaller than H.264 at the same quality), the master playlist must also carry H.264 variants as a fallback — this is the industry-standard approach, anchored by Apple's official HLS Authoring Specification (HEVC delivery to Apple devices requires accompanying H.264 variants for backward compatibility); Netflix / YouTube follow the same "new codec + AVC fallback" ladder strategy.

Why: HEVC carries patent royalties, so browsers ship no software decoder and rely entirely on hardware — Safari (iOS/macOS) supports it natively, Chrome/Edge 105+ only when the device has hardware decoding (older iGPUs / old Android don't), Firefox effectively doesn't. The SwVideoPlayer stack (Artplayer + hls.js ≥1.6) imposes no limit of its own: hls.js and Safari pick a supported codec among the variants via MediaSource.isTypeSupported(), so with dual variants, devices without HEVC silently fall back to H.264 — full compatibility.

时序图渲染中…
# master playlist with dual codec variants (transcoder output sketch)
#EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",RESOLUTION=1920x1080
hevc_1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4200000,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1920x1080
avc_1080p/index.m3u8

The frontend contract is a single URL: both variants live inside one master playlist — what the frontend receives (SwVideoPlayer's url, or each track's m3u8_url in multi-track links) is that one master.m3u8 address. Codec selection happens automatically at the HLS protocol layer (hls.js / Safari), zero frontend changes.

Anti-pattern — backend hands out two separate URLs for the frontend to choose from: this drags a problem already solved at the protocol layer up into the business layer — the frontend must probe codecs and maintain selection logic, a wrong guess means a black screen, and every consumer (Web / App / TV) has to reimplement it. The correct contract is a single dual-variant master playlist from the transcoder; any standard HLS player picks natively.

Anti-pattern — HEVC-only variants: devices without hardware decoding fail outright (the component emits error, the user sees a black screen), and this "some users can't play" class of issue is very hard to reproduce on your own machine. To verify a specific device, run MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') in its console.

Looking ahead: adding an AV1 variant works the same way

The current transcoding requirement is the HEVC + H.264 dual ladder; if AV1 is introduced later, it is just one more CODECS="av01..." variant line in the same master playlist — frontend and player stay untouched (hls.js 1.5+ supports AV1 segments in fMP4; the framework ships 1.6.x). AV1's appeal: royalty-free + broad browser support (Chrome / Firefox even ship software decoders; Safari needs iPhone 15 Pro / M3-class hardware) + proven at scale by Netflix / YouTube. By contrast H.266/VVC currently has zero browser support and an even messier patent situation than HEVC — for the web, the realistic next-gen codec is AV1. Keep the H.264 fallback rung then as now; this section's contract is unchanged.