diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py
index ab1f6b5..9f07111 100644
--- a/api/tests/test_invariants.py
+++ b/api/tests/test_invariants.py
@@ -703,3 +703,28 @@ def test_i12_build_fails_when_commit_sha_is_empty():
and it happened on 2026-08-14."""
df = (ROOT / "Dockerfile").read_text()
assert 'test -n "${COMMIT_SHA}"' in df
+
+
+# --------------------------------------------------------------------------
+# G2.3 — branding lives in the repo, not only on one host's disk
+# --------------------------------------------------------------------------
+def test_g23_branding_is_version_controlled():
+ """It was applied directly to Veron's disk first, which is the config-drift
+ trap this project documents: the running system and the repo disagree, and
+ a rebuild silently reverts to stock Gitea."""
+ b = ROOT / "deploy" / "branding"
+ for f in ("apply.sh", "README.md", "templates/home.tmpl",
+ "templates/custom/header.tmpl"):
+ assert (b / f).exists(), f"missing {f}"
+
+
+def test_g23_brand_css_filename_is_versioned():
+ """Cloudflare caches /assets/* for 6h and no token here can purge, so a
+ fixed filename leaves stale bytes live for hours."""
+ import re as _re
+
+ hdr = (ROOT / "deploy" / "branding" / "templates" / "custom" / "header.tmpl").read_text()
+ m = _re.search(r"theme-windy\.v(\d+)\.css", hdr)
+ assert m, "brand CSS must carry a version in its FILENAME"
+ assert (ROOT / "deploy" / "branding" / "public" / "assets" / "css"
+ / f"theme-windy.v{m.group(1)}.css").exists()
diff --git a/deploy/branding/README.md b/deploy/branding/README.md
new file mode 100644
index 0000000..4da84cc
--- /dev/null
+++ b/deploy/branding/README.md
@@ -0,0 +1,28 @@
+# Windy Git branding (G2.3)
+
+Gitea's **supported** customisation surface: custom templates and public assets.
+No Gitea source is modified, so upstream upgrades keep arriving (D-2, I-1).
+
+ deploy/branding/ → $GITEA_CUSTOM (/data/gitea) in the container
+ → /srv/windygit/git/gitea/ on Veron 1
+
+Apply with `./deploy/branding/apply.sh`.
+
+## Two traps this cost, both worth knowing
+
+**1. `GITEA__DEFAULT__APP_NAME` does not work.** Gitea reads `APP_NAME` from the
+*top level* of `app.ini` (before any `[section]`). The env var instead created a
+literal `[default]` section, which Gitea ignores — and the installer's stock
+`APP_NAME` kept winning, so the site said "Gitea: Git with a cup of tea" while
+the config looked correct. Worse, the env-to-ini pass **appended** a second
+`APP_NAME` rather than replacing the first. `apply.sh` sets it at the top level
+directly.
+
+**2. Cloudflare caches `/assets/*` for 6 hours and no token in this stack can
+purge.** Editing a fixed filename leaves the old bytes live for hours — the new
+logo and CSS were both invisible while being correct at origin. **Version the
+filename** (`theme-windy.v2.css`) on every brand change; a `?query` is not
+enough because some caches ignore it.
+
+The nav logo is swapped in CSS rather than by overriding Gitea's navbar
+template — a one-line rule instead of a forked template that would drift.
diff --git a/deploy/branding/apply.sh b/deploy/branding/apply.sh
new file mode 100755
index 0000000..9994069
--- /dev/null
+++ b/deploy/branding/apply.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Apply Windy Git branding to the Gitea custom tree. Idempotent.
+set -euo pipefail
+CUSTOM="${GITEA_CUSTOM_HOST:-/srv/windygit/git/gitea}"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+
+sudo mkdir -p "$CUSTOM"/templates/custom "$CUSTOM"/public/assets/img "$CUSTOM"/public/assets/css
+sudo cp -r "$HERE"/templates/. "$CUSTOM"/templates/
+sudo cp -r "$HERE"/public/. "$CUSTOM"/public/
+sudo chown -R 1000:1000 "$CUSTOM"/templates "$CUSTOM"/public
+
+# APP_NAME must sit at the TOP LEVEL. See README trap #1.
+INI="$CUSTOM/conf/app.ini"
+sudo cp "$INI" "$INI.bak-brand-$(date +%s)"
+sudo python3 - "$INI" <<'PY'
+import sys
+p = sys.argv[1]
+lines = [l for l in open(p).read().splitlines()
+ if not l.strip().startswith(("APP_NAME", "APP_SLOGAN"))]
+lines.insert(0, "APP_NAME = Windy Git")
+open(p, "w").write("\n".join(lines) + "\n")
+PY
+echo "applied. restart gitea to pick it up."
diff --git a/deploy/branding/public/assets/css/theme-windy.v2.css b/deploy/branding/public/assets/css/theme-windy.v2.css
new file mode 100644
index 0000000..9f6670e
--- /dev/null
+++ b/deploy/branding/public/assets/css/theme-windy.v2.css
@@ -0,0 +1,27 @@
+/* Windy Git brand accent. Layered on top of Gitea's theme rather than
+ replacing it, so upstream theme fixes keep arriving. */
+:root {
+ --color-primary: #0ea5e9;
+ --color-primary-dark-1: #0284c7;
+ --color-primary-dark-2: #0369a1;
+ --color-primary-light-1: #38bdf8;
+ --color-primary-light-2: #7dd3fc;
+}
+.wg-hero { max-width: 780px; margin: 4rem auto 2rem; padding: 0 1.5rem; text-align: center; }
+.wg-hero h1 { font-size: 2.6rem; margin: 1.2rem 0 .4rem; letter-spacing: -.02em; }
+.wg-hero .wg-sub { font-size: 1.15rem; opacity: .78; margin-bottom: 2.2rem; }
+.wg-grid { display: grid; gap: 1.1rem; grid-template-columns: repeat(auto-fit,minmax(230px,1fr));
+ max-width: 900px; margin: 0 auto 2.5rem; padding: 0 1.5rem; text-align: left; }
+.wg-card { border: 1px solid var(--color-secondary); border-radius: 8px; padding: 1.1rem 1.2rem; }
+.wg-card h3 { margin: 0 0 .35rem; font-size: 1.02rem; }
+.wg-card p { margin: 0; opacity: .74; font-size: .9rem; line-height: 1.5; }
+.wg-cta { margin-bottom: 3rem; }
+
+/* Logo swap via CSS.
+ Cloudflare cached the stock /assets/img/logo.svg for 6h and no available API
+ token can purge. Pointing at a NEW filename sidesteps the stale object
+ without touching Gitea's own templates — the supported customisation surface,
+ per D-2 (membrane, not merge). */
+img[src$="/assets/img/logo.svg"] {
+ content: url("/assets/img/wg-mark.svg");
+}
diff --git a/deploy/branding/public/assets/img/logo.svg b/deploy/branding/public/assets/img/logo.svg
new file mode 100644
index 0000000..938f82e
--- /dev/null
+++ b/deploy/branding/public/assets/img/logo.svg
@@ -0,0 +1,9 @@
+
diff --git a/deploy/branding/public/assets/img/wg-mark.svg b/deploy/branding/public/assets/img/wg-mark.svg
new file mode 100644
index 0000000..938f82e
--- /dev/null
+++ b/deploy/branding/public/assets/img/wg-mark.svg
@@ -0,0 +1,9 @@
+
diff --git a/deploy/branding/templates/custom/header.tmpl b/deploy/branding/templates/custom/header.tmpl
new file mode 100644
index 0000000..0187801
--- /dev/null
+++ b/deploy/branding/templates/custom/header.tmpl
@@ -0,0 +1,5 @@
+{{/* Windy Git brand layer.
+ The filename carries a version: Cloudflare caches /assets/* for 6h with no
+ purge token available to this stack, so editing a fixed filename leaves the
+ old bytes live for hours. Bump the suffix on every brand change. */}}
+
diff --git a/deploy/branding/templates/home.tmpl b/deploy/branding/templates/home.tmpl
new file mode 100644
index 0000000..8800db9
--- /dev/null
+++ b/deploy/branding/templates/home.tmpl
@@ -0,0 +1,27 @@
+{{template "base/head" .}}
+
+
+
+
Windy Git
+
Your work, every version — and agents as citizens.