Hermit Sync: Local-first Apps with rqlite + Syncthing¶
Objective: Build a cross-platform desktop app (Tauri) that uses rqlite (Raft-replicated SQLite over HTTP) for local durability while Syncthing P2P syncs encrypted snapshots between machines. Result: tiny, resilient, air-gap-friendly CRUD with zero central server.
Architecture¶
flowchart LR
T[Tauri UI] -->|HTTP API| R[rqlite Cluster]
R -->|Raft Consensus| R2[rqlite Node 2]
R -->|Raft Consensus| R3[rqlite Node 3]
R -->|Snapshot Export| S[Syncthing Folder]
S -->|P2P Sync| S2[Remote Syncthing]
S2 -->|Snapshot Import| R4[Remote rqlite] Why: rqlite provides SQLite with Raft consensus for local durability, Syncthing enables P2P sync without central infrastructure, and Tauri creates tiny, secure desktop apps with Rust performance.
Docker Compose with Profiles¶
# docker-compose.yml
version: "3.9"
x-hc: &hc { interval: 5s, timeout: 3s, retries: 40 }
services:
rqlite-1:
image: rqlite/rqlite:7.21.4
command: ["-http-addr", "0.0.0.0:4001", "-raft-addr", "0.0.0.0:4002", "-node-id", "1", "-http-adv-addr", "localhost:4001", "-raft-adv-addr", "localhost:4002"]
ports: ["4001:4001", "4002:4002"]
volumes:
- rqlite_data_1:/rqlite
healthcheck: { test: ["CMD", "curl", "-sf", "http://localhost:4001/status"], <<: *hc }
rqlite-2:
image: rqlite/rqlite:7.21.4
command: ["-http-addr", "0.0.0.0:4003", "-raft-addr", "0.0.0.0:4004", "-node-id", "2", "-http-adv-addr", "localhost:4003", "-raft-adv-addr", "localhost:4004", "-join", "http://rqlite-1:4001"]
ports: ["4003:4003", "4004:4004"]
volumes:
- rqlite_data_2:/rqlite
depends_on:
rqlite-1: { condition: service_healthy }
rqlite-3:
image: rqlite/rqlite:7.21.4
command: ["-http-addr", "0.0.0.0:4005", "-raft-addr", "0.0.0.0:4006", "-node-id", "3", "-http-adv-addr", "localhost:4005", "-raft-adv-addr", "localhost:4006", "-join", "http://rqlite-1:4001"]
ports: ["4005:4005", "4006:4006"]
volumes:
- rqlite_data_3:/rqlite
depends_on:
rqlite-1: { condition: service_healthy }
syncthing:
image: syncthing/syncthing:1.27.0
profiles: ["p2p"]
ports: ["8384:8384", "22000:22000"]
volumes:
- syncthing_data:/var/syncthing
- ./syncthing/config:/var/syncthing/config
- ./syncthing/data:/var/syncthing/data
environment:
- STGUIADDRESS=0.0.0.0:8384
healthcheck: { test: ["CMD", "curl", "-sf", "http://localhost:8384/rest/system/ping"], <<: *hc }
# Snapshot exporter (profile)
exporter:
build: ./exporter
profiles: ["sync"]
environment:
RQLITE_URL: http://rqlite-1:4001
SYNC_FOLDER: /sync
volumes:
- ./sync:/sync
depends_on:
rqlite-1: { condition: service_healthy }
volumes:
rqlite_data_1:
rqlite_data_2:
rqlite_data_3:
syncthing_data:
Operator Recipes¶
# Core rqlite cluster
docker compose up -d
# Add P2P sync
docker compose --profile p2p up -d
# Add snapshot exporter
docker compose --profile sync up -d
Tauri Desktop App¶
Cargo.toml¶
[package]
name = "hermit-sync"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "1.5", features = [] }
[dependencies]
tauri = { version = "1.5", features = ["api-all"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
src/main.rs¶
use tauri::{command, generate_handler, generate_context, Manager};
use serde::{Deserialize, Serialize};
use reqwest::Client;
use std::collections::HashMap;
#[derive(Serialize, Deserialize)]
struct Note {
id: Option<i64>,
title: String,
body: String,
updated_at: String,
}
#[derive(Serialize, Deserialize)]
struct RqliteResponse {
results: Vec<HashMap<String, serde_json::Value>>,
}
#[command]
async fn get_notes() -> Result<Vec<Note>, String> {
let client = Client::new();
let response = client
.get("http://localhost:4001/db/query")
.json(&serde_json::json!({
"statements": [{"q": "SELECT * FROM notes ORDER BY updated_at DESC"}]
}))
.send()
.await
.map_err(|e| e.to_string())?;
let rqlite_response: RqliteResponse = response.json().await.map_err(|e| e.to_string())?;
let mut notes = Vec::new();
for row in rqlite_response.results {
notes.push(Note {
id: row.get("id").and_then(|v| v.as_i64()),
title: row.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
body: row.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
updated_at: row.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
});
}
Ok(notes)
}
#[command]
async fn add_note(title: String, body: String) -> Result<(), String> {
let client = Client::new();
let sql = format!(
"INSERT INTO notes(title, body, updated_at) VALUES('{}','{}', CURRENT_TIMESTAMP)",
title.replace("'", "''"),
body.replace("'", "''")
);
client
.post("http://localhost:4001/db/execute")
.json(&serde_json::json!({
"statements": [{"q": sql}]
}))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[command]
async fn update_note(id: i64, title: String, body: String) -> Result<(), String> {
let client = Client::new();
let sql = format!(
"UPDATE notes SET title='{}', body='{}', updated_at=CURRENT_TIMESTAMP WHERE id={}",
title.replace("'", "''"),
body.replace("'", "''"),
id
);
client
.post("http://localhost:4001/db/execute")
.json(&serde_json::json!({
"statements": [{"q": sql}]
}))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[command]
async fn delete_note(id: i64) -> Result<(), String> {
let client = Client::new();
let sql = format!("DELETE FROM notes WHERE id={}", id);
client
.post("http://localhost:4001/db/execute")
.json(&serde_json::json!({
"statements": [{"q": sql}]
}))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
fn main() {
tauri::Builder::default()
.invoke_handler(generate_handler![get_notes, add_note, update_note, delete_note])
.run(generate_context!())
.expect("error while running tauri application");
}
src-tauri/tauri.conf.json¶
{
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"devPath": "../dist",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "Hermit Sync",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.hermit.sync",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"security": {
"csp": null
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "Hermit Sync",
"width": 800,
"height": 600
}
]
}
}
Frontend (React + TypeScript)¶
package.json¶
{
"name": "hermit-sync-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^1.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^4.4.0"
}
}
src/App.tsx¶
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/tauri';
interface Note {
id?: number;
title: string;
body: string;
updated_at: string;
}
function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [newNote, setNewNote] = useState({ title: '', body: '' });
const [editingNote, setEditingNote] = useState<Note | null>(null);
const loadNotes = async () => {
try {
const result = await invoke<Note[]>('get_notes');
setNotes(result);
} catch (error) {
console.error('Failed to load notes:', error);
}
};
const addNote = async () => {
if (!newNote.title.trim()) return;
try {
await invoke('add_note', {
title: newNote.title,
body: newNote.body
});
setNewNote({ title: '', body: '' });
loadNotes();
} catch (error) {
console.error('Failed to add note:', error);
}
};
const updateNote = async (note: Note) => {
if (!note.id) return;
try {
await invoke('update_note', {
id: note.id,
title: note.title,
body: note.body
});
setEditingNote(null);
loadNotes();
} catch (error) {
console.error('Failed to update note:', error);
}
};
const deleteNote = async (id: number) => {
try {
await invoke('delete_note', { id });
loadNotes();
} catch (error) {
console.error('Failed to delete note:', error);
}
};
useEffect(() => {
loadNotes();
}, []);
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>Hermit Sync - Local Notes</h1>
<div style={{ marginBottom: '20px', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h3>Add New Note</h3>
<input
type="text"
placeholder="Title"
value={newNote.title}
onChange={(e) => setNewNote({ ...newNote, title: e.target.value })}
style={{ width: '100%', marginBottom: '10px', padding: '8px' }}
/>
<textarea
placeholder="Body"
value={newNote.body}
onChange={(e) => setNewNote({ ...newNote, body: e.target.value })}
style={{ width: '100%', height: '100px', marginBottom: '10px', padding: '8px' }}
/>
<button onClick={addNote} style={{ padding: '8px 16px' }}>
Add Note
</button>
</div>
<div>
<h3>Notes ({notes.length})</h3>
{notes.map((note) => (
<div key={note.id} style={{ marginBottom: '15px', padding: '15px', border: '1px solid #eee', borderRadius: '8px' }}>
{editingNote?.id === note.id ? (
<div>
<input
type="text"
value={editingNote.title}
onChange={(e) => setEditingNote({ ...editingNote, title: e.target.value })}
style={{ width: '100%', marginBottom: '10px', padding: '8px' }}
/>
<textarea
value={editingNote.body}
onChange={(e) => setEditingNote({ ...editingNote, body: e.target.value })}
style={{ width: '100%', height: '100px', marginBottom: '10px', padding: '8px' }}
/>
<button onClick={() => updateNote(editingNote)} style={{ marginRight: '10px', padding: '8px 16px' }}>
Save
</button>
<button onClick={() => setEditingNote(null)} style={{ padding: '8px 16px' }}>
Cancel
</button>
</div>
) : (
<div>
<h4>{note.title}</h4>
<p>{note.body}</p>
<small>Updated: {note.updated_at}</small>
<div style={{ marginTop: '10px' }}>
<button
onClick={() => setEditingNote(note)}
style={{ marginRight: '10px', padding: '8px 16px' }}
>
Edit
</button>
<button
onClick={() => note.id && deleteNote(note.id)}
style={{ padding: '8px 16px', backgroundColor: '#ff4444', color: 'white' }}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
export default App;
Syncthing Configuration¶
syncthing/config.xml¶
<?xml version="1.0" encoding="UTF-8"?>
<configuration version="39">
<folder id="hermit-sync" path="/var/syncthing/data" type="sendreceive" rescanIntervalS="3600" fsWatcherEnabled="true" fsWatcherDelayS="10" ignorePerms="false" autoNormalize="true">
<device id="device-1"></device>
<device id="device-2"></device>
<minDiskFree unit="%">1</minDiskFree>
<versioning type="simple" params="keep=10"></versioning>
<copiers>0</copiers>
<pullerMaxPendingKiB>0</pullerMaxPendingKiB>
<hashers>0</hashers>
<order>random</order>
<ignoreDelete>false</ignoreDelete>
<scanProgressIntervalS>0</scanProgressIntervalS>
<pullerPauseS>0</pullerPauseS>
<maxConflicts>10</maxConflicts>
<disableSparseFiles>false</disableSparseFiles>
<disableTempIndexes>false</disableTempIndexes>
<paused>false</paused>
<weakHashThresholdPct>25</weakHashThresholdPct>
<markerName>@marker</markerName>
<copyOwnershipFromParent>false</copyOwnershipFromParent>
<modTimeWindowS>0</modTimeWindowS>
<maxConcurrentWrites>2</maxConcurrentWrites>
<disableFsync>false</disableFsync>
<blockPullOrder>standard</blockPullOrder>
<copyRangeMethod>standard</copyRangeMethod>
<caseSensitiveFS>false</caseSensitiveFS>
<junctionsAsDirs>true</junctionsAsDirs>
</folder>
</configuration>
Snapshot Exporter¶
exporter/Dockerfile¶
FROM python:3.11-slim
RUN pip install requests schedule
WORKDIR /app
COPY exporter.py .
CMD ["python", "exporter.py"]
exporter/exporter.py¶
import os
import time
import schedule
import requests
import sqlite3
from datetime import datetime
class SnapshotExporter:
def __init__(self):
self.rqlite_url = os.getenv("RQLITE_URL", "http://localhost:4001")
self.sync_folder = os.getenv("SYNC_FOLDER", "/sync")
def export_snapshot(self):
"""Export rqlite database snapshot to Syncthing folder."""
try:
# Get database snapshot from rqlite
response = requests.get(f"{self.rqlite_url}/db/backup")
if response.status_code == 200:
# Save snapshot with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
snapshot_path = f"{self.sync_folder}/hermit_sync_{timestamp}.db"
with open(snapshot_path, 'wb') as f:
f.write(response.content)
print(f"Snapshot exported: {snapshot_path}")
# Keep only last 10 snapshots
self.cleanup_old_snapshots()
else:
print(f"Failed to export snapshot: {response.status_code}")
except Exception as e:
print(f"Error exporting snapshot: {e}")
def cleanup_old_snapshots(self):
"""Keep only the last 10 snapshots."""
import glob
snapshots = glob.glob(f"{self.sync_folder}/hermit_sync_*.db")
snapshots.sort()
if len(snapshots) > 10:
for old_snapshot in snapshots[:-10]:
os.remove(old_snapshot)
print(f"Removed old snapshot: {old_snapshot}")
def run(self):
"""Run the exporter with scheduled snapshots."""
# Export immediately
self.export_snapshot()
# Schedule hourly exports
schedule.every().hour.do(self.export_snapshot)
print("Snapshot exporter started - hourly exports scheduled")
while True:
schedule.run_pending()
time.sleep(60)
if __name__ == "__main__":
exporter = SnapshotExporter()
exporter.run()
Database Schema¶
Schema Setup¶
-- Create notes table
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Create index for performance
CREATE INDEX idx_notes_updated_at ON notes(updated_at);
Failure Modes & Runbook¶
Raft Quorum in Dev¶
- Problem: Single node failure breaks cluster
- Solution: Always run 3+ nodes, monitor cluster health
- Monitoring:
curl http://localhost:4001/status
Snapshot Frequency¶
- Problem: Too frequent snapshots consume bandwidth
- Solution: Adjust schedule based on data change rate
- Optimization: Use incremental snapshots for large datasets
Clock Skew¶
- Problem: Timestamps inconsistent across nodes
- Solution: Use NTP, monitor time synchronization
- Prevention: Set up time sync in Docker containers
Restore Collisions¶
- Problem: Multiple nodes restore from different snapshots
- Solution: Implement conflict resolution, use last-write-wins
- Prevention: Coordinate restore operations
TL;DR Runbook¶
# 1. Start rqlite cluster
docker compose up -d
# 2. Initialize database schema
curl -X POST http://localhost:4001/db/execute \
-d '{"statements": [{"q": "CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"}]}'
# 3. Add P2P sync
docker compose --profile p2p up -d
# 4. Add snapshot exporter
docker compose --profile sync up -d
# 5. Build and run Tauri app
cd hermit-sync
npm install
npm run tauri dev
# 6. Configure Syncthing
# - Open http://localhost:8384
# - Add remote device
# - Share hermit-sync folder
# 7. Test offline-first behavior
# - Create notes offline
# - Rejoin network
# - Watch peers converge
This tutorial provides the complete machinery for offline-first desktop apps with peer-to-peer sync. Each component is production-ready, copy-paste runnable, and designed for air-gap-friendly deployment without central infrastructure.