Skip to content

MajorDocs Setup & Publishing Pipeline

A walkthrough of how MajorDocs is built, configured, and kept in sync — from writing in Obsidian to publishing via MkDocs.

Architecture Overview

Component Role
Obsidian Writing and editing markdown notes
Forgejo Self-hosted git remote at git.majorshouse.com
Webhook Small Flask app, non-root, 127.0.0.1:9091 only; verifies the HMAC signature and drops a trigger file
Build majwiki-build.path → majwiki-build.service (root oneshot): git pull --ff-only, one-off mkdocs build in Docker (no network), atomic symlink swap
Caddy Serves the built site as static files at docs.majorshouse.com; the old notes.majorshouse.com address answers with a permanent (301) redirect to the same page, except /webhook, which both addresses forward to the local webhook

There is no long-running wiki container. Until 2026-11-02 the site was served by mkdocs serve, MkDocs' live-preview server, which MkDocs says is not meant for production. See "Problems Encountered" below.

File Layout

/opt/majwiki/              # Git repo (content) — also holds mkdocs.yml
/opt/majwiki-config/       # Outside git
  compose.yml              # build-only profile, network_mode: none
  Dockerfile               # FROM squidfunk/mkdocs-material:<pinned>
  webhook.py               # signature check + trigger only
/srv/majwiki/
  builds/<timestamp>-<rev> # last 3 kept
  current -> builds/...    # what Caddy serves (SELinux: httpd_sys_content_t)
/etc/majwiki-webhook/secret  # root 600, passed to the webhook via LoadCredential=

Publishing Flow

  1. Write or edit a note in Obsidian
  2. Obsidian syncs the vault to Gitea via git (using Obsidian Git plugin)
  3. Forgejo POSTs to https://notes.majorshouse.com/webhook; Caddy forwards it to 127.0.0.1:9091
  4. The webhook (user majwiki-hook, no Docker access) verifies the HMAC signature and touches a trigger file — nothing else
  5. majwiki-build.path sees the trigger and starts majwiki-build.service: pull, build into a new builds/ directory, relabel, swap current, prune old builds
  6. Caddy serves the new files immediately — about 10–14 s from push to live

Key Configuration

mkdocs.yml (in the content repo, /opt/majwiki/mkdocs.yml)

mkdocs.yml lives in the content repo and is mounted read-only into the build container. The critical setting is docs_dir: docs — this tells MkDocs to look for markdown files in the docs/ subdirectory of its working directory, which maps to /opt/majwiki/ via the Docker volume mount.

compose.yml volume mounts

volumes:
  - /opt/majwiki/mkdocs.yml:/docs/mkdocs.yml:ro
  - /opt/majwiki:/docs/docs:ro
  - /srv/majwiki:/out

Each section needs an index.md to make the top nav tabs clickable. Without it, MkDocs renders the tab as a non-linked dropdown header. These index files must live in the git repo root, not in any Obsidian sync subfolder.

Index files are generated — SUMMARY.md is not

A .githooks/pre-commit hook does two things automatically on every commit that touches an article (any *.md that isn't README.md, index.md, SUMMARY.md, MajorWiki-Deploy-Status.md, or a */index.md):

  1. Regenerates the indexes. It runs .githooks/generate_index.py and stages the result — the root index.md and every domain index (0*/index.md). Article listings are sorted and the "recent entries" block on the home page is rebuilt from scratch.
  2. Refuses the commit if a new article isn't in SUMMARY.md. This check fires only for articles being added or renamed (--diff-filter=AR), and prints the offending paths.

The practical rule when adding an article:

File Who updates it
05-troubleshooting/index.md (and any 0*/index.md) The hook — do not hand-edit, your entry gets overwritten
Root index.md The hook
SUMMARY.md You — manually, or the commit is rejected

Hand-editing a domain index is harmless but wasted effort: the generator overwrites the whole listing between <!-- BEGIN GENERATED INDEX --> markers. Only SUMMARY.md needs a human.

git commit --no-verify bypasses both behaviors. Avoid it — skipping the hook lands an article that is unreachable from the nav and absent from the indexes, which is invisible until someone goes looking for the page.

The hook lives in .githooks/, which git does not use by default. It works only if the repo is configured with git config core.hooksPath .githooks. On a fresh clone, set that before the first commit or neither behavior runs.

Problems Encountered & Solutions

index.md files not found by MkDocs

The git repo at /opt/majwiki/ contains a docs/ subdirectory used by Obsidian sync. MkDocs was seeing index files at docs/01-linux/index.md instead of 01-linux/index.md. Fix: removed the stale copies from /opt/majwiki/docs/ and kept the authoritative ones in the repo root.

mkdocs.yml wiped by git reset

Running git reset --hard origin/main on /opt/majwiki/ deleted mkdocs.yml, compose.yml, and webhook.py since they weren't tracked in the repo. Fix: moved all config files to /opt/majwiki-config/ outside the git repo.

docs_dir: . not allowed

MkDocs refuses docs_dir: . when mkdocs.yml is in the same directory. Fix: mount mkdocs.yml separately at /docs/mkdocs.yml and the content at /docs/docs/ via Docker volumes, then set docs_dir: docs.

The original index.md used Obsidian-style [[wikilinks]] which MkDocs renders as plain text. Fix: rewrote index.md using standard Markdown links.

502 from a bad SUMMARY.md (2026-03-11)

The site returned 502 Bad Gateway. The majwiki container was in a restart loop because SUMMARY.md used invalid globbing syntax and the literate-nav plugin failed with a LiterateNavParseError. Fix: rewrote SUMMARY.md with explicit links. The same outage left majwiki-webhook.service broken; it was repaired by killing an orphaned webhook process and correcting Caddy's reverse-proxy routing for /webhook.

Running mkdocs serve in production

The original setup served the public site with mkdocs serve --dev-addr=0.0.0.0:8092 — the live-preview server — and rebuilt by restarting the container. It worked for months, but it is a development tool: it listens on every address, announces itself as a Python dev server, and the rebuild-by-restart meant the webhook (running as root) needed Docker access. It went unnoticed because a CDN proxy in front of the site hid the server banner; removing the proxy exposed it. Fix: mkdocs build to static files, serve them with the reverse proxy you already run, and let the webhook do nothing but verify and signal.

Managing the Services

# Rebuild now (same path a push takes)
systemctl start majwiki-build.service
journalctl -u majwiki-build.service -n 50

# Webhook
systemctl status majwiki-webhook

# Rebuild the image (e.g. after a CVE in the base)
cd /opt/majwiki-config && docker compose --profile build build --no-cache wiki

Never print the webhook secret. To give it to Forgejo, fetch it in your own terminal (ssh majorlab sudo cat /etc/majwiki-webhook/secret), not in a shared session or chat.


Updated 2026-03-13: Obsidian Git plugin dropped. See canonical workflow below.

Canonical Publishing Workflow

The Obsidian Git plugin was evaluated but dropped — too convoluted for a simple push. Manual git from the terminal is the canonical workflow.

cd ~/MajorVault  # MajorRig: cd /mnt/c/Users/majorlinux/Documents/MajorVault
git add 30-Areas/MajorWiki/
git commit -m "wiki: describe your changes"
git push

From there: Forgejo receives the push → fires webhook → majorlab pulls → MkDocs rebuilds → docs.majorshouse.com updates.