diff --git a/.claude/.codebase-info/directory-structure.md b/.claude/.codebase-info/directory-structure.md
index 20a62a8d..f6b083c7 100644
--- a/.claude/.codebase-info/directory-structure.md
+++ b/.claude/.codebase-info/directory-structure.md
@@ -30,5 +30,5 @@ proof9/ # Claude Code plugin: hooks/, tests/, README.md
tests/ # core/, cli/, adapters/, integration/, lifecycle/, e2e/, ci/, …
docs/ # GOLDEN_PATH, CLI_WIREFRAME, PRODUCT_ROADMAP, VISION, CLI guide, images/
scripts/ # lifecycle runner, quickstart-cleanroom/, ci/ helpers, verify scripts
-site/ # Static codeframe.sh page (no build, no JS); make-og-image.sh rebuilds the card
+site/ # Static codeframe.sh page (no build, no JS); tools/site/make-og-image.sh rebuilds the card, tools/site/deploy.sh publishes it
```
diff --git a/site/index.html b/site/index.html
index ec0fa790..4c5f9f08 100644
--- a/site/index.html
+++ b/site/index.html
@@ -12,7 +12,7 @@
-
+
diff --git a/tools/site/README.md b/tools/site/README.md
new file mode 100644
index 00000000..e91892b3
--- /dev/null
+++ b/tools/site/README.md
@@ -0,0 +1,51 @@
+# Site tooling
+
+Scripts for the static `codeframe.sh` page in `site/`. They live here rather than
+in `site/` so that everything under `site/` is servable, and a deploy can copy the
+directory as-is with no exclude list to remember.
+
+| Script | What it does |
+|---|---|
+| `make-og-image.sh` | Regenerates `site/og-image.png` (the 1200x630 social card) and `site/proof9-loop-still.png`. Needs ffmpeg and DejaVu fonts. |
+| `make-banner.py` | Builds the outlined banner SVG. Usage is in its docstring. |
+| `deploy.sh` | Publishes `site/` at `origin/main` to the web root. Runs on the server. |
+
+## Deploying
+
+The trigger is manual. After a change to `site/` merges:
+
+```bash
+tailscale ssh root@frank-prod-server deploy-codeframe-site
+```
+
+One-time setup on the server. It needs `git`, `rsync` and the `www-data` user, which
+nginx creates; the script refuses to start without that user rather than failing
+part-way through.
+
+```bash
+git clone --depth 1 https://github.com/frankbria/codeframe /opt/codeframe-site
+install -m 755 /opt/codeframe-site/tools/site/deploy.sh /usr/local/bin/deploy-codeframe-site
+```
+
+Re-run the `install` line whenever `deploy.sh` itself changes; the installed copy
+does not update itself.
+
+What a run does:
+
+- pulls the public repository, so no credential is needed and none is stored;
+- keeps one rolling backup of the web root it replaces, at `/root/codeframe.sh-predeploy.tgz`.
+ It is skipped on a first deploy, when the root already holds the same commit, and after
+ an interrupted run, so a re-run after a bad or broken deploy cannot overwrite the last
+ good copy;
+- mirrors `site/` into the web root with `rsync --delete`, so an asset removed or
+ renamed in the repository disappears on the next deploy;
+- writes the deployed commit to `.deployed-sha` in the web root.
+
+Paths can be overridden with `REPO_URL`, `SRC`, `DOCROOT` and `BACKUP`, which is how
+the script is tested in a throwaway container.
+
+Check what is live:
+
+```bash
+tailscale ssh root@frank-prod-server cat /var/www/codeframe.sh/.deployed-sha
+```
diff --git a/tools/site/deploy.sh b/tools/site/deploy.sh
new file mode 100755
index 00000000..f5531280
--- /dev/null
+++ b/tools/site/deploy.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Publish site/ at origin/main to the codeframe.sh web root.
+#
+# Runs ON the web server, not on a workstation. Installed as
+# /usr/local/bin/deploy-codeframe-site; see tools/site/README.md.
+#
+# The repository is public, so this pulls with no credential. rsync --delete
+# makes the web root an exact copy of site/: an asset removed or renamed in the
+# repository disappears here on the next run.
+set -euo pipefail
+
+REPO_URL=${REPO_URL:-https://github.com/frankbria/codeframe}
+SRC=${SRC:-/opt/codeframe-site}
+DOCROOT=${DOCROOT:-/var/www/codeframe.sh}
+BACKUP=${BACKUP:-/root/codeframe.sh-predeploy.tgz}
+# Present only while a sync is under way. If it survives, a previous run died
+# mid-sync and the web root is a torn mix of two commits.
+INCOMPLETE=${INCOMPLETE:-${BACKUP%.tgz}.incomplete}
+
+# A clone killed part-way leaves a .git with no commit, and every later fetch
+# would fail. Remove only that: a checkout with a commit is kept even if fetch
+# fails, and a directory that is not a git checkout is never deleted.
+if [ -d "$SRC/.git" ] && ! git -C "$SRC" rev-parse -q --verify HEAD >/dev/null 2>&1; then
+ rm -rf "$SRC"
+fi
+if [ ! -d "$SRC/.git" ]; then
+ git clone -q --depth 1 "$REPO_URL" "$SRC"
+fi
+git -C "$SRC" fetch -q --depth 1 origin main
+git -C "$SRC" reset -q --hard origin/main
+HEAD_SHA=$(git -C "$SRC" rev-parse HEAD)
+
+# Fail before touching anything if the web server user is missing, rather than
+# part-way through with a live site and no record of the deploy.
+id -u www-data >/dev/null 2>&1 || { echo "deploy: user www-data does not exist; create it or install nginx first" >&2; exit 1; }
+
+# One rolling backup of what is being replaced, taken only when the web root is
+# a complete deploy of a different commit. Skipped on a first deploy (no root),
+# on a re-run at the same commit, and after an interrupted run (torn root): in
+# each of those cases a backup would replace the last good copy with something
+# that is not one. The sync below still runs, so a re-run repairs the root.
+if [ -d "$DOCROOT" ] && [ ! -e "$INCOMPLETE" ] \
+ && [ "$(cat "$DOCROOT/.deployed-sha" 2>/dev/null)" != "$HEAD_SHA" ]; then
+ # Written aside and renamed: a tar that fails part-way (disk full) must not
+ # truncate the backup it was meant to replace.
+ tar -C "$(dirname "$DOCROOT")" -czf "$BACKUP.tmp" "$(basename "$DOCROOT")"
+ mv "$BACKUP.tmp" "$BACKUP"
+fi
+
+# The marker goes down before the first change to the web root and comes up
+# after the last, so no interruption can leave a changed root without it.
+touch "$INCOMPLETE"
+mkdir -p "$DOCROOT"
+# .well-known/ is left alone in case certificate renewal ever uses this root.
+rsync -a --delete --exclude '/.well-known/' --exclude '/.deployed-sha' "$SRC/site/" "$DOCROOT/"
+chown -R www-data:www-data "$DOCROOT"
+# Success is recorded only after the last step that can fail, so a failure
+# leaves the previous sha and the marker in place for the next run to repair.
+echo "$HEAD_SHA" > "$DOCROOT/.deployed-sha"
+rm -f "$INCOMPLETE"
+
+echo "deployed ${HEAD_SHA:0:12} to $DOCROOT"
diff --git a/site/make-banner.py b/tools/site/make-banner.py
similarity index 98%
rename from site/make-banner.py
rename to tools/site/make-banner.py
index 24f2d064..905d6edb 100644
--- a/site/make-banner.py
+++ b/tools/site/make-banner.py
@@ -9,7 +9,7 @@
Needs fontTools (and cairosvg for the PNG), plus IBM Plex Sans and IBM Plex Mono
as .woff, e.g. from `npm pack @ibm/plex-sans @ibm/plex-mono` (fonts/complete/woff/).
- python3 site/make-banner.py site/codeframe-lockup-reverse.svg \
+ python3 tools/site/make-banner.py site/codeframe-lockup-reverse.svg \
/fonts/complete/woff /fonts/complete/woff \
codeframe_github_header.svg
python3 -c "import cairosvg; cairosvg.svg2png(url='codeframe_github_header.svg', \
diff --git a/site/make-og-image.sh b/tools/site/make-og-image.sh
similarity index 94%
rename from site/make-og-image.sh
rename to tools/site/make-og-image.sh
index 40d48dae..9f1c576a 100755
--- a/site/make-og-image.sh
+++ b/tools/site/make-og-image.sh
@@ -3,7 +3,8 @@
# (the reduced-motion still: the demo GIF's last frame). Needs ffmpeg + DejaVu fonts.
# Edit the COPY strings below when marketing copy changes, then re-run.
set -euo pipefail
-cd "$(dirname "$0")"
+# Lives in tools/site/; its inputs and outputs are in site/.
+cd "$(dirname "$0")/../../site"
# COPY: card strings
LINE1="Your coding agent can't quietly"