Immutable, deployment-ID static hosting
Mirrored from
iblai/lmsยทdocs/DEPLOYMENT.md. This page is generated โ edit it in the repository, not here.
Eliminates the version-skew problem (a browser on build A fetching a chunk from
a node on build B โ ChunkLoadError) that today forces us to drain LB backend
sets during a blue/green deploy.
The idea
Every build's /_next/* assets are published, immutable, to object storage
under a per-release path and served from a CDN. The deployment ID is baked into
the image at build time, so each node emits self-consistent absolute asset
URLs for its own release:
a node running v0.101.0 emits:
<cdn>/apps/lms/0.101.0/_next/static/chunk-abc.js โ the browser fetches this from the CDN
Because all releases' assets coexist in the CDN and are retained, node build skew can no longer break chunk loading โ so nodes can be updated rolling, in place, with no full-pool drain. Nodes never fetch or look up assets: the ID is compile-time, the browser pulls from the CDN.
Storage layout (bucket apps/<app>/<version>/):
apps/lms/0.101.0/_next/static/โฆ immutable, additive
apps/lms/0.101.0/public/โฆ immutable, additive
apps/lms/manifest/0.101.0.json this build's record
apps/lms/current.json desired-live pointer (for GC + rollback tooling)
What's in this repo (the app side โ done)
| Change | Effect |
|---|---|
next.config.ts | generateBuildId = release version; assetPrefix = <NEXT_PUBLIC_ASSET_CDN>/apps/<app>/<version> when that env is set, else unchanged. crossOrigin: 'anonymous'. |
Dockerfile | Passes NEXT_PUBLIC_ASSET_CDN, NEXT_PUBLIC_APP_NAME, APP_VERSION into the build. |
scripts/upload-static.sh | Post-build: syncs .next/static + public to apps/<app>/<version>/, writes manifest + current.json. |
scripts/prune-static.sh | Retention/GC: keep newest KEEP_LAST + current, delete the rest. Dry-run by default. |
This is a no-op until NEXT_PUBLIC_ASSET_CDN is provided at build time. With
it unset (local dev, Tauri/offline export, and current prod) the app still serves
its own static exactly as before โ nothing breaks on merge.
What you need to do (infra + CI โ manual)
AWS S3 โ create a bucket (any name โ the scripts read it from
S3_BUCKET; nothing assumes a particular one). Keep it private and reach it via CloudFront OAC (a public-read bucket also works). Create an IAM user/role for CI withs3:PutObject+s3:ListBucketon the bucket (adds3:DeleteObjectfor the retention job) โ access key/secret.CloudFront โ distribution with origin = the S3 bucket (via an Origin Access Control so the bucket stays private), alternate domain
assets.ibl.ai+ an ACM cert in us-east-1. Cache/_next/static/*forever (the objects already carryCache-Control: immutable); attach a response-headers policy with CORS (Access-Control-Allow-Origin) so fonts/workers load cross-origin. Point theassets.ibl.aiDNS record at the distribution. No cache invalidation is ever needed โ this distribution only serves immutable, version-namespaced static; HTML is served by the app nodes, not CloudFront. Verify CloudFront serves keys 1:1 (no origin-path rewrite) soassets.ibl.ai/apps/lms/<v>/โฆmaps to bucket keyapps/lms/<v>/โฆ.CI secrets/vars (set on the build repo,
iblai/lmsโ Settings โ Secrets and variables โ Actions):- Variables:
NEXTJS_PUBLIC_ASSET_CDN=https://assets.ibl.ai,NEXTJS_S3_BUCKET=<your bucket>. - Secrets:
NEXTJS_S3_ACCESS_KEY,NEXTJS_S3_ACCESS_SECRET,NEXTJS_S3_AWS_REGION.
(
NEXT_PUBLIC_APP_NAMEis taken from the workflow'sapp_nameโlms.S3_ENDPOINTis only for a non-AWS S3-compatible store; leave it unset.)- Variables:
Build + publish wiring โ DONE in
reusable-spa-docker-build.yml: whenNEXTJS_PUBLIC_ASSET_CDNis set it passes theNEXT_PUBLIC_ASSET_CDN/NEXT_PUBLIC_APP_NAME/APP_VERSIONbuild-args, then a Publish static assets to S3 step extracts.next/static+publicfrom the built image and runsscripts/upload-static.sh. Gated on the variable, so it's a no-op for every other app. (The step installs the aws CLI to$HOMEif the runner lacks it.) Nothing more to wire โ just set the values in step 3.Schedule retention: a workflow running
scripts/prune-static.sh(DRY_RUN=false,KEEP_LASTโฅ your rollback horizon).Switch the rollout (ops
prod-service-update.sh): once assets are on the CDN, update nodes rolling, in place instead of draining a backend set. Keep the LB swap only as an optional canary.Server-Action skew (the one residual): enable LB sticky sessions during the rollout window and add a client
ChunkLoadErrorโlocation.reload()self-heal so a client briefly on an old build silently lands on the current one.
Rollback
Images are tagged by release version and each is a self-contained deployment.
Roll back = re-deploy the previous image tag (e.g. 0.100.1). That image
emits 0.100.1 asset URLs, and 0.100.1's assets are still in the bucket
(retention), so the rollback is complete and instant โ no rebuild, no re-upload,
and skew-safe (both versions' assets coexist).
โ ๏ธ Rollback only works while the target version's assets are retained โ keep
KEEP_LAST (or an age policy) larger than how far back you'd ever roll.
App-specific cautions
- Tauri / offline (
output: 'export') builds must NOT setNEXT_PUBLIC_ASSET_CDNโ they need self-contained relative assets. - Service worker (
sw.js) caching must be reviewed against the new cross-origin static origin before Phase 3. - If a release is ever rebuilt with different content, use
DEPLOYMENT_ID=<version>-<sha8>so it doesn't overwrite immutable assets.