--- name: saiop-git-sync-sidecar description: "Pattern for keeping a container's data volume continuously synced to a Gitea repo via a small sidecar container — used for the Obsidian vault, reusable elsewhere." version: 1.0.0 author: Claude Code (SAIOP ops session) license: MIT platforms: [linux] prerequisites: env_vars: [] commands: [docker, git] metadata: hermes: tags: [SAIOP, git, sync, Gitea, pattern, sidecar] --- # Git-sync sidecar pattern ## When to use Any time an app's data volume should be continuously backed by a Gitea repo (used for the Obsidian vault → `atu/saiop-knowledge`). Generalizes to any app that just reads/writes files in a directory without needing to know git exists. ## The pattern A second, tiny container (`alpine/git`) mounts the **same volume** as the main app and loops: `git pull` → check `git status --porcelain` → if dirty, `add`/`commit`/`push` → `sleep`. The main app never needs to know about git at all. ## Gotchas hit building this 1. **`rm -rf` on the mount point itself fails** ("Resource busy") — you can't remove a bind-mounted directory, only its contents. Use `find "$DIR" -mindepth 1 -delete`, or `git clone .` directly into the already-existing empty directory instead of removing-then-cloning. 2. **UID mismatch silently breaks the main app's write access.** If the sidecar runs as root (the `alpine/git` default) and the main app runs as a non-root `PUID`/`PGID` (common for LinuxServer.io images), every file the sidecar creates is unwritable by the main app. Set `user: "UID:GID"` on the sidecar to match the main app's user — don't just chown once after the fact, the mismatch comes back on every commit. 3. **Add a `.gitignore` for the app's own volatile state files** (Obsidian: `workspace.json`, `cache/`) before the first commit, or every sync cycle produces a noise commit even with zero real content changes. 4. **Don't hardcode a container's internal IP for inter-container access** — IPs churn (confirmed twice on this stack already). Use the container **name** if both containers share a network (Docker's embedded DNS resolves it), which is why the sync sidecar talks to `gitea:3000` directly rather than an IP.