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— setsnextConfig.assetPrefix = process.env.NEXT_PUBLIC_ASSET_PREFIXinside theNEXT_BUILD === '1'branch, guarded on the var being present. Absent var → pod-relative paths (pre-CDN behaviour), neverundefined/….frontend/Dockerfile—NEXT_PUBLIC_ASSET_PREFIXis a build arg;assetPrefixis inlined at build time..github/workflows/deploy-app.yml, jobbuild-and-push, step Publish frontend assets to Spaces CDN — extracts/usr/share/nginx/html/_next/staticfrom the image that actually ships (docker create+docker cp, never a secondnext build) andaws 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 buildin CI would produce different hashes than the deployed HTML references — that is the bug. - Upload before deploy. The step lives in
build-and-push, whichdeploy-kuberneteslists inneeds:, so assets are live before any HTML referencing them is served. - The pod keeps its own copy.
DockerfilestillCOPYs/app/out. Rollback = unset theNEXT_PUBLIC_ASSET_PREFIXsecret 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:
- Long edge TTL, respect origin
immutable. - Error responses non-cacheable — kills the ~3-minute 404 negative cache.