The need: real Obsidian, from any browser
On October 1, 2026, I wanted to open and edit my Obsidian vault from a phone or a PC that isn’t mine, without installing anything. Not a Markdown viewer: the real Obsidian, with Dataview actually running, the Outliner, callouts rendered exactly as on the desktop. And without touching the existing flow: on the PC, the obsidian-git plugin pulls the repository at startup and every 15 minutes, and pushes to Gitea, which remains the source of truth. The vault holds 188 notes, about 4 MB.
Three options were on the table. SilverBullet is web-native, but does not run Dataview. The code-server instance already running in the cluster would edit files, not views. That left the bluntest one: run the desktop application itself in a pod and stream its screen. The linuxserver/obsidian image does exactly that, with Selkies providing the remote desktop in the browser.
Streaming is the easy part. The real subject is what happens as soon as there are two writers on the same git repository: the PC on one side, the pod on the other, and neither knows what the other is typing.
The architecture: one pod, two containers, a disposable volume
The pod is made of three pieces sharing a 5 Gi Longhorn PVC mounted on /config:
- a
vault-initinitContainer clones the vault on first start and pre-registers the folder in Obsidian’s configuration, to skip the “choose a vault” screen; if the clone exists, it does nothing; - the
obsidiancontainer: image pinned by digest, 2 CPU / 2 Gi limit,/dev/shmas a 1 Gi in-memory volume as Electron requires, no GPU; - the
git-syncsidecar: analpine/gitimage running a POSIX script in a loop every 120 seconds, with a Gitea deploy key that can write to this one repository only.
The Deployment uses strategy: Recreate: the volume is RWO, and there must never be two pods on the same clone. It is pinned to the cluster’s two 16 GB Raspberry Pi 5 nodes; the 2 GB worker is excluded. The probes are tcpSocket, not httpGet: HTTP answers 401 until basic auth is passed, so an HTTP probe would fail forever. The HTTPS that Selkies needs (WebCodecs requires a secure context) is provided on the browser side by Cloudflare.
The PVC is disposable. The truth lives in Gitea, which is already backed up; losing the volume costs the pod’s UI settings, not a note. On one condition: nothing must stay stuck in that volume. The rest of this article revolves around that condition.
The loop: add, commit, fetch, rebase, push
The principle fits in four lines. Every 120 seconds:
git add -A, then aweb: <ISO timestamp>commit if anything is staged;git fetch origin main;git rebase origin/main;- if
HEADis ahead oforigin/main,git push origin HEAD:main.
A round with no change writes nothing, not even a log line. The success criterion in the spec was simple: a note edited on one side shows up on the other within 3 minutes. On the PC, nothing changes; it just sees web: … commits appear in the history.
Those four lines are the happy path. The script runs to just under 120 lines, and almost everything else is there to handle what falls off the happy path.
rien d'indexé : pas de commit, pas de lognothing staged: no commit, no log line
Obsidian a écrit depuis le commit : ce n'est pas un conflitObsidian wrote since the commit: not a conflict
seul un rebase resté en cours signale un vrai conflitonly a rebase left in progress means a real conflict
la version web part sur une branche, le pod se réaligne sur mainthe web version goes to a branch, the pod realigns on main
15 tours en échec d'affilée → alerte Alertmanager15 failed rounds in a row → Alertmanager alert
The conflict: a branch instead of a loss
When the same note was edited on the web and on the PC within the same window, the rebase stops. The script does not attempt any automatic resolution; it puts the web version somewhere safe and steps aside:
git rebase --abort 2>/dev/null
ref="conflit-web/$(date -u +%Y%m%d-%H%M%S)"
git push -q origin "HEAD:refs/heads/$ref" || return 1
log "CONFLIT : version web sauvegardee sur $ref"
if ! git reset -q --keep "origin/$BRANCH" 2>/dev/null; then
log "edition pendant le conflit, realignement reporte"
return 0
fi
The web version goes to a conflit-web/<YYYYMMDD-HHMMSS> branch, the pod realigns on main, and main is never rewritten. Resolution happens on the PC with the usual tools: git fetch, git merge origin/conflit-web/…, resolve, push main, delete the branch.
You still need to know there is something to merge. Every round, the script runs git ls-remote on conflit-web/* and, as long as it finds one, POSTs an ObsidianSyncConflit alert straight to the cluster Alertmanager’s v2 API, which forwards it to Telegram. The alert carries an endsAt of +10 minutes and is re-sent every round: once the branch is deleted, nobody renews it, and it goes RESOLVED on its own within a dozen minutes. No Prometheus rule, no exporter: one wget and a three-key JSON body.
The sidecar that is not allowed to die
The most important rule of the script sits in its header comment: it never exits with an error. No set -e, only set -u. Every failing step does return 1 inside the round function, the loop counts, and the last line of the file is exit 0.
The reason is mechanical. A sidecar that exits with an error restarts, then goes into CrashLoop; the pod becomes NotReady, the Service drops its endpoint, and web access is gone. Precisely when Gitea is unreachable or a key has been revoked, which is exactly when you would at least like to read your notes.
Problems therefore surface as alerts rather than restarts:
if sync_once; then
fails=0
else
fails=$((fails + 1))
log "echec ($fails tour(s) consecutif(s))"
fi
check_conflicts
if [ "$fails" -ge "$MAX_FAILS" ]; then
send_alert ObsidianSyncEnErreur "git-sync obsidian echoue depuis $fails tours"
fi
With MAX_FAILS=15 and 120-second rounds, ObsidianSyncEnErreur fires after about 30 minutes of consecutive failures. Below that, a failure is treated as transient. The typical case: the PC pushed between the pod’s fetch and push, the push is rejected, and the next round catches up without bothering anyone.
The script is tested outside the cluster: a local bare repository plays Gitea, one clone plays the PC, another the pod, and a fake Alertmanager of about twenty lines of Python logs every POST to a file. Intervals are configurable through environment variables; a test runs one or two rounds with INTERVAL=0 and checks the state of the fake Gitea.
Four ways to lose a keystroke
The first version of the script, the one from the plan, passed its tests. A final code review still found ways to lose data or stall the sync. What all these cases have in common: Obsidian writes whenever it likes. Autosave does not follow the loop’s schedule.
1. A note being typed mistaken for a conflict. This one was anticipated in the plan. Obsidian saves a note between the commit and the rebase: the tree is dirty, and git rebase refuses to start. That is not a conflict, and creating a conflit-web/* branch for it would wake Telegram up for nothing. The script checks git status --porcelain after the fetch and, if the tree is dirty, defers to the next round. The keystroke will be in the next commit.
2. A keystroke during the conflict-branch push. This was the defect rated critical. The first version realigned the pod with git reset --hard origin/main. But pushing the conflict branch takes time, and a note saved by Obsidian during that push is in no commit: --hard silently overwrote it. The fix is one word, --keep, which refuses to touch a locally modified file. The reset fails, the round stops, and the next round commits the keystroke and pushes it to a new conflict branch. The matching test installs a pre-push hook that writes into the note during the push, then checks that some branch of the fake Gitea contains the text.
3. A false conflict caused by a lock. An index.lock held by another git process makes the rebase fail without any conflict at all. The first version saw a failed rebase and opened a conflict branch. Now the script tells them apart: if the rebase fails and a rebase was left in progress (.git/rebase-merge or .git/rebase-apply), it is a real conflict; otherwise it is a transient failure, retried next round.
4. Orphan locks. A git process killed mid-operation (restarted container, child killed by the OOM killer) leaves an index.lock behind, and every following operation fails. At startup, git-sync is alone in the pod: any lock is orphaned and gets removed. Mid-run it is trickier, since a fresh lock may belong to a live process; only locks older than 10 minutes are removed. Since git-sync is the only process that rebases and its rounds never overlap, a rebase in progress at the start of a round is necessarily orphaned: it is aborted, and the web version ends up on a conflict branch instead of lingering in the PVC’s reflog.
The test suite grew from 18 checks in the plan to 28, each fix arriving with a test that reproduces the scenario using git hooks.
The infrastructure traps
The ConfigMap that changes without anything changing
The script lives in a ConfigMap, generated by a small Python script from the source file. The problem: when the ConfigMap changes, ArgoCD shows it as Synced, kubelet does update the mounted file on disk, but the git-sync shell read its script at startup and keeps running the old one. Worse, the /etc/passwd mounted through subPath (see below) is never updated at all.
The fix is the classic checksum in the pod template:
template:
metadata:
annotations:
# GENERE par gen_configmap.py : change avec le ConfigMap -> le pod redemarre
checksum/git-sync: "7453…4f1c"
The generator computes the ConfigMap’s SHA-256 and rewrites the annotation in the Deployment. A new script changes the hash, therefore the template, therefore the pod restarts. Two tests keep it honest: one checks that the ConfigMap embeds exactly the source script, the other that the annotation matches the ConfigMap. Forgetting to regenerate fails the suite.
OpenSSH and uid 1000
First start, first clone, first failure: No user exists for uid 1000. The container runs as uid 1000 so that it writes with the same owner as Obsidian, but the alpine/git image has no user 1000, and OpenSSH refuses to run without a passwd entry. The fix adds a passwd key to the ConfigMap, mounted on /etc/passwd in both the initContainer and the sidecar:
root:x:0:0:root:/root:/bin/sh
obsidian:x:1000:1000:obsidian:/tmp:/bin/sh
The NetworkPolicy and the test pod in a hurry
Only the Cloudflare tunnel should reach Obsidian. The NetworkPolicy allows ingress only from app=cloudflared pods in the cloudflared namespace, not from the whole namespace: it also hosts a hostNetwork DaemonSet whose pods carry the nodes’ IPs.
The trap showed up while testing it. An ephemeral pod started in cloudflared to run a wget was refused, even though the policy should have let it through. The explanation: k3s enforces NetworkPolicies with kube-router, which only adds a new pod to its ipsets at the next sync. A pod that lives a few seconds is refused before it has ever existed as far as the firewall is concerned. To test, you need a pod that lives at least 45 seconds and carries the app=cloudflared label.
No Ingress, on purpose
There is no Traefik Ingress for this service, and that is deliberate. The LAN has a wildcard DNS entry sending every homelab subdomain to Traefik; an Ingress would make Obsidian reachable on the local network without going through Cloudflare Access. The tunnel therefore targets the ClusterIP Service directly.
The Access application is dedicated, allows a single identity, uses a 24-hour session, and was created before the public hostname, so that the URL never existed unprotected. Selkies’ basic auth stays as a second layer, in case the NetworkPolicy or Access ever get misconfigured.
The obsidian-git plugin, frozen in the pod
The plan hoped the Obsidian image would not ship git, which would have left the obsidian-git plugin, enabled in the vault’s versioned configuration, inert. No such luck: the image ships git. So the plugin runs in the pod too, and without precautions it would become a third writer, with its own commits and its own locks.
Two measures. Its pod-local settings (the plugin’s data.json, which is gitignored) are frozen at zero: no auto-commit, no auto-push, no auto-pull. And the Obsidian container gets GIT_OPTIONAL_LOCKS=0, so the plugin’s reads do not take an opportunistic lock during a git-sync round. The PC keeps its plugin as is: it is the only legitimate writer on the other side.
What I take away
- Two writers on one repository is concurrency, not sync. The happy path is four commands; all the work is in the cases where someone writes at the wrong moment.
- Tell conflict, dirty tree and lock apart. A failing
git rebasedoes not mean a conflict. Only a rebase left in progress is one; everything else is deferred to the next round. reset --keeprather than--hardwhenever a human may be writing in parallel. A reset that refuses beats a reset that overwrites.- A sidecar does not exit with an error. If its death takes down the main application, it counts its failures and alerts. The Alertmanager v2 API with a short
endsAtgives you auto-resolution for free. - ConfigMap checksum in the template. Without it, a mounted script changes on disk but not in the process that already read it.
- Test a NetworkPolicy with a pod that stays alive. Under kube-router, an ephemeral pod is refused before the ipsets sync; a negative test can lie.
- No Ingress for anything that must stay behind Access, as long as a LAN wildcard exists.
An honest limit: the design assumes that simultaneous edits of the same note on both sides are rare. They lose nothing, but each one costs a manual merge on the PC. If they became frequent, the answer would not be a better sync script, but to stop having two writers.