Skip to content

Frontend Asset Serving

How the Next.js static export's hashed assets (_next/static/**) reach the browser, and why they are served from a CDN bucket rather than the frontend pod.


The Problem

The frontend is a Next.js static export (output: 'export') baked into an nginx image (frontend/Dockerfile). Each build emits content-hashed chunks — _next/static/chunks/<hash>.css, <hash>.js — and the hashes change on every build.

Production runs a single frontend replica with a RollingUpdate strategy (maxUnavailable: 0, maxSurge: 1). During a deploy the old and new pod both sit behind one Service for ~10s. The index.html is never cached by Cloudflare (cf-cache-status: DYNAMIC), so a browser always gets fresh HTML from whichever pod answers — but that HTML references the new build's hashes while the old pod has only the old ones (or vice versa).

Result: the stylesheet request 404s, the page renders unstyled, and Cloudflare negative-caches that 404 for ~3 minutes so every visitor is affected, not just the one racing the rollout.

The same failure hits any client holding older HTML — a tab left open across a deploy, a bfcache entry — when it lazy-loads a chunk the current pod never had.

The Fix

_next/static/** is served from a DigitalOcean Spaces bucket (hardhat-flow-assets) fronted by its built-in CDN. The deployed HTML points at it via assetPrefix.

  • frontend/next.config.js — sets nextConfig.assetPrefix = process.env.NEXT_PUBLIC_ASSET_PREFIX inside the NEXT_BUILD === '1' branch, guarded on the var being present. Absent var → pod-relative paths (pre-CDN behaviour), never undefined/….
  • frontend/DockerfileNEXT_PUBLIC_ASSET_PREFIX is a build arg; assetPrefix is inlined at build time.
  • .github/workflows/deploy-app.yml, job build-and-push, step Publish frontend assets to Spaces CDN — extracts /usr/share/nginx/html/_next/static from the image that actually ships (docker create + docker cp, never a second next build) and aws s3 syncs it to the bucket.

Invariants

  • The upload is additive — never --delete. The bucket must accumulate every build's hashes so old HTML keeps resolving. The 90-day lifecycle rule on _next/static/ is the only thing that removes objects.
  • Extract, never rebuild. A re-run of next build in CI would produce different hashes than the deployed HTML references — that is the bug.
  • Upload before deploy. The step lives in build-and-push, which deploy-kubernetes lists in needs:, so assets are live before any HTML referencing them is served.
  • The pod keeps its own copy. Dockerfile still COPYs /app/out. Rollback = unset the NEXT_PUBLIC_ASSET_PREFIX secret and redeploy.

Bucket configuration

Setting Value Why
Objects public-read (per-object ACL, set on upload) CDN + anonymous browser GET. No bucket policy — DO Spaces refuses bucket-scoped keys on policied buckets.
CORS AllowedOrigins https://hardhatflow.com, https://www.hardhatflow.com The CSS @font-face rules load ../media/*.woff2; once CSS is cross-origin the font fetches need Access-Control-Allow-Origin or text falls back to system fonts.
Lifecycle expire _next/static/ after 90 days Housekeeping. Far beyond any realistic open-tab lifetime (~3 MB/build).
CI credentials Spaces key hardhat-flow-assets-ci, readwrite scoped to this bucket only Separate from the Django media credentials in hardhat-flow-secrets.

Cloudflare

Two cache rules on /_next/static/*, kept permanently as defense-in-depth:

  1. Long edge TTL, respect origin immutable.
  2. Error responses non-cacheable — kills the ~3-minute 404 negative cache.