The high-level design of my Markdown system is simple: Git is the common transport and history for documents written by people and documents written by software.
The implementation has one important complication. A Git server and an automation host want different things from a repository. The Git service wants a bare repository that accepts pushes, while automation needs an ordinary directory of files that it can read and modify. My OpenBSD server therefore maintains both.
The repository and the work tree
The canonical copy of my vault lives in a bare Git repository:
/home/git/vaults/Main.git
That repository is the synchronization endpoint for every client. Laptops, desktops, and my phone all exchange changes with it.
The server also maintains a separate checked-out copy:
/home/obsidian/vaults/Main
This is the copy used by server-side tools. Keeping those roles separate avoids turning the Git endpoint itself into a working directory: the bare repository handles history and synchronization, while the work tree provides ordinary files that automation can read and write.
The relationship looks roughly like this:
clients
↕
Git
↕
Main.git (bare repo)
↓
post-receive hook
↓
Main (server work tree)
↕
document automation
The bare repository remains authoritative, while the checked-out tree provides a filesystem view of that history for software running on the server.
Keeping the work tree current
When the server accepts a push, a post-receive hook updates the checked-out vault.
The Git repository and the work tree are owned by different service accounts, so the hook crosses that boundary explicitly rather than making the whole system writable by one user. A shared group provides the filesystem access they need while keeping the roles distinct:
- the Git account accepts repository operations;
- the vault account operates the checked-out files and document automation.
A client push therefore follows a predictable path:
client commit
↓
SSH push
↓
canonical bare repository
↓
post-receive hook
↓
server work tree updated
Once that finishes, server-side software sees the same version of the vault that clients just pushed.
Protecting the canonical repository
Because the bare repository is the authority for the entire system, I protect it independently of the clients.
The Git server rejects non-fast-forward pushes and branch deletion. Reflogs are retained, and the vault repository is bundled regularly to separate storage.
These controls cover different kinds of mistakes. The synchronization rules reduce the chance of destructive changes entering the repository in the first place. Reflogs make previous Git state recoverable when history changes unexpectedly, while bundle backups provide another copy outside the active repository.
Client synchronization with vaultctl
On desktops and laptops I use vaultctl, a small Go utility I wrote largely because I got tired of doing routine Git housekeeping by hand.
The common nuisance is a minor divergence between devices. I might make a change on one machine, forget to sync it, and then make another change somewhere else. Resolving that manually can mean staging or stashing local work, fetching, rebasing, restoring changes, and finally pushing everything back up.
None of that is particularly difficult, but it is enough friction that I do not want to think through the sequence every time two devices get slightly out of step.
On a normal client clone, vaultctl sync handles that routine for me:
- stages and commits local changes;
- fetches the canonical repository;
- determines whether the local history is equal, ahead, behind, or diverged;
- uses the appropriate Git operation to converge the histories;
- stops when the repository reaches a state that actually requires human judgment.
Git still does the version-control work. vaultctl mainly turns the normal synchronization procedure into one command.
It does not force-push over another history or invent a conflict resolution simply to finish the sync. Minor, mechanically resolvable divergence gets handled automatically; an actual conflict gets handed back to me. The scheduling around login, shutdown, suspend, resume, and mobile use is covered separately in Client Operations in a Git-Backed Markdown Vault.
Using vaultctl on the server
The same convenience matters on the server, although the Git layout is different.
On a normal client, the repository and work tree live together in an ordinary clone. On the server, the canonical repository and checked-out vault are separate:
bare repository:
/home/git/vaults/Main.git
work tree:
/home/obsidian/vaults/Main
Doing Git work against that arrangement manually is cumbersome because commands need to be pointed explicitly at both the bare repository and the external work tree. A simple operation quickly turns into a command full of repository and work-tree paths.
vaultctl hides that plumbing. Server mode knows where those pieces live and provides the same basic benefit as client mode: routine Git operations become simple commands instead of procedures I have to reconstruct each time.
The mechanics are different, but the reason for wrapping them is the same as it is on my laptop: keeping the vault synchronized should not require me to babysit Git.
External repository projection
vaultctl is also gaining support for external repository projection: bringing selected content from other Git repositories into managed destinations in the vault while leaving the source repository authoritative.
My first use for it will be keeping the current documentation from each of my project repositories available inside the vault automatically. That gives me a convenient local reference without turning the vault into a second source of truth for those documents.
The mechanism itself is not documentation-specific. It is a general way to expose repository-owned material inside another workspace without making the destination responsible for maintaining it.
The planned implementation keeps the relationship explicit: sources and destinations are declared, projected files have a defined ownership set, source updates are fast-forward only, incomplete Git recovery states stop the refresh, and updates are handled so a failed projection does not leave a partially refreshed destination.
The goal is to make external repository content convenient to use inside the vault without giving an automated refresh permission to overwrite unrelated work.
NRE as the job runtime
The checked-out vault gives server-side automation somewhere to write, but I do not want every automated note to become its own miniature application with separate logging, error handling, and execution scaffolding.
NRE provides a standard runtime for those jobs. I wrote more about why that runtime exists in nre — Real Automation for Markdown Notes. When I add a new automated note, most of my attention can stay on the part that is actually unique: gathering the data, transforming it, and deciding what the resulting document should contain. The machinery around that job—how it is run, how execution is logged, and how failures are recorded—comes from the same runtime used by the other jobs.
That becomes more useful as the number of automations grows. Without a common runtime, every new job is another opportunity to invent a slightly different logging format, error path, or operational convention. With NRE, a new generator plugs into infrastructure that already exists.
A typical run looks like this:
scheduled job
↓
NRE runtime
↓
note-specific generator
↓
Markdown written into server work tree
↓
Git commit
↓
canonical repository
↓
client sync
NRE gives each job the same execution and logging model, so troubleshooting does not depend on remembering how that particular script was put together.
I also feed job health back into the vault itself. My daily-note dashboard monitors the automation jobs and surfaces problems there, so a failed or unhealthy job becomes visible during normal use of the vault rather than sitting unnoticed in a server log.
The jobs generate documents for the vault, and the vault in turn tells me when the machinery generating those documents needs attention.
Failure is a visible state
One rule shows up repeatedly throughout the system: automation should stop when it no longer has enough information to make a safe decision.
That applies to client synchronization, external repository projection, and server-side repository operations. A diverged Git history is not automatically “fixed.” An interrupted merge or rebase is not ignored, and an automatic refresh does not get permission to overwrite files outside the set it owns.
Most synchronization is routine and can happen without attention. The exceptional cases stay visible because they are the ones that actually need attention.
One system, several responsibilities
The final layout is a collection of fairly small pieces:
| Component | Responsibility |
|---|---|
| OpenBSD Git host | Canonical repository, SSH access, permissions, hooks, and recovery safeguards |
| Server work tree | Filesystem view used by automation |
vaultctl | Git synchronization and external repository projection |
| NRE | Standard runtime, logging, and execution for Markdown-generation jobs |
| Obsidian | Primary interface for reading and editing Markdown in the vault |
Git owns history and transport. The server work tree exposes files to automation. NRE supplies the common runtime and operational visibility for document-generation jobs, while vaultctl removes most of the routine Git work involved in keeping the pieces synchronized.
End to end
A note written on my laptop takes this path:
edit in Obsidian
↓
vaultctl sync
↓
canonical Git repository
↓
post-receive hook
↓
server work tree
A report generated on the server travels the other way:
NRE job
↓
Markdown in server work tree
↓
Git commit
↓
canonical repository
↓
vaultctl sync
↓
Obsidian
The server does not need to run a note-taking application, and the clients do not need to know how automation works. They meet at a Git repository full of ordinary Markdown files.
The higher-level rationale for building the system this way is covered in Self-Hosted Git as Document Infrastructure. This article covers the machinery underneath it.
