Written by: Jan Bachelor
Date: 2026-08-18
Part 1 of this series got Obsidian self-hosted with proper multi-device sync. This article covers the other half: Grav, the flat-file CMS that will actually serve the tutorials publicly. Grav stores everything as Markdown + YAML on disk, no database, no import/export step between "what I wrote" and "what's published" beyond restructuring the folder layout (covered in Part 3).
This article deploys Grav in Docker, wires it up publicly through Cloudflare and OPNsense, and walks through the first login and some basic customization, including two real mistakes I made along the way that are worth knowing about before you hit them yourself.
web1)8081 in this environment)There's no single official Grav Docker image from the Grav project itself. I went with websitemacherei/grav (nginx+PHP bundled, though see the correction below), actively maintained as of writing. Two things worth checking before you trust any third-party image's README:
docker-compose.yml pinned GRAV_VERSION=1.7.7, badly out of date. Current stable at the time of writing is 2.0.21. Check https://github.com/getgrav/grav/releases/latest yourself rather than copying a README's example version.services:
grav:
image: websitemacherei/grav:latest
container_name: grav
restart: always
environment:
- GRAV_VERSION=2.0.21
volumes:
- ./user:/var/www/html/user
ports:
- "127.0.0.1:8083:80"
logging:
driver: "journald"
options:
tag: "{{.Name}}"
Do not just bind-mount an empty host folder onto /var/www/html/user/ and start the container. Unlike most CMS images where the content folder is purely "your stuff," this image's user/ directory also ships the default theme and the config files Grav needs just to boot (system.yaml, etc.). Mounting an empty directory over it masks that default content entirely: Docker doesn't merge the image's files with your empty folder, it just hides them. The result is a startup error (Failed to open dir: plugins:// does not exist) and a 500 on every request.
The fix, extract the image's real default user/ content before you rely on the bind mount. Save the docker-compose.yml above into its own directory first:
mkdir -p /opt/grav && cd /opt/grav
# save the docker-compose.yml above into this directory
Then run the actual fix:
# 1. Run a throwaway instance with no volume mount, let it fully initialize
docker run -d --name grav-temp -e GRAV_VERSION=2.0.21 websitemacherei/grav:latest
sleep 20 # let the entrypoint finish installing Grav core + default user/ content
# 2. Extract its real user/ folder onto the host
mkdir -p /opt/grav/user
docker cp grav-temp:/var/www/html/user/. /opt/grav/user/
# 3. Match the container's actual www-data UID/GID (check yours - it isn't always 33:33)
docker exec grav-temp id www-data
chown -R 1000:33 /opt/grav/user # substitute whatever id reported
# 4. Clean up the throwaway container, then bring up the real one
docker rm -f grav-temp
docker compose up -d
📷 Screenshot: the Grav homepage rendering successfully after this fix (compare against the 500 error you'd see without it)
server {
listen 8081;
server_name tutorials.bachelor-tech.com;
include /etc/nginx/snippets/trusted-proxies.conf;
include /etc/nginx/snippets/custom-error-403.conf;
include /etc/nginx/blocklist.conf;
client_max_body_size 20M;
location / {
proxy_pass http://127.0.0.1:8083;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Save that to /etc/nginx/sites-available/grav.conf, symlink it into sites-enabled/, then test and reload:
sudo ln -s /etc/nginx/sites-available/grav.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Nothing exotic here compared to the internal services in Part 1, Grav serves regular page loads, no long-lived streaming connection to account for.
The key difference from Part 1: this attaches to the public-facing HAProxy frontend (the one already routing your other live sites through Cloudflare), not the internal LAN-VIP frontend.
web1_nginx object.web1_nginx. No special timeout tuning needed.tutorials.bachelor-tech.com → route to the backend pool.tutorials.bachelor-tech.com to its domain list and force a reissue, or public HTTPS will silently fail the same way the internal services did.tutorials.bachelor-tech.com at your existing origin/domain (I pointed mine at the same target bachelor-tech.com already uses).Install the Admin plugin from inside the container:
docker exec grav bin/gpm install admin -y
With no accounts yet configured, visiting /admin for the first time shows Grav's own account-registration form.
📷 Screenshot:
05-Deploy Grav - admin interface.png, the "Register Admin User" form on first visit to/admin
(missing image: 05-Deploy Grav - admin interface.png)
Fill in username, email, password, and full name, and log in. From the dashboard, Plugins in the left nav shows everything installed so far:
(missing image: 06-Grav-Admin-Plugins.png)
Worth noting: this image ships with Git Sync pre-installed as a plugin (visible in that list), relevant groundwork for Part 3, where the actual Obsidian → Grav publishing pipeline gets built.
An article that had been live for weeks suddenly started throwing this on every visit:
Twig\Error\SyntaxError: Unexpected token "operator" of value "." in
"@Page:/var/www/html/user/pages/14.auto-update-vaultwarden-in-docker/..."
at line 191.
Nothing had changed about that page's own content recently, and the actual text on the reported line was an ordinary if [ ... ] bash conditional, nowhere near anything that looked like the problem. The real trigger was a few lines earlier, a docker inspect command in a code example using Docker's own Go-template output format:
docker inspect -f '{{.State.Status}}' "$CONTAINER"
Grav was trying to parse that {{.State.Status}} as Twig, choking on the leading . (a valid Twig expression can't start with a property-access operator), and this is a plain fenced ```bash code block, nothing about it should ever be treated as a live template.
Every page this pipeline generates already sets process: { twig: false } in its own frontmatter (see frontmatter() in publish_inbox.py, Part 3). That turned out not to be enough on its own. Grav's core system/config/security.yaml explains why, in its own comments:
Twig in Content, gate for editor-authored Twig inside page content. Three layers, evaluated in order:
twig_content.process_enabled, the master gate. If false, page-content Twig is not parsed at all.process: { twig: true }in frontmatter is ignored. Default false on fresh 2.0 installs.
Default false on fresh installs, but the websitemacherei/grav image this series uses ships with it set to true. A per-page twig: false in frontmatter is only a request, not an override, of the site-wide gate, and with the gate open, Grav still parses page content as a Twig template to decide whether there's anything to render, and that parse step is what throws, before the per-page setting is ever consulted. A code example containing anything that looks like {{ ... }} (a Docker or Kubernetes Go-template format string, a Jinja2 snippet, a Vue or Angular template, a Mustache example, LaTeX-style macros) is one bad first line away from taking the whole page down.
Since this pipeline has no use for Twig-in-content anywhere, the right fix is closing the gate itself, not chasing every future code example that happens to contain a brace pair. Add this as a proper user/config/ override rather than editing the file inside the container, an image rebuild would silently revert an in-container edit and reopen the exact same bug:
# /opt/grav/user/config/security.yaml
twig_content:
process_enabled: false
sudo docker exec grav bin/grav clearcache
Worth doing this early, right after the first login in the section above, rather than waiting to find out about it the way this one got found.
Grav's site configuration is plain YAML under user/config/, editable directly, no admin panel required, same pattern as everything else in this project.
# /opt/grav/user/config/site.yaml
title: Bachelor-Tech Tutorials
author:
name: Jan Bachelor
email: [email protected]
metadata:
description: "Self-hosted IT tutorials: homelab infrastructure, high availability, and self-hosting guides."
Switching themes: I went with Learn2, Grav's own official documentation/tutorial-site theme (literally what Grav uses for their own docs), a better fit for a tutorial archive than the generic default:
docker exec grav bin/gpm install learn2 -y
Then edit user/config/system.yaml's pages.theme value to learn2, and clear the cache:
docker exec grav bin/grav clearcache
docker exec ... sh -c is fragileWriting a multi-line file via a nested heredoc through docker exec container sh -c "cat > file" <<EOF is easy to get wrong, the quoting has to survive multiple shell layers (your shell → SSH → the remote shell → sh -c), and a mistake silently produces an empty file rather than an obvious error. That's exactly what happened here on the first attempt, wiping site.yaml to zero bytes.
The fix: since the container's content folder is bind-mounted from the host, write directly to the host path instead of going through docker exec at all:
sudo tee /opt/grav/user/config/site.yaml > /dev/null <<'EOF'
title: Bachelor-Tech Tutorials
...
EOF
This creates one clean shell layer, no nested quoting, and it is immediately visible inside the container without a restart.
📷 Screenshot: the homepage with the Learn2 theme and updated title live
Every URL on the site came with an /en segment, tutorials.bachelor-tech.com/en/some-article, even though English is the only language this site has or will ever have. Grav's language support is on by default the moment system.yaml lists any languages.supported, en included, and one of the settings under it, include_default_lang, controls whether the default language's own URLs still carry its own prefix. It ships true.
# /opt/grav/user/config/system.yaml
languages:
supported:
- en
include_default_lang: false
docker exec grav bin/grav clearcache
No redirect rule needed for old /en/... links already out there: Grav handles that itself, requesting the old URL comes back a 302 straight to the new one, confirmed directly with curl -I. Worth setting include_default_lang: false from the very start on a single-language site, rather than discovering it after /en links have already been shared somewhere.
None of this showed up until I actually checked the site on a phone. Four separate problems, all fixed the same way: an override in the theme's own custom.css (already introduced above, loaded via assets.addCss('theme://css/custom.css', 100) in base.html.twig) rather than touching any vendored file directly.
Clicking a thumbnail to zoom uses Featherlight, a small jQuery lightbox this theme bundles (user/themes/learn2/js/featherlight.min.js). Its own default CSS ships a real 25px of padding plus a white background around the enlarged image:
.featherlight .featherlight-content {
padding: 25px 25px 0;
border-bottom: 25px solid transparent;
background: #fff; }
That's the thick frame. One thing worth knowing before you copy a fix for this: custom.css actually loads before featherlight.min.css in base.html.twig (check your own theme's load order rather than assuming), so a plain override of equal specificity loses the cascade and does nothing. The fix needs a little extra specificity to win regardless of load order:
body .featherlight .featherlight-content {
padding: 6px 6px 0;
border-bottom: 6px solid transparent;
}
@media only screen and (max-width: 1024px) {
body .featherlight .featherlight-content {
padding: 4px 4px 0;
border-bottom: 4px solid transparent;
}
}
By default Featherlight only closes when you click the dimmed background around the image, not the image itself, awkward on a phone where a large zoomed image can leave little to no background actually visible to tap. This one isn't a CSS fix, it's how Featherlight gets initialized, in user/themes/learn2/js/learn.js:
$('a[rel="lightbox"]').featherlight({
root: 'section#body',
closeOnClick: 'anywhere'
});
closeOnClick: 'anywhere' (Featherlight's own option, not something custom) makes any click inside the lightbox close it, image included.
This turned out not to be a lightbox problem at all. The theme's own viewport meta tag, in base.html.twig, had pinch-zoom turned off site-wide:
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, shrink-to-fit=no" />
maximum-scale=1, user-scalable=no disables it everywhere, not just inside the lightbox, a common but generally unwanted default. Removing those two directives fixes zooming site-wide:
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
Nucleus (this theme's base typography, css-compiled/nucleus.css) sets flat, desktop-sized headings with no responsive scaling at all: h1 at 3.25rem, h2 at 2.55rem, h3 at 2.15rem. A long article title at 52px doesn't fit a phone's width and wraps across several lines. The fix is a mobile breakpoint in custom.css, but it needs all three headings addressed together, shrinking only h1 leaves h2 visually larger than the title above it, which reads as broken hierarchy rather than a fix:
@media only screen and (max-width: 768px) {
h1 {
font-size: 2.1rem;
}
h2 {
font-size: 1.7rem;
}
h3 {
font-size: 1.4rem;
}
}
Keeping these roughly proportional to the original desktop sizes (h2 a bit smaller than h1, h3 smaller again) matters more than the exact numbers, adjust to taste, just don't shrink one level without checking the others still make sense next to it.
This one isn't mobile-specific, it shows up at every screen size, so it gets its own section rather than folding into the one above. Every multi-line code block on the site had its first line sitting a few pixels further right than every line after it, easy to mistake for a stray space typed into the source, and just as easy to miss completely unless you're looking closely. It isn't a typo. It's a real bug in Learn2's own compiled theme.css.
The theme has a generic rule meant for short inline code spans in prose, the kind you get from a single backtick like `git clone`:
code {
padding: .2rem .4rem;
}
and a separate rule that overrides color, background, and font size for code sitting inside a full <pre> block:
pre code {
color: #237794;
background: inherit;
font-size: 1rem;
}
That second rule never resets padding back to 0, so the .4rem (about 6.4px) from the first rule leaks straight through onto every fenced code block on the site. Here's the part that makes it look like only the first line is affected: <code> stays display: inline (nothing sets it to block), and its content includes real line breaks. CSS only ever applies an inline element's left and right padding at the very start of its first line and the end of its last line, never at an internal line break. So every line after the first sits flush against the box, and only line 1 gets pushed right by that leftover 6.4px, indistinguishable at a glance from a stray leading space.
The fix is one override, added to custom.css alongside everything else in this article:
pre code {
padding: 0;
}
Scoped specifically to code inside <pre>, so ordinary inline code spans elsewhere in your prose keep their own padding and background pill.
Worth knowing if you ever go chasing something like this yourself: I only found the real cause by measuring pixel positions directly with the browser's own Range API rather than trusting a screenshot or a plain visual check. Both of those can look clean while a real, if subtle, layout bug is still sitting there.
A "Download Markdown" link and a "Save as PDF" button on every article, next to the title. Two small pieces, and one gotcha each.
Every page's folder under user/pages/ already holds a copy of everything it needs, images included (see Part 3 for how those get copied in). Grav happily serves any of those files directly if you link to them, the same way an embedded screenshot's URL just points straight at the file sitting next to default.md. So the "Download Markdown" link just needs a plain markdown copy of the article sitting in that same folder, no plugin, no new route.
The gotcha: Grav refuses to serve a bare .md file this way. Confirmed directly, the exact same bytes come back 200 as .txt and 404 as .md, almost certainly deliberate since .md is Grav's own content-file extension. The fix doesn't need a workaround on Grav's side at all, it needs one on the link itself: write the file as .txt, but set the HTML download attribute to the filename you actually want:
<a href="article-slug.txt" download="article-slug.md">Download Markdown</a>
download only controls what the browser saves the file as locally, it has nothing to do with the URL it was actually served from. The visitor ends up with a real .md file on disk either way.
No new dependency needed for this one either, just the browser's own print dialog:
<button type="button" onclick="window.print()">Save as PDF</button>
Left alone, a printed page would include the sidebar, header, footer, and every copy-to-clipboard button, none of which belong in a saved article. A @media print block in custom.css strips all of that and lets the content use the full page width instead of leaving room for the (now hidden) sidebar:
@media print {
#sidebar, #header-wrapper, #top-bar, #navigation, #body .nav,
.copy-to-clipboard, .page-downloads, #footer, .searchbox, #overlay {
display: none !important;
}
#body {
margin-left: 0 !important;
}
#body .padding {
padding: 0 !important;
}
a[href]:after {
content: "" !important;
}
}
That last rule matters more than it looks: browsers print the target URL after every link by default, which turns unreadable fast on a page as link-heavy as a tutorial.
Same .button-secondary class on both, same computed padding, same computed height (42.5px, measured directly, not eyeballed), and the "Save as PDF" button still looked a couple of pixels taller than the link next to it. Not a height difference at all, it turned out, a baseline one: <a> defaults to vertical-align: baseline, <button> defaults to vertical-align: middle, ordinary browser defaults for an inline text element versus a form control, nothing either of us set. That shifts the button 1.5px lower on the line even though its own box is identical in size, so its bottom edge sticks out further, which reads as "taller."
.page-downloads a,
.page-downloads button {
vertical-align: middle;
}
Scoped to just these two elements rather than the whole .button-secondary class, so nothing else on the site shifts.
The original homepage was a personal "About Jan" bio page, and the only real way to browse the site's tutorials was a flat "All Articles" list plus whatever fit in Learn2's own sidebar tree. That was fine for a handful of articles. It stopped being fine once a batch of 24 imported articles was about to land on top of the existing ones, pushing the total well past 30, all in one flat list with no way to browse by topic.
The fix, in three parts: the homepage became a categorized card grid, the old "All Articles" got renamed "Search Articles" and moved to its own /search route (still the same flat, searchable listing, just no longer the only way in), and the old "About Jan" bio content moved out to its own /about page since the homepage needed the space for the article grid instead.
Grav has a real taxonomy system built into core, category and tag are two of its defaults, no plugin required. Rather than build a separate tagging UI, publish_inbox.py (Part 3) reads an optional YAML frontmatter block at the very top of each Obsidian note, the same block Obsidian's own Properties panel writes natively:
---
category: OPNSense
tags: [firewall, vlan]
---
# The actual article title
and turns it into that page's Grav taxonomy: frontmatter. A brand new category needs no template change anywhere, tagging the next article with it is enough, the homepage picks it up on the next publish.
A new blog.html.twig, set as 01.home's template via template: blog in its frontmatter (Home.md at the vault root still only supplies the title and intro text above the grid, same relationship it always had to that page). It walks the same top-level page tree the old "All Articles" template does, builds a category -> count map from each page's taxonomy.category, and renders a filter button per category plus a card per article:
{% set categories = {} %}
{% for entry in articles %}
{% set cat = entry.taxonomy.category ? entry.taxonomy.category[0] : 'Uncategorized' %}
{% set categories = categories|merge({(cat): (categories[cat]|default(0)) + 1}) %}
{% endfor %}
Worth knowing if you haven't hit this before: Twig for-loops normally scope their own variables to each iteration, categories here only survives past the loop because it's reassigning a variable that already existed outside it, a documented Twig behavior, not a bug.
Filtering itself is a small vanilla-JS handler (initBlogFilter() in custom.js, the same file the theme's other homepage widgets already live in, still a no-op on any page without the matching markup): clicking a category button just toggles display: none on the cards that don't match, no page reload, no real taxonomy-URL routing. Simple was the goal here, not a general-purpose filtering framework.
📷 Screenshot: the new homepage, card grid with category filter buttons across the top
A single-part "series" folder (see Part 3) used to always get an index page wrapping its one real part, which meant the blog grid's card for it showed a misleading "1-part series" badge. Since fixed properly at the source (Part 3 covers the actual collapse logic), but worth knowing the workaround existed first, in case you hit the same symptom before getting to that fix: gate the badge on entry.children.count > 1, not > 0.
Grav ships with Git Sync pre-installed (spotted back in Section 5's plugin list). It pulls a folder from a git remote into user/pages, optionally pushes local edits back, and can be triggered by a webhook instead of a schedule. On paper it's exactly the trigger mechanism Part 3 needs. In practice, getting it working reliably took a genuinely long debugging session, almost entirely because its own admin UI corrupts its own saved settings. Worth reading this section in full before you touch it yourself.
Plugins → Git Sync → the wizard walks through four steps: hosting service, repository, webhook, and what to synchronize.
(missing image: 07-Grav-Admin-Plugins-Git-Sync.png)
Step 2 asks for the repository HTTPS URL and branch:
(missing image: 08-Grav-Git-Sync-Gitea-URL.png)
Git Sync generates a random webhook path (/_git-sync-<hash>) and, if enabled, a secret. Both need to be copied into a new webhook on the Gitea side: repo → Settings → Webhooks → Add Webhook → Gitea (the native format, not Slack/Discord/etc., which are chat-notification formats meant for a completely different kind of consumer; also not "Gogs," which is a close but non-native compatibility format).
(missing image: 09-Gitea-Create-Webhook-for-Grav.png)
(missing image: 10-Gitea-Create-Webhook-for-Grav-completed.png)
Notice the URL in that second screenshot: http://tutorials.bachelor-tech.com/_git-sync-419a1d3e3152, plain HTTP, and a webhook hash that turned out to already be stale by the time it was tested. Both of those come back up below.
None of what follows was user error, every one of these was a real bug in how Git Sync's admin form persists its own configuration. The pattern that emerged: treat the admin UI as write-only and unreliable, and verify every save by reading the actual config file (user/config/plugins/git-sync.yaml) afterward.
Use HTTPS for the webhook, not HTTP. A plain-HTTP request to a Cloudflare-proxied hostname hits a 520 at Cloudflare's edge, there's no real plain-HTTP path to the origin, everything terminates TLS at 443.
The webhook path drifts. Git Sync regenerates /_git-sync-<hash> when the field is empty. Whatever URL got copied into Gitea can silently go stale if the settings form gets saved again later. Symptom: a clean 404 from Grav's own routing (not an error page, an actual "this page doesn't exist," proof the request is reaching Grav fine, just hitting no matching route). Fix: re-copy the current path from Grav's settings, don't trust what you copied earlier.
No internal route existed for the public hostname. Gitea, sitting on the internal LAN, tried reaching the public tutorials.bachelor-tech.com, which only had a public DNS record, so the request went out through Cloudflare and back in, a classic hairpin NAT situation many firewalls don't handle cleanly. Symptom in Gitea's delivery log: a "Response: 0" badge, no HTTP response at all, a network-level failure, not an application error. Fix: add an internal Unbound override for the hostname pointing at the LAN-VIP, and attach the same backend pool to the internal HAProxy frontend as well as the public one, same split-horizon pattern used throughout Part 1.
The "Web Hook Secret: Enabled/Disabled" toggle disables the entire webhook route, not just signature verification. Switching it to "Disabled" to simplify testing produced a 404, the route stops existing, it doesn't just skip the signature check. This cost real debugging time chasing the wrong theory before the config file made it obvious.
Disabling that toggle also silently nulled the stored secret, a side effect of the same save, on a field that wasn't touched.
A stray leading space gets prepended to the repository URL on save. Reproducible, not a one-off, happened twice. Symptom: fatal: protocol ' https' is not supported (look closely, there's a space before https). Only fixable by editing the config file directly; retyping the same value into the form and saving reintroduces it.
The webhook path can lose its leading slash. Seen once, after re-running the full wizard rather than editing a single field, _git-sync-<hash> instead of /_git-sync-<hash>, breaking route matching, another 404.
docker exec grav cat /var/www/html/user/config/plugins/git-sync.yaml
This is the actual source of truth, the admin panel's displayed state (placeholder text like "Your password is securely stored") doesn't reliably reflect what's really saved, and testing directly against a webhook path or secret you think is current wastes time when it's already drifted.
Gitea's own signed webhook delivery uses the legacy GitHub-style X-Hub-Signature header, SHA-1, not SHA-256, and not X-Gitea-Signature (Git Sync's PHP source only checks $_SERVER['HTTP_X_HUB_SIGNATURE'], confirmed by reading git-sync.php directly rather than guessing). A hand-crafted, correctly-signed test request is faster than repeatedly clicking "Test Delivery" in Gitea's UI:
SECRET="your-webhook-secret"
PAYLOAD='{"ref":"refs/heads/main","commits":[{"id":"...","message":"test"}]}'
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha1 -hmac "$SECRET" | sed 's/^.* //')
curl -s -X POST "https://tutorials.bachelor-tech.com/_git-sync-<current-hash>" \
-H "Content-Type: application/json" \
-H "X-Gitea-Event: push" \
-H "X-Hub-Signature: sha1=$SIG" \
-d "$PAYLOAD"
A correct setup returns {"status":"success","message":"GitSync completed the synchronization"}. Worth also testing with no signature header at all, and with a deliberately wrong one, both should come back 401 Unauthorized request. If they don't, the secret isn't actually being enforced despite being configured, which is a real security gap worth catching before relying on it.
Yes, they protect different things. The personal access token (Section 2's kind of credential) gates whether Grav can read your private Gitea repo at all, that's the what gets synced question. The webhook secret gates who's allowed to trigger a sync in the first place. Without it, anyone who discovers the webhook URL (which, unlike a header value, ends up in access logs, proxy logs, and browser history, it's not really secret, just obscure) can force repeated git pull operations on your server for free. Not a data-exposure risk given the token still gates actual content access, but a real, if modest, resource-abuse vector, and free to close off, so worth keeping enabled.
After everything above, Git Sync went on to delete the entire live pages/ directory, twice, including Grav's own default pages, causing full site outages. Both times happened around its "reset"/disable actions touching content well outside the _inbox folder it was supposed to be scoped to. The most likely cause: it appears to run something equivalent to git clean -fdx (force-remove untracked and gitignored files) as part of its own housekeeping, and since pages/, accounts/, config/, etc. all legitimately live gitignored inside the same user/ directory Git Sync treats as its git working tree, they were fair game for it to erase.
Checked the official Grav plugin directory for an alternative, and nothing else does this. Git Sync was fully uninstalled and replaced with a plain, isolated approach: a git clone kept completely outside Grav's user/ tree, pulled on a schedule, with a small Python script converting its content into pages. No plugin has write access to live content anymore. The full build (deploy keys, the timer, the conversion script and the bugs found along the way) is in Part 3.
A bug report was filed upstream: github.com/trilbymedia/grav-plugin-git-sync.
websitemacherei/grav:latest, Apache 2.4.67 / PHP 8.3.31