diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
new file mode 100644
index 0000000..56557ac
--- /dev/null
+++ b/.github/workflows/deploy-site.yml
@@ -0,0 +1,143 @@
+# Deploy the documentation site to stratara.tech.
+#
+# Builds the DocFX site exactly as docs.yml did and copies it by rsync to the document root of the
+# stratara.tech vhost on our own server. Runs on merges to main that touch the site, and can be
+# started by hand. Never on a pull request: the job holds a deploy key, and a fork's run gets no
+# secrets — which is exactly the protection that makes a public repository safe to run this in.
+#
+# The `production` environment carries a required reviewer, so the deploy waits for a human. The key
+# can write the document root of a public site; a bad merge or a compromised action would otherwise
+# ship unattended. Same reasoning as the nuget-org gate in release.yml.
+name: Deploy site
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - docs/**
+ - llms.txt
+ - llms-full.txt
+ - .github/workflows/deploy-site.yml
+ workflow_dispatch:
+
+# Two deploys must not interleave: rsync --delete from two runs at once leaves neither tree intact.
+concurrency:
+ group: deploy-site
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+ name: Build and deploy
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ # The approval gate. Nothing below runs until a reviewer confirms.
+ environment:
+ name: production
+ url: https://stratara.tech
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Install DocFX
+ run: dotnet tool install -g docfx
+
+ # The same two commands docs.yml ran, so the move changes where the site lives and nothing
+ # about how it is built.
+ - name: Build DocFX site (warningsAsErrors)
+ run: |
+ docfx metadata docs/docfx.json
+ docfx build docs/docfx.json --warningsAsErrors
+
+ # llms.txt and llms-full.txt live at the repository root and are not DocFX resources; they are
+ # served from the site root so that the trigger paths above mean something.
+ - name: Add llms.txt to the site root
+ run: cp llms.txt llms-full.txt docs/_site/
+
+ # Plesk's nginx proxies to Apache, so cache headers are an .htaccess file rather than server
+ # configuration. Only the fingerprinted files under public/ are immutable: DocFX also writes
+ # unfingerprinted main.css, main.js and docfx.min.* there, and those must revalidate or a
+ # template change is never seen. Everything the browser navigates to or the search reads
+ # (HTML, JSON, TXT, XML, YML) revalidates on every request.
+ - name: Add cache headers
+ run: |
+ set -euo pipefail
+ cat > docs/_site/.htaccess <<'HTACCESS'
+
+
+ Header set Cache-Control "no-cache"
+
+
+ HTACCESS
+ cat > docs/_site/public/.htaccess <<'HTACCESS'
+
+
+ Header set Cache-Control "public, max-age=31536000, immutable"
+
+
+ HTACCESS
+
+ - name: Prepare the deploy key
+ env:
+ DEPLOY_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_B64 }}
+ DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
+ run: |
+ set -euo pipefail
+ mkdir -p ~/.ssh
+ echo "$DEPLOY_KEY_B64" | base64 -d > ~/.ssh/stratara_deploy
+ chmod 600 ~/.ssh/stratara_deploy
+ echo "$DEPLOY_KNOWN_HOSTS" >> ~/.ssh/known_hosts
+
+ # --delete makes the deployed tree equal the built tree, so a renamed page does not linger.
+ # .well-known is excluded because Plesk keeps its ACME challenge directory there; deleting it
+ # on every deploy is a bet on a Plesk routing detail that costs nothing to avoid.
+ - name: Deploy
+ env:
+ DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
+ DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
+ run: |
+ set -euo pipefail
+ rsync -az --delete --exclude='.well-known' -e 'ssh -i ~/.ssh/stratara_deploy' \
+ docs/_site/ "${DEPLOY_USER}@${DEPLOY_HOST}:httpdocs/"
+
+ # A deploy that reports success while the site is broken is worse than one that fails, so this
+ # asks the live domain rather than trusting the upload.
+ - name: Check what is actually served
+ run: |
+ set -euo pipefail
+ # Here-strings, not a pipe into grep: with pipefail, `grep -q` exits on the first match, a
+ # curl still writing the rest of a large page gets a broken pipe, and the check fails on a
+ # page that was correct.
+ contains() {
+ local body
+ body=$(curl -fsS "$1")
+ grep -qi -- "$2" <<<"$body" || { echo "$3"; exit 1; }
+ }
+ status_is() {
+ local code
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$1")
+ [ "$code" = "$2" ] || { echo "$3 — got $code"; exit 1; }
+ }
+ header_has() {
+ local headers
+ headers=$(curl -fsSI "$1")
+ grep -qi -- "$2" <<<"$headers" || { echo "$3"; exit 1; }
+ }
+ # The apex itself must answer, not redirect: a 301 to www would mean Plesk's preferred
+ # domain still points the other way, and every published link would take a detour.
+ status_is https://stratara.tech/ 200 "the apex does not answer with the site itself"
+ contains https://stratara.tech/ "Start small, keep the receipts" "the landing page is not the documentation"
+ status_is https://stratara.tech/concepts/why-event-sourcing.html 200 "a deep page is missing"
+ status_is https://stratara.tech/legal/imprint.html 200 "the imprint is not served"
+ status_is https://stratara.tech/legal/privacy.html 200 "the privacy policy is not served"
+ status_is https://stratara.tech/assets/badges/nuget.svg 200 "the badges are not served from our own host"
+ status_is https://stratara.tech/llms.txt 200 "llms.txt is not served"
+ header_has https://stratara.tech/ 'cache-control: no-cache' "the no-cache header is missing on the landing page"
+ echo "stratara.tech answers, deep links resolve, and the pages that must not go stale revalidate."
diff --git a/openspec/changes/serve-the-docs-from-our-own-server/proposal.md b/openspec/changes/serve-the-docs-from-our-own-server/proposal.md
index 2394df8..72f0c3a 100644
--- a/openspec/changes/serve-the-docs-from-our-own-server/proposal.md
+++ b/openspec/changes/serve-the-docs-from-our-own-server/proposal.md
@@ -1,6 +1,6 @@
# Serve the documentation site from our own server
-> **Status:** proposed
+> **Status:** approved — 2026-09-04, owner
## Why
diff --git a/openspec/changes/serve-the-docs-from-our-own-server/tasks.md b/openspec/changes/serve-the-docs-from-our-own-server/tasks.md
index ec7056a..a794b61 100644
--- a/openspec/changes/serve-the-docs-from-our-own-server/tasks.md
+++ b/openspec/changes/serve-the-docs-from-our-own-server/tasks.md
@@ -8,33 +8,33 @@ Umami. Each one names how to prove it worked.
## 1. Open the door on the server
-- [ ] 1.1 **owner** In Plesk, enable SSH access for the `stratara.tech` subscription's system user
+- [x] 1.1 **owner** In Plesk, enable SSH access for the `stratara.tech` subscription's system user
`stratara.tech_d4hk78khxi` (shell `/bin/bash`, as `loomweaver.dev_1kv2wmu3zbc` already has).
Proof: `ssh stratara-server 'grep stratara.tech_d4hk78khxi /etc/passwd'` no longer ends in
`/bin/false`.
-- [ ] 1.2 Generate an ed25519 deploy key pair named for this purpose. The private half must never
+- [x] 1.2 Generate an ed25519 deploy key pair named for this purpose. The private half must never
be written to the repository or to a file that survives the session.
-- [ ] 1.3 Install the public half in `/var/www/vhosts/stratara.tech/.ssh/authorized_keys`, owned by
+- [x] 1.3 Install the public half in `/var/www/vhosts/stratara.tech/.ssh/authorized_keys`, owned by
the vhost user, `700` on `.ssh` and `600` on the file — the layout
`/var/www/vhosts/loomweaver.dev/.ssh/` already has. Proof: `ssh -i @ true`
succeeds.
-- [ ] 1.4 **owner** Register the repository secrets `DEPLOY_SSH_KEY_B64` (base64 of the private
+- [x] 1.4 **owner** Register the repository secrets `DEPLOY_SSH_KEY_B64` (base64 of the private
half), `DEPLOY_USER`, `DEPLOY_HOST`, and the variable `DEPLOY_KNOWN_HOSTS`
(`ssh-keyscan` output for the host). Proof: `gh secret list` and `gh variable list` show all
four.
-- [ ] 1.5 Prove the path end to end before any workflow exists: `rsync -n -az` a throwaway file to
+- [x] 1.5 Prove the path end to end before any workflow exists: `rsync -n -az` a throwaway file to
`@:httpdocs/` and confirm it is listed but, being a dry run, not written.
## 2. The deploy workflow
-- [ ] 2.1 Add `.github/workflows/deploy-site.yml`, modelled on `loomweaver.dev`'s `deploy.yml`:
+- [x] 2.1 Add `.github/workflows/deploy-site.yml`, modelled on `loomweaver.dev`'s `deploy.yml`:
trigger on push to `main` filtered to `docs/**`, `llms.txt`, `llms-full.txt` and the workflow
itself, plus `workflow_dispatch`; **never** on `pull_request`, so a fork's run gets no
secrets; `concurrency` group so two deploys cannot interleave; `permissions: contents: read`.
-- [ ] 2.2 **owner** Create the `production` environment with a required reviewer, and reference it
+- [x] 2.2 **owner** Create the `production` environment with a required reviewer, and reference it
from the job. Proof: the first run stops and waits, exactly as `release.yml` does at
`nuget-org`.
-- [ ] 2.3 Build step — the same two commands `docs.yml` runs today, so the build is unchanged by
+- [x] 2.3 Build step — the same two commands `docs.yml` runs today, so the build is unchanged by
this arc:
```bash
@@ -42,10 +42,10 @@ Umami. Each one names how to prove it worked.
docfx build docs/docfx.json --warningsAsErrors
```
-- [ ] 2.4 Write `docs/_site/.htaccess` with cache headers before uploading: HTML, JSON, TXT and XML
+- [x] 2.4 Write `docs/_site/.htaccess` with cache headers before uploading: HTML, JSON, TXT and XML
`no-cache`; hashed assets under `public/` `immutable, max-age=31536000`. `.htaccess` is the
right mechanism because Plesk's nginx proxies to Apache (design.md → *Context*).
-- [ ] 2.5 Deploy step. The exclusion is decision 2 — without it the ACME challenge directory is
+- [x] 2.5 Deploy step. The exclusion is decision 2 — without it the ACME challenge directory is
deleted on every deploy:
```bash
@@ -53,7 +53,7 @@ Umami. Each one names how to prove it worked.
docs/_site/ "${DEPLOY_USER}@${DEPLOY_HOST}:httpdocs/"
```
-- [ ] 2.6 Post-deploy check that asks the live domain and fails the run otherwise: `https://stratara.tech/`
+- [x] 2.6 Post-deploy check that asks the live domain and fails the run otherwise: `https://stratara.tech/`
contains the landing hero string; a deep page such as `/concepts/why-event-sourcing.html`
returns 200; `/legal/imprint.html` returns 200; `/assets/badges/nuget.svg` returns 200 and is
served from our own host.