26 KiB
PRD: Outline Sync Web UI
Version: 2.0 Date: 2026-03-06 Status: Draft — WebDAV Architecture Depends on: SYNC_PRD.md (sync engine)
1. Problem Statement
The sync engine (sync.sh) works but requires terminal access on the VPS. The user edits notes locally in Obsidian on their own machine. Changes need to flow bidirectionally between local Obsidian and the remote Outline wiki without terminal interaction.
The key constraint: Obsidian runs locally, the vault git repo lives on the VPS.
2. Architecture Options Evaluated
| Option | Mechanism | Verdict |
|---|---|---|
| WebDAV + Remotely Save | WebDAV server on VPS serves vault dir; Obsidian plugin syncs automatically | ✅ Recommended |
| Self-hosted LiveSync | CouchDB on VPS; Obsidian plugin syncs in real-time | ❌ Adds CouchDB; no direct file access for sync engine |
| Local REST API | Obsidian exposes REST on localhost:27124 | ❌ VPS can't reach local machine |
| Manual zip download/upload | Browser download/upload of vault archives | ❌ Eliminated by WebDAV option |
Decision: WebDAV. A WebDAV Docker container on the VPS serves the vault directory directly. The Obsidian plugin remotely-save syncs to it automatically. No tunnel, no extra database, no build step.
3. System Architecture
┌─────────────────────────────────────────────────────────┐
│ Your local machine │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Obsidian │ │
│ │ Plugin: remotely-save │ │
│ │ Auto-sync every N minutes (or on open/close) │ │
│ └──────────────────────┬──────────────────────────┘ │
└─────────────────────────│───────────────────────────────┘
│ WebDAV over Tailscale (encrypted tunnel)
│ (basic auth + Tailscale network isolation)
┌─────────────────────────▼───────────────────────────────┐
│ VPS (domverse.de) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ WebDAV container (obsidian-webdav) │ │
│ │ Serves: /outline-vault/ (read-write) │ │
│ │ URL: http://100.x.x.x (Tailscale only) │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ shared volume │
│ ┌────────────────────▼─────────────────────────────┐ │
│ │ /outline-vault/ (git repo) │ │
│ │ ├── .git/ │ │
│ │ │ ├── outline branch (last Outline state) │ │
│ │ │ └── main branch (current vault state) │ │
│ │ ├── Bewerbungen/ │ │
│ │ ├── Projekte/ │ │
│ │ └── _sync_log.md │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ shared volume │
│ ┌────────────────────▼─────────────────────────────┐ │
│ │ outline-sync-ui (FastAPI, port 8080) │ │
│ │ URL: https://sync.domverse.de/ │ │
│ │ Auth: Authentik │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────────┐ │
│ │ outline_sync.py (sync engine) │ │
│ │ network: domnet │ │
│ │ API: http://outline:3000 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Data Flow: Obsidian → Outline
- User edits notes locally in Obsidian
remotely-saveplugin syncs changed files to WebDAV server on VPS (automatic)- WebDAV writes files directly into
/outline-vault/ - User clicks "Send to Outline" in web UI (or cron runs push)
- Sync engine:
git diff outline..main→ calls Outline API per changed file - Sync engine advances
outlinebranch
Data Flow: Outline → Obsidian
- User clicks "Get from Outline" in web UI (or cron runs pull)
- Sync engine exports Outline → commits to
outlinebranch → merges intomain - Files updated in
/outline-vault/ - WebDAV immediately serves updated files
remotely-saveplugin picks up changes on next sync interval
4. User-Facing Mental Model
Two systems, one bridge:
| What the user sees | What actually happens |
|---|---|
| Obsidian auto-syncs | remotely-save talks WebDAV to VPS |
| "Get from Outline" | sync engine pulls Outline API → git merge → files update in vault |
| "Send to Outline" | sync engine diffs vault → calls Outline API |
| "Version conflict" | git merge produced conflict markers |
| "Keep mine / Keep Outline's" | sync resolve --accept local/remote |
Git is invisible. WebDAV is invisible. Obsidian just sees files that stay in sync.
5. Goals
| # | Goal |
|---|---|
| G1 | Obsidian syncs to VPS automatically — no manual file transfer |
| G2 | Pulling from Outline and pushing to Outline are single button clicks in a browser |
| G3 | Live output visible while sync runs |
| G4 | Conflicts resolvable in browser with side-by-side diff |
| G5 | New files created in Obsidian appear in Outline after push |
| G6 | New documents created in Outline appear in Obsidian after next WebDAV sync |
| G7 | Web UI protected by Authentik SSO |
| G8 | WebDAV endpoint protected by authentication |
| G9 | Zero terminal interaction for normal workflow |
6. Non-Goals
| # | Non-Goal | Reason |
|---|---|---|
| NG1 | In-browser markdown editor | Obsidian is the editor |
| NG2 | Real-time Outline → Obsidian sync | WebDAV poll interval (e.g. 5 min) is sufficient |
| NG3 | Syncing .obsidian/ config, templates, daily notes |
Outline-synced content only |
| NG4 | Multi-user | Single-user system |
| NG5 | Mobile Obsidian support | Remotely Save does support mobile, but not a primary target |
6. Component Inventory
6.1 WebDAV Server (new container)
Simple nginx-WebDAV container. Serves /outline-vault/ as a WebDAV share.
Not exposed via Traefik. Bound exclusively to the VPS Tailscale interface — invisible from the public internet.
obsidian-webdav:
image: dgraziotin/nginx-webdav-nononsense:latest
container_name: obsidian-webdav
networks:
- domnet
ports:
- "100.x.x.x:80:80" # Tailscale IP only — replace with: tailscale ip -4
volumes:
- /home/crabix/docker_authentik/outline-vault:/data
environment:
- WEBDAV_USERNAME=obsidian
- WEBDAV_PASSWORD=${WEBDAV_PASSWORD} # from .env
restart: unless-stopped
# No Traefik labels — not publicly routed
Auth note: WebDAV cannot use Authentik forward auth (Obsidian plugin can't handle SSO redirect). Network isolation via Tailscale is the primary security layer — the endpoint is unreachable from the public internet. Basic auth (htpasswd) via nginx provides a second layer. Tailscale encrypts the tunnel, so plain HTTP is safe for this leg.
6.2 Web UI (new container)
FastAPI + HTMX. Control plane for sync operations.
outline-sync-ui:
image: python:3.11-slim
container_name: outline-sync-ui
networks:
- domnet
volumes:
- /home/crabix/docker_authentik/outline-tools:/app:ro # scripts, read-only
- /home/crabix/docker_authentik/outline-vault:/vault # vault, read-write
working_dir: /app
command: >
bash -c "pip install -qqq fastapi uvicorn requests &&
uvicorn webui:app --host 0.0.0.0 --port 8080"
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.outline-sync.rule=Host(`sync.domverse.de`)"
- "traefik.http.routers.outline-sync.entrypoints=https"
- "traefik.http.routers.outline-sync.tls=true"
- "traefik.http.routers.outline-sync.middlewares=secHeaders@file,middlewares-authentik-new@file"
- "traefik.http.services.outline-sync.loadbalancer.server.port=8080"
6.3 Obsidian Setup (one-time, on local machine)
- Install plugin
remotely-savefrom Obsidian community plugins - Ensure Tailscale is running on the local machine and connected to the VPS
- Configure:
- Remote service: WebDAV
- Server URL:
http://100.x.x.x(VPS Tailscale IP — plain HTTP, tunnel is encrypted) - Username / Password: WebDAV credentials
- Sync on startup: yes
- Sync interval: every 5 minutes (or on vault open/close)
- Sync direction: bidirectional (default)
- Exclude
.git/from sync (configure in plugin's ignore list)
7. New File Handling
This is the key correctness concern: when the user creates a new .md file in Obsidian, it must reach Outline correctly.
Flow for new files
- User creates
Projekte/NewNote.mdin Obsidian (no frontmatter) remotely-savesyncs it to VPS via WebDAV → file appears in/outline-vault/Projekte/NewNote.md- User clicks "Send to Outline" in web UI
- Sync engine:
git diff outline..mainshowsA Projekte/NewNote.md - Sync engine determines parent:
Projekte/folder exists → look upProjekte.mdfor itsoutline_id→ use asparentDocumentId documents.createcalled → new document created in Outline under correct collection/parentoutline_idwritten back into frontmatter ofNewNote.md- File committed → WebDAV serves updated file with frontmatter → Obsidian picks it up on next sync
New file in new folder
- User creates
NewCollection/FirstDoc.md - Sync engine: top-level folder
NewCollection/not in any known collection →collections.create("NewCollection") FirstDoccreated inside new collection- Frontmatter written back to both files
Ordering guarantee
Sync engine creates documents in topological order (parents before children), regardless of which order files were synced via WebDAV.
8. Conflict Model
A conflict occurs when:
- User edited
File.mdin Obsidian (synced via WebDAV tomainbranch) - Someone also edited the same document in Outline
sync pull→git merge outline→ conflict markers inFile.md
After conflict:
File.mdcontains<<<<<<<markers → WebDAV immediately serves this broken file → Obsidian shows it with markers- Conflict is resolved in the web UI (not Obsidian)
- After resolution, WebDAV serves the clean file → Obsidian's next sync picks it up
This is the key reason the web UI exists: conflict resolution in a browser with a diff view is far better than editing git conflict markers in a text editor.
9. Web UI Screens
9.1 Dashboard (/)
┌─────────────────────────────────────────────────────────┐
│ Outline Sync sync.domverse.de │
├─────────────────────────────────────────────────────────┤
│ │
│ Vault status ● Clean │
│ Last pull 2026-03-06 14:32 (from Outline) │
│ Last push 2026-03-05 09:10 (to Outline) │
│ Pending local 5 changes (from Obsidian via WebDAV) │
│ │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ Get from Outline │ │ Send to Outline │ │
│ │ (pull) │ │ (5 pending) │ │
│ └───────────────────┘ └───────────────────┘ │
│ │
│ ⚠ 2 version conflicts need resolution [Resolve →] │
│ │
└─────────────────────────────────────────────────────────┘
"Pending local" count = git diff outline..main --name-only | wc -l
(these are files Obsidian wrote via WebDAV, not yet pushed to Outline)
9.2 Live Sync Output (inline SSE)
Triggered by either button. Live output streamed line by line via Server-Sent Events. HTMX replaces the button area with output panel.
┌─────────────────────────────────────────────────────────┐
│ Sending to Outline... │
│ ───────────────────────────────────────────────────── │
│ ✓ 5 local changes detected │
│ ✓ Projekte/NewNote.md → created (id: 4f2a...) │
│ ✓ Bewerbungen/CV.md → updated │
│ ✓ Infra/HomeLab.md → updated │
│ ✓ Infra/OldDoc.md → deleted (deletions=off, skip) │
│ ✓ Projekte/Renamed.md → title updated │
│ ───────────────────────────────────────────────────── │
│ Done. 3 updated, 1 created, 1 skipped. │
│ [Back to Dashboard] │
└─────────────────────────────────────────────────────────┘
9.3 Pending Changes (/changes)
Before pushing, shows what will happen.
┌─────────────────────────────────────────────────────────┐
│ Pending changes (5) [Send to Outline] │
├─────────────────────────────────────────────────────────┤
│ │
│ Modified │
│ ● Bewerbungen/CV.md [preview diff] │
│ ● Infra/HomeLab.md [preview diff] │
│ │
│ New — will be created in Outline │
│ + Projekte/NewNote.md │
│ + NewCollection/FirstDoc.md (new collection too) │
│ │
│ Renamed │
│ → Projekte/OldName.md → Projekte/NewName.md │
│ │
│ Deleted (skipped — deletions are off in settings) │
│ ✗ Infra/OldDoc.md │
│ │
└─────────────────────────────────────────────────────────┘
9.4 Conflict Resolution (/conflicts)
One card per conflict. Expand for side-by-side diff.
┌─────────────────────────────────────────────────────────┐
│ Version conflicts (2) │
│ Same document was edited in Obsidian and in Outline. │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Bewerbungen/CV.md │ │
│ │ Your edit (via Obsidian): 2026-03-06 09:14 │ │
│ │ Outline's edit: 2026-03-06 11:03 │ │
│ │ │ │
│ │ ┌─────────────────┬─────────────────────────┐ │ │
│ │ │ Your version │ Outline's version │ │ │
│ │ │─────────────────│─────────────────────────│ │ │
│ │ │ # CV │ # CV │ │ │
│ │ │ │ │ │ │
│ │ │[+ New section] │ │ │ │
│ │ │ │[+ Contact info updated] │ │ │
│ │ └─────────────────┴─────────────────────────┘ │ │
│ │ │ │
│ │ [Keep mine] [Keep Outline's] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
After resolving, card collapses. If all resolved, redirect to dashboard showing "Push now available".
9.5 Sync History (/history)
_sync_log.md rendered as a table, most recent first.
10. API Endpoints (internal)
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Dashboard |
GET |
/status |
JSON vault state (counts, last sync, conflicts) |
GET |
/changes |
Pending changes list |
GET |
/conflicts |
Conflict list |
GET |
/history |
Sync log view |
POST |
/pull |
Start pull; streams SSE |
POST |
/push |
Start push; streams SSE |
GET |
/stream/{job_id} |
SSE stream for running job |
POST |
/resolve |
Body: `{file, accept: local |
GET |
/diff/{encoded_path} |
HTML fragment: side-by-side diff |
11. Security
WebDAV endpoint
- Network isolation: bound to Tailscale interface only — not reachable from public internet
- Basic auth (username + password from
.env) — second layer of protection - Encryption: Tailscale encrypts the tunnel end-to-end; plain HTTP between Obsidian and VPS is safe
- No Authentik forward auth — Obsidian plugin can't handle SSO redirects; Tailscale isolation makes this a non-issue
- No
vault.domverse.desubdomain — no DNS exposure, no Traefik involvement
Web UI
- Authentik forward auth via Traefik
- No additional app-level auth needed
- Token in
settings.jsonnever exposed to browser
Subprocess safety
- Sync commands invoked with fixed argument lists
- File paths from
/resolvevalidated against known conflict list before shell use outline-tools/mounted read-only in UI container
12. Implementation Phases
Phase A — WebDAV Container (infrastructure)
Deploy obsidian-webdav container bound to the VPS Tailscale IP (tailscale ip -4). No Traefik config needed.
Test WebDAV access from local machine via Tailscale using a WebDAV client (e.g. curl --user obsidian:$PASS http://100.x.x.x/).
Configure remotely-save plugin in Obsidian with the Tailscale URL. Verify files sync bidirectionally.
Done when: editing a file in Obsidian and running sync → file appears on VPS.
Phase B — Read-Only Dashboard
webui.py: FastAPI app. GET /status reads git status, parses into JSON. Dashboard template shows status badge, pending count, last sync times. No write operations yet.
Done when: https://sync.domverse.de shows current vault state.
Phase C — Pull with Live Output
POST /pull spawns outline_sync.py pull as async subprocess. GET /stream/{job_id} streams stdout via SSE. HTMX wires button → POST → SSE panel → auto-refresh dashboard.
Done when: "Get from Outline" button works with live output; new Outline docs appear in Obsidian on next WebDAV sync.
Phase D — Pending Changes View
GET /changes: parses git diff outline..main --name-status into structured list. Shows modified / new / renamed / deleted with explanatory labels. Inline diff preview for modified files.
Done when: changes page accurately shows what Obsidian wrote via WebDAV.
Phase E — Push with Live Output
POST /push spawns outline_sync.py push. Same SSE pattern. Button disabled if conflicts exist. New-file creation flow (frontmatter written back → WebDAV serves updated file → Obsidian picks up IDs).
Done when: new Obsidian files appear in Outline; modified files update; frontmatter IDs land back in Obsidian vault.
Phase F — Conflict Resolution
GET /conflicts: lists conflict files. GET /diff/{path}: renders side-by-side HTML diff using difflib. POST /resolve: calls sync resolve. Card UX with per-conflict actions.
Done when: all conflicts resolvable via browser; resolved files served cleanly by WebDAV to Obsidian.
Phase G — History View
Render _sync_log.md as reverse-chronological HTML table.
13. Risks
| Risk | Mitigation |
|---|---|
| WebDAV sync and web UI push run simultaneously | Job lock in web UI: only one sync job at a time |
remotely-save overwrites conflict markers in Obsidian |
Pull always creates .conflict.md sidecar; original file left clean (conflict is in sidecar, not the working file) |
WebDAV serving .git/ directory |
Configure nginx WebDAV to deny access to .git/ path |
| New file created in Obsidian without a matching collection folder | Sync engine warns and skips; status page shows "unknown collection" |
User edits _sync_log.md in Obsidian |
.gitattributes union merge prevents conflicts; worst case: duplicate log entries |
| WebDAV password brute force | Endpoint bound to Tailscale interface only — not reachable from public internet |
remotely-save syncs .obsidian/ config files |
Configure plugin ignore list: .obsidian/, *.conflict.md |
14. Open Questions
-
Push trigger: Manual-only (button) or also auto-trigger after WebDAV sync completes? Manual is safer; auto-push requires detecting that
remotely-savefinished syncing (no event available). -
WebDAV auth: Basic auth (htpasswd) with Tailscale network isolation. Tailscale is the primary security boundary; basic auth is a fallback. Acceptable for single-user. ✅ Resolved.
-
Cron pull: Ofelia can run
sync pull --autohourly so Outline changes appear in Obsidian without clicking "Get from Outline". Recommended to enable. -
.git/in WebDAV: The vault root contains.git/. The WebDAV server must not serve it (security + Obsidian confusion). Verify nginx config denies/.git/access.
15. Summary
| Component | Technology | Purpose |
|---|---|---|
100.x.x.x (Tailscale) |
nginx-WebDAV container | Obsidian ↔ VPS file sync (VPN-only) |
sync.domverse.de |
FastAPI + HTMX | Control plane: pull/push/resolve |
/outline-vault/ |
git repo (shared volume) | Merge layer + history |
remotely-save |
Obsidian plugin | Local → WebDAV sync |
outline_sync.py |
existing sync engine | WebDAV vault ↔ Outline API |
| Ofelia | existing cron scheduler | Scheduled pull from Outline |