diff --git a/.gitignore b/.gitignore
index f59f179..3f0d5d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules/
*.log
assets/work/
+dist/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4455c65
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1
+# Static teaching site: bash assembles dist/ from the split source, then a pinned
+# nginx-unprivileged serves it. No Node build — build.sh is plain bash + coreutils.
+
+# ---- build stage ----------------------------------------------------------
+# Reusing the digest the site image already pins: it's a bookworm-slim base that is
+# verified and cached in the zot pull-through mirror, so this layer costs nothing
+# extra. Only bash/sed/find/install are used here — node itself is incidental.
+FROM node:22-bookworm-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS build
+WORKDIR /app
+COPY . .
+RUN bash build.sh
+
+# ---- runtime stage --------------------------------------------------------
+# Same vetted digest as the site. Renovate keeps it current.
+FROM ghcr.io/nginx/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0
+
+COPY nginx/default.conf /etc/nginx/conf.d/default.conf
+COPY nginx/security-headers.conf /etc/nginx/security-headers.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 8080
diff --git a/README.md b/README.md
index 9ca9258..fe042c8 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,8 @@ Interactive scroll-driven teardown of Kubernetes / OpenShift / Docker, in the es
Bat-Computer visual world (void `#070b14`, cyan `#3fbaf5` → magenta `#e879f9`).
- **v2** (tag `v2`) — CSS `preserve-3d` diagrams. Parked: eyeballed px positions, misaligned at some widths.
-- **v3** (in progress) — Nano Banana 2 exploded hero shots, hand-sliced into `clip-path` parts and
+- **v3** (in progress) — ships PUBLIC at **learn.bztmon.com**.
+- **v3 build** — Nano Banana 2 exploded hero shots, hand-sliced into `clip-path` parts and
scrubbed collapsed → hero with GSAP. Spec: `homelab/specs/SPEC-exploded-cluster-v3.md`.
## Layout
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..946229c
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Assemble dist/ — the CSP-clean static build served at learn.bztmon.com.
+#
+# The artifact build inlines GSAP and the app JS because the artifact CSP blocks every
+# external request. nginx has no such constraint, so here they stay SEPARATE files:
+# `script-src 'self'` holds with no 'unsafe-inline' exception, and the two GSAP bundles
+# (116KB, unchanged between deploys) become independently cacheable.
+set -euo pipefail
+IFS=$'\n\t'
+
+trap 'printf "build failed at line %s\n" "$LINENO" >&2' ERR
+
+here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+cd -- "$here"
+
+dist="$here/dist"
+src_markup="ec2-part1.html"
+src_app="ec2-part2.html"
+
+for f in "$src_markup" "$src_app" gsap.min.js st.min.js; do
+ [[ -f "$f" ]] || { printf 'missing source: %s\n' "$f" >&2; exit 1; }
+done
+
+rm -rf -- "$dist"
+mkdir -p -- "$dist"
+
+# --- app.js: strip the single block; served standalone it must be raw JS.
+sed -e '1{/^[[:space:]]*
+
+
+