Written by: Jan Bachelor
Date: 2026-08-27
Part 1 got Obsidian self-hosted with sync across devices. Part 2 got Grav running publicly with some customizations. This article (Part 3) covers how they sync together using an isolated git clone, a small Python conversion script, and a systemd timer, publishing real Obsidian notes to Grav automatically, with no plugin holding write access to live content.
It also covers a second thing that turned out to need solving along the way: getting screenshots pasted straight from Obsidian to stop showing up as multi-megabyte, randomly named files, and getting that whole publish step down to a single deliberate action, without leaning on Obsidian Git's own commit button at all in the end.
The actual chain when you press the hotkey:
main.js on the desktop, which computes the repo path (<vault>/Published, the REPO_SUBPATH constant) and runs pythonw.exe publish_vault.py with that as the working directory.publish_vault.py kicks in, sitting directly at the top of Published/ (not inside any article's subfolder), imports optimize_images.py from that same top-level location, runs the resize/rename pass, then does git add, commit, and push itself.publish_inbox.py on the server (web1 in my case) notices new content, pulls it and converts it to a Grav page. What we will do below is to cover the pipeline below step-by-step.
While other commercial tools like Github/Gitlab allow you to create a personal key that is scoped to a repository, this restriction is (so far) not possible with Gitea and Forgejo. However, there is a different option to use with these - deploy keys!
NOTE: Gitea v1.23 supports them now!
The first working version of this pipeline used a single Gitea Personal Access Token, scoped to the whole account rather than one repository, for both directions: the desktop pushed with it, and the web host pulled with it. That works, but it means one leaked credential (in a config file, a shell history, a screenshot) exposes every repository on the account, not just this one, and it can't be revoked for one consumer without breaking the other.
The fix: a deploy key per repository per direction, each one scoped to obsidian_grav_tutorials alone and nothing else.
desktop-obsidian-grav-push, write access, lives on the desktop, used only to push.web1-grav-pull-readonly, read-only, lives on web1, used only to pull. Read-only matters here: this side of the pipeline never needs to write back to Gitea, so the key simply can't, even if something on that host were compromised.git remote set-url origin "git@<gitea-host>:jan/obsidian_grav_tutorials.git"
git config core.sshCommand "ssh -i C:/Users/<you>/.ssh/desktop-obsidian-grav-push -o StrictHostKeyChecking=accept-new"
Two gotchas worth knowing before you hit them:
git@ at the hostname just times out. The IP only needs to resolve to something on the same LAN (or reachable over your VPN) as the desktop.core.sshCommand is handed off to Git for Windows' bundled sh.exe, which treats a backslash as an escape character, not a path separator. A path typed as C:\Users\jan\.ssh\key silently comes out the other side as C:Usersjan.sshkey, ssh can't find the file, and it quietly falls back to a password prompt instead of failing loudly. Forward slashes sidestep the problem entirely.A quick way to confirm it's actually using the key rather than falling back: git fetch should return cleanly with no password prompt at all.
git -C /opt/grav_source/repo remote set-url origin "git@<gitea-host>:jan/obsidian_grav_tutorials.git"
git -C /opt/grav_source/repo config core.sshCommand "ssh -i /opt/grav_source/deploy_key -o StrictHostKeyChecking=accept-new"
No backslash problem on Linux, same direct-IP requirement. Once both sides were confirmed working (a real git push from the desktop, a real git pull on web1, both clean), the old account-wide token was deleted from Gitea entirely.
Given what Git Sync did to user/, the isolated clone lives at /opt/grav_source/repo, a plain directory with no relationship to Grav at all beyond being read by the conversion script below. It is not mounted into the container, not referenced anywhere in Grav's own config, and gitignoring or deleting anything in user/ has no effect on it.
Create it once, then point it at the deploy key that we set up earlier:
sudo git clone "git@<gitea-host>:jan/obsidian_grav_tutorials.git" /opt/grav_source/repo
sudo git -C /opt/grav_source/repo config core.sshCommand "ssh -i /opt/grav_source/deploy_key -o StrictHostKeyChecking=accept-new"
If this clone were ever wiped by accident, the fix is exactly the same command again, nothing more.
The original open question was webhook versus polling. Polling won, for the same reason it was already used for the WordPress uploads sync elsewhere in this environment: no inbound endpoint to secure, and it self-heals automatically if the host was offline when a push happened, rather than needing that push replayed later.
# /etc/systemd/system/grav-publish.timer
[Unit]
Description=Run grav-publish.service every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
[Install]
WantedBy=timers.target
# /etc/systemd/system/grav-publish.service
[Unit]
Description=Convert Grav _inbox/ content into published pages
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/grav/publish_inbox.py
Save both files to /etc/systemd/system/, save publish_inbox.py itself to /opt/grav/publish_inbox.py (matching the ExecStart path above), then enable and start the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now grav-publish.timer
Trigger one run immediately rather than waiting up to 5 minutes, and confirm it actually worked:
sudo systemctl start grav-publish.service
journalctl -u grav-publish.service --no-pager -n 20
publish_inbox.pyEach run does three things: pull the isolated clone, walk its content, and write Grav pages under user/pages/, numbered from 10. upward so 01/02 stay free for Grav's own default pages.
.md files at the repo root become single, standalone pages..md file inside is a part, ordered by a leading "Part N" in its filename where present, alphabetically otherwise. Any image anywhere in that folder (including a nested assets subfolder) is available to every part in the series. A folder with exactly one .md part is collapsed to a direct single page instead, no index page with a one-item "Parts in this series" list, that was just an extra click to reach the only real content. The URL still comes from the folder's own slug in that case, not the part's own title-derived one, on purpose: a one-off tutorial that lives in its own folder for the sake of a nearby assets/ folder shouldn't have its URL depend on whether it happens to have one part or three.# Heading line in the file, with any heading that falls inside a fenced ``` code block correctly ignored (more on why below). Falls back to the filename if no real heading exists at all.Category and tags come from an optional YAML frontmatter block at the very top of the file, Obsidian's own Properties panel writes this natively, no plugin needed:
---
category: OPNSense
tags: [firewall, vlan]
---
# The actual article title
Stripped from the body before title extraction runs, and fed into that page's Grav taxonomy: frontmatter, which is what the categorized blog homepage (Part 2) filters and groups by. Entirely optional, an article with no frontmatter block just has no category or tags, nothing breaks. A series takes its category and tags from whichever part defines them first, in part order (usually Part 1), since the whole series shows as one card on the homepage, not one per part.
[[Note Name]], [[Note Name|Display text]]) get rewritten to relative Grav links, resolved against a title-to-slug map built across the whole batch before any page is written.![[image.png]] embed syntax or plain Markdown , get resolved by filename, copied alongside the generated page, and given a lightbox plus a size class based on the image's own height, so a short, wide screenshot doesn't render as a tiny sliver. A URL-encoded path (%20 for spaces, common when standard Markdown syntax is written by an editor rather than typed by hand) is decoded before that filename lookup, otherwise it silently never matches anything on disk.<p align="center"><a href="assets/x.png"><img src="assets/x.png" ...></a></p>), never Markdown syntax, get converted into plain Markdown image syntax first, then flow through the exact same resolution/copy/classing logic as the bullet above. Patching the raw HTML's src/href in place directly was tried first and still 404'd: a bare relative path in raw HTML is resolved by the browser's own URL rules, which drop the page's last path segment, fine on a page with a single-segment route, wrong (one directory too shallow) on a nested series/part page. Grav's own Markdown image handling doesn't have that problem, it resolves against the page's actual route however deep the nesting, which converting to Markdown syntax first gets for free.```mermaid fenced block gets translated into <div class="mermaid" style="text-align:center">...</div>, the shortcode Grav's mermaid-diagrams plugin actually expects, since that plugin has no idea what a fenced code block is. Keeping the source in standard Markdown syntax means it still renders correctly in Obsidian and in Gitea's own preview too, only Grav needs the translation. That plugin has to actually be installed for this to do anything, docker exec grav bin/gpm install mermaid-diagrams -y, without it the <div class="mermaid" style="text-align:center">...</div> shortcode this script produces just shows up as literal text on the page, nothing breaks, it simply isn't a diagram.> [!info] Title, > [!warning], and so on) get their opening line rewritten into GitHub's own alert syntax instead: > [!NOTE], TIP, IMPORTANT, WARNING, or CAUTION, exactly five fixed types, Obsidian's wider vocabulary mapped down to whichever is closest. This Grav install already has a plugin for that exact syntax (github-markdown-alerts), so the result is a real titled, colored box instead of a plain blockquote with [!info] sitting there as literal text, which is all Grav's own Markdown parser would otherwise make of it. GitHub's syntax has no slot for a custom title, so an Obsidian title becomes a bold first line of the body instead of getting silently dropped. Everything else in the callout, lists, bold text, links, is left completely untouched and renders exactly like anywhere else on the page, since it's still the same > blockquote marker underneath, this script only ever touches the opening line.`span` (a bash comment, a mermaid subroutine shape that happens to look like a wikilink, or a literal `Note Name` written as a syntax example) is ever mistaken for real Obsidian syntax.default.md (see Part 2 for the "Download Markdown" link and "Save as PDF" button that actually expose it): the original markdown, the same content the rewriting above is about to transform, not Grav's shortcode/querystring output, so it's something a visitor could genuinely reuse elsewhere, another vault, GitHub, wherever. Written with a .txt extension rather than .md: confirmed directly that Grav refuses to serve a bare .md file sitting in a page folder as a static download (the same bytes come back 404 as .md, 200 as .txt), presumably deliberate since .md is Grav's own content-file extension./opt/grav/.publish_manifest.json) recording what the script itself created. A renamed or removed article has its old page removed automatically. Anything not in the manifest, meaning Grav's own default pages, is never touched.Home.md at the vault root overwrites Grav's own reserved homepage (01.home/default.md) directly, instead of becoming a numbered page like every other standalone article. Left untouched, never reset, if Home.md isn't present in a given run. That page is forced to template: blog (the categorized homepage covered in Part 2), so Home.md itself only ever supplies its title and any intro text above the listing, same relationship the next two bullets have to their own pages.Search Articles.md at the vault root works the same way for 02.search/default.md (the flat, searchable listing, "Search Articles" in the sidebar, formerly named All Articles.md / 02.articles before the site grew a proper categorized homepage, see Part 2), only controlling that page's title and intro text. The actual listing there is generated live by a custom Twig template (articles.html.twig), not stored as page content anywhere.About.md at the vault root is the same idea again, for 03.about/default.md, the personal "About" page.user/pages/ to the container's www-data UID/GID (1000:33 in this setup, confirmed with docker exec grav id www-data) at the end of every run. Without this, Grav's own admin UI, which edits and deletes pages as www-data from inside the container, fails with a permission error the moment it touches anything this script wrote.No dashboard for any of this, so checking it directly:
# does the clone see the latest commit from Gitea
git -C /opt/grav_source/repo fetch
git -C /opt/grav_source/repo log --oneline -3 origin/main
# is the timer actually enabled and running on schedule
systemctl status grav-publish.timer
# what happened on the last few runs
journalctl -u grav-publish.service --no-pager -n 40
# what actually landed on disk
find /opt/grav/user/pages -maxdepth 3
A clean run's log looks like wrote 10.some-article/default.md <- Source File.md for each page, followed by Done. N top-level page(s) processed, cache cleared. A removed stale ... line means the cleanup above just caught a rename or deletion, which is expected behaviour, not an error.
Everything above only cares that a commit lands on Gitea; it doesn't care who made it. Obsidian Git handled that well enough on its own for a while. Then a second problem showed up: screenshots pasted straight from Obsidian kept landing in Gitea as multi-megabyte files with names like Pasted image 20260825221518.png, which gets messy the moment you have more than a few.
The obvious place to fix it was a git pre-commit hook: resize and rename anything staged, right before the commit that would carry it to Gitea. That's the same shape as the sibling Notion-to-Gitea backup script this whole idea is modeled on, which does its own image handling inline as part of one script that owns the entire upload.
It doesn't work with Obsidian Git, though. A pre-commit hook only ever fires for a commit made through the real git binary, and testing this directly showed that Obsidian Git's own commits don't invoke local git hooks at all: a real "add" commit went through with the original, untouched file, no hook output whatsoever. Obsidian Git most likely commits through its own bundled JavaScript git implementation rather than shelling out to the system's git.exe, even on desktop. Whatever the exact reason, no hook can ever fire for it.
So the fix doesn't try to hook into Obsidian Git's commit at all. Instead, a single script (publish_vault.py does the resize/rename pass and the commit and the push itself, driving git directly. A small custom Obsidian plugin gives that script one deliberate trigger: a command in the palette, or a hotkey, run whenever you're actually ready to publish.
optimize_images.py scans the whole vault and, for every image it finds:
Part-N-Heading-Slug-NN.ext: N is the part number from the referencing note's own filename, Heading-Slug is the nearest H2 heading above the image (falling back to the nearest heading of any level if there's no H2 above it), and NN is a two-digit counter scoped to that exact heading, so the first image under a given H2 is 01, the second is 02, and so on. Case is preserved from the heading text on purpose, this is meant to read as a real, SEO-friendly filename, not a lowercased slug. The file gets moved into an assets/ folder next to the note that references it, matching the convention already used elsewhere in this vault.It's idempotent: a file already named exactly what a given run would produce is left alone entirely, so running it repeatedly never reshuffles numbers or touches an image twice. Numbering also picks up correctly from whatever's already on disk, so adding a third image under a heading that already has 01 and 02 from an earlier session correctly becomes 03, not another 01.
This applies to every image it can find a note reference for, not only Obsidian's own auto-generated paste names. A deliberately-placed diagram or icon gets the same treatment, since the goal is a consistently organized assets/ folder, not just cleaning up pasted screenshots specifically.
It regenerates __pycache__, and that's worth excluding from sync. Importing this module from publish_vault.py creates a .pyc bytecode cache file right inside the vault folder by default. Left alone, git would happily commit it as a new untracked file, and worse, if the same vault is also synced elsewhere (LiveSync, in this project's case), a churning binary file that keeps changing hash can produce a real sync conflict on a file that never should have been synced in the first place, which is exactly what happened during testing. The fix is one line, sys.dont_write_bytecode = True, set before the import, plus a .gitignore entry for __pycache__/ as a second layer of protection.
Renaming everything retroactively is a real decision, not just a default. The first version of this script only touched Obsidian's own auto-generated paste names, leaving anything already named by hand alone. Extending it to rename every image it finds, including ones already live and referenced on the published site, was a deliberate choice: cleaner, more consistent naming across the whole vault, at the cost of changing URLs for content that's already public. If you only want this going forward, gate the rename on the filename still looking auto-generated (a simple regex check) rather than applying it to everything.
A custom Obsidian plugin was created to orchestrate the commit + push from Obsidian to Gitea, it is executed manually. Why a custrom plugin instead of the main Git plugin supplied with Obsidian?
A single command, Commit and Publish, that runs publish_vault.py (which does its own image optimization, commit, and push, all in one pass) and shows one short notification with the result, something like Published, 1 image renamed, 1 resized, rather than a wall of console text to read through.
Download it from my public Github repo. The plugin is two files, manifest.json and main.js, placed in <vault>/.obsidian/plugins/commit-and-publish/, then added to .obsidian/community-plugins.json and enabled the normal way (Settings → Community plugins), or picked up automatically on the next restart if you add its id to that file directly.
Obsidian doesn't ship a default hotkey for this, or for Obsidian Git's own commit command either, community plugin commands never get one automatically. To set one:
Any combination that isn't already bound to something else works. There's nothing to "match" from Obsidian Git specifically, since it never had a default of its own.
Alternatively, you can run it by pressing Ctrl (CMD) + P while in Obsidian and selecting 'Commit and Publish'.
Only web1 runs the pull/convert side today. The original plan sketched web2 and web3 pulling the same way, and nothing about the design prevents that: it's the same isolated clone, the same read-only deploy key (issued separately per host, same as web1's), and the same timer and script, copied to each additional host. Not yet built simply because there's only one live site to publish to so far.
The first draft of this article listed five open questions before any of this was built. For the record, here's what each one turned into:
Published/ folder or it doesn't; there's no separate frontmatter flag yet. Worth revisiting if "finished but not ready" ever becomes a real, common state.NN.slug) generated deterministically from each entry's earliest commit date, newest first, starting at 10 to leave Grav's own default pages alone.