
Synch
Self-hosted encrypted vault synchronization for Obsidian
Synch is a self-hosted Obsidian Sync alternative built on Cloudflare (D1, Durable Objects, R2, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.
Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
A small top-level self-hosted deployment can fit Workers, D1, standard R2 and SQLite Durable Object free allowances. Hosted Polar/email/queue configuration is not required for this path. Vault media and retained versions can exceed storage quotas quickly.
Hosting requirements
- Use the top-level SELF_HOSTED=true configuration with your own AUTH_ALLOWED_EMAILS, BETTER_AUTH_SECRET and SYNC_TOKEN_SECRET.
- E2E encryption does not hide account, vault, blob-size or timing metadata from the service; no independent encryption or runtime security audit is claimed.
- Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested.
Sources checked 01/10/2026
Repository snapshot: d38fcfb. Hosting eligibility reflects the deployment documentation and listed assumptions.
- obsidian-sync ↗
<p align="center"> <a href="https://synch.run">Website</a> · <a href="https://synch.run/self-hosting">Cloudflare deployment</a> · <a href="https://synch.run/self-hosting-docker">Docker deployment</a> </p>
- workers ↗
oudflare can provision * resources for first-time setup flows. */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "synch-api", "main": "src/index.ts", "compatibility_date": "2026-04-14", "compatibility_flags": [ "nodejs_compat" ], // Vite builds the React MPA before dev, dry-run builds, and deploys. "build": { "command": "pnpm run build:public", "watch_dir": "./web" }, "migrations": [ { "new_sqlite_classes": [ "SyncCoordinator" ], "tag": "v1" } ], "as
- d1 ↗
DINATOR" } ] }, "r2_buckets": [ { "binding": "SYNC_BLOBS", "bucket_name": "synch-bucket" } ], "d1_databases": [ { "binding": "DB", "database_name": "synch-db", "migrations_dir": "drizzle" } ], "vars": { "SYNC_TOKEN_TTL_SECONDS": 120, "SELF_HOSTED": true }, "triggers": { "crons": ["*/5 * * * *"] }, "secrets": { "required": [ "AUTH_ALLOWED_EMAILS", "BETTER_AUTH_SECRET", "SYNC_TOKEN_SECRET" ] }, "env": { "managed": { "triggers": { "c
- r2 ↗
objects": { "bindings": [ { "class_name": "SyncCoordinator", "name": "SYNC_COORDINATOR" } ] }, "r2_buckets": [ { "binding": "SYNC_BLOBS", "bucket_name": "synch-bucket" } ], "d1_databases": [ { "binding": "DB", "database_name": "synch-db", "migrations_dir": "drizzle" } ], "vars": { "SYNC_TOKEN_TTL_SECONDS": 120, "SELF_HOSTED": true }, "triggers": { "crons": ["*/5 * * * *"] }, "secrets": { "required": [ "AUTH_ALLOWED_EMAILS", "BETTER_AUTH_SECR
- durable-objects ↗
": [ "**/*.sql" ], "fallthrough": true } ], "durable_objects": { "bindings": [ { "class_name": "SyncCoordinator", "name": "SYNC_COORDINATOR" } ] }, "r2_buckets": [ { "binding": "SYNC_BLOBS", "bucket_name": "synch-bucket" } ], "d1_databases": [ { "binding": "DB", "database_name": "synch-db", "migrations_dir": "drizzle" } ], "vars": { "SYNC_TOKEN_TTL_SECONDS": 120, "SELF_HOSTED": true }, "triggers": { "crons": ["*/5 * * * *"] }, "secrets": {
- free-tier-eligible ↗
/** * Shared Wrangler config for Synch API deployments. * * Top-level bindings are the public self-hosted deployment used by Deploy to * Cloudflare. They omit generated resource IDs so Cloudflare can provision * resources for first-time setup flows. */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "synch-api", "main": "src/index.ts", "compatibility_date": "2026-04-14", "compatibility_flags": [ "nodejs_compat" ], // Vite builds the React MPA before dev, dry-run builds, and deploys. "build": { "command": "pnpm run build:public", "watch_dir": "./web" }, "migrations": [ { "new_sqlite_classes": [ "SyncCoordinator" ], "tag": "v1" } ], "assets": { "directory": "./public", "html_handling": "drop-trailing-slash", "not_found_handling": "no
- free-tier-eligible ↗
| **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation |
- free-tier-eligible ↗
| Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |
- free-tier-eligible ↗
| Storage | 10 GB-month / month |
- free-tier-eligible ↗
Durable Objects are available both on Workers Free and Workers Paid plans.
- free-tier-eligible ↗
| Class A Operations | 1 million requests / month |
- free-tier-eligible ↗
| Class B Operations | 10 million requests / month |
- free-tier-eligible ↗
| Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows |
- free-tier-eligible ↗
| SQL Stored data <sup>5</sup> | 5 GB (total) | 5 GB-month, + $0.20/ GB-month |
- MIT ↗
MIT License Copyright (c) 2026 Synch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WI
- architecture ↗
/** * Shared Wrangler config for Synch API deployments. * * Top-level bindings are the public self-hosted deployment used by Deploy to * Cloudflare. They omit generated resource IDs so Cloudflare can provision * resources for first-time setup flows. */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "synch-api", "main": "src/index.ts", "compatibility_date": "2026-04-14", "compatibility_flags": [ "nodejs_compat" ], // Vite builds the React MPA before dev, dry-run builds, and deploys. "build": { "command": "pnpm run build:public", "watch_dir": "./web" }, "migrations": [ { "new_sqlite_classes": [ "SyncCoordinator" ], "tag": "v1" } ], "assets": { "directory": "./public", "html_handling": "drop-trailing-slash", "not_found_handling": "no
- architecture ↗
{ "$schema": "node_modules/wrangler/config-schema.json", "name": "synch-www", "compatibility_date": "2026-04-17", "assets": { "binding": "ASSETS", "directory": "./dist", "html_handling": "force-trailing-slash", "not_found_handling": "none" } }
- architecture ↗
oudflare can provision * resources for first-time setup flows. */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "synch-api", "main": "src/index.ts", "compatibility_date": "2026-04-14", "compatibility_flags": [ "nodejs_compat" ], // Vite builds the React MPA before dev, dry-run builds, and deploys. "build": { "command": "pnpm run build:public", "watch_dir": "./web" }, "migrations": [ { "new_sqlite_classes": [ "SyncCoordinator" ], "tag": "v1" } ], "as
- architecture ↗
DINATOR" } ] }, "r2_buckets": [ { "binding": "SYNC_BLOBS", "bucket_name": "synch-bucket" } ], "d1_databases": [ { "binding": "DB", "database_name": "synch-db", "migrations_dir": "drizzle" } ], "vars": { "SYNC_TOKEN_TTL_SECONDS": 120, "SELF_HOSTED": true }, "triggers": { "crons": ["*/5 * * * *"] }, "secrets": { "required": [ "AUTH_ALLOWED_EMAILS", "BETTER_AUTH_SECRET", "SYNC_TOKEN_SECRET" ] }, "env": { "managed": { "triggers": { "c
- architecture ↗
objects": { "bindings": [ { "class_name": "SyncCoordinator", "name": "SYNC_COORDINATOR" } ] }, "r2_buckets": [ { "binding": "SYNC_BLOBS", "bucket_name": "synch-bucket" } ], "d1_databases": [ { "binding": "DB", "database_name": "synch-db", "migrations_dir": "drizzle" } ], "vars": { "SYNC_TOKEN_TTL_SECONDS": 120, "SELF_HOSTED": true }, "triggers": { "crons": ["*/5 * * * *"] }, "secrets": { "required": [ "AUTH_ALLOWED_EMAILS", "BETTER_AUTH_SECR
- architecture ↗
": [ "**/*.sql" ], "fallthrough": true } ], "durable_objects": { "bindings": [ { "class_name": "SyncCoordinator", "name": "SYNC_COORDINATOR" } ] }, "r2_buckets": [ { "binding": "SYNC_BLOBS", "bucket_name": "synch-bucket" } ], "d1_databases": [ { "binding": "DB", "database_name": "synch-db", "migrations_dir": "drizzle" } ], "vars": { "SYNC_TOKEN_TTL_SECONDS": 120, "SELF_HOSTED": true }, "triggers": { "crons": ["*/5 * * * *"] }, "secrets": {
Upstream screenshot · hjinco/synch repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Obsidian vault file synchronization, encrypted history and conflict handling; no official-service compatibility, migration guarantee or complete Obsidian Sync parity.
See supporting source ↗How it works
The shape of Synch on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit d38fcfbf44b9. Files were read as data; upstream applications and CI jobs were not executed.
Partial source coverage: 67 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.
Deployment configuration · 2 files
Cloudflare Workers · compatibility 2026-04-14
synch-api · default
Entrypoint: src/index.ts
Build: pnpm run build:public
Static assets: ./public · none
Cron triggers (UTC): */5 * * * *
DB→ D1SYNC_BLOBS→ R2SYNC_COORDINATOR→ Durable Objects · class SyncCoordinatorStatic assets→ Static assets
synch-api · env.managed
Inherited from default: main, build, compatibility_date, compatibility_flags
Entrypoint: src/index.ts
Build: pnpm run build:public
Cron triggers (UTC): */5 * * * * · 0 */6 * * *
DB→ D1SYNC_BLOBS→ R2SYNC_COORDINATOR→ Durable Objects · class SyncCoordinatorVAULT_PURGE_QUEUE→ Queues (producer) · queue synch-vault-purgePOLICY_REFRESH_QUEUE→ Queues (producer) · queue synch-policy-refreshRETENTION_NOTIFICATION_QUEUE→ Queues (producer) · queue synch-retention-notificationsynch-vault-purge→ Queues (consumer) · queue synch-vault-purgesynch-policy-refresh→ Queues (consumer) · queue synch-policy-refreshsynch-retention-notification→ Queues (consumer) · queue synch-retention-notificationEMAIL→ Send Email
Cloudflare Workers · compatibility 2026-04-17
synch-www · default
Static assets: ./dist · none
ASSETS→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L43 · apiError calls (conditional paths may differ): JSON.stringify
- L60 · domainApiError calls (conditional paths may differ): JSON.stringify
- L83 · onError calls (conditional paths may differ): error.getResponse, c.json, logServerError
- L119 · logServerError calls (conditional paths may differ): console.error, formatRequestForLog, formatErrorForLog
- L136 · formatErrorForLog calls (conditional paths may differ): formatErrorCauseForLog
- L329 · internalErrorResponse calls (conditional paths may differ): Response.json
- L350 · mapCoordinatorRpcError calls (conditional paths may differ): apiError, rpcErrorStatus, rpcPublicCode, rpcErrorMessage
- L366 · rpcErrorMessage calls (conditional paths may differ): String
- L404 · formatLogError calls (conditional paths may differ): String
- L14 · createCoordinatorRuntime calls (conditional paths may differ): readCloudflareProfile, createDb, createCoordinatorApplication, ctx.getWebSockets, readPolarProductIdsByPlanId, ctx.blockConcurrencyWhile, storage.migrate, maintenanceScheduler.ensureArmed
Environment references: env.DB · env.SYNC_BLOBS · env.SYNC_TOKEN_SECRET · env.SYNC_TOKEN_TTL_SECONDS
- L13 · createRuntimeApp calls (conditional paths may differ): parseCloudflareHttpConfig, readPolarProductIdsByPlanId, createApiApplication, createDb, requireBinding, Array.from, requireNonBlankStringBinding, createSubscriptionPolicyRefreshQueue, queue.enqueueOrganizationPolicyRefresh, application.app.fetch
Environment references: env.DB · env.SYNC_BLOBS · env.SYNC_COORDINATOR · env.VAULT_PURGE_QUEUE · env.GOOGLE_CLIENT_ID · env.GOOGLE_CLIENT_SECRET · env.GITHUB_CLIENT_ID · env.GITHUB_CLIENT_SECRET · env.EMAIL · env.AUTH_EMAIL_FROM · env.AUTH_ALLOWED_EMAILS · env.SYNC_TOKEN_SECRET · env.ADMIN_TOKEN · env.SYNC_TOKEN_TTL_SECONDS · env.POLAR_ACCESS_TOKEN · env.POLAR_WEBHOOK_SECRET · env.POLICY_REFRESH_QUEUE
- L29 · createQueueConsumer calls (conditional paths may differ): readCloudflareProfile, createDb, createSubscriptionFeature, isCommunityEdition, readPolarProductIdsByPlanId, createVaultFeature, createSubscriptionRefreshFeature
Environment references: env.DB · env.SYNC_COORDINATOR · env.RETENTION_NOTIFICATION_QUEUE · env.EMAIL · env.AUTH_EMAIL_FROM
- L17 · runScheduledTasks calls (conditional paths may differ): Date.now, runSharingRefreshSchedule, runVaultRetentionSchedule
- L26 · runSharingRefreshSchedule calls (conditional paths may differ): createDb, flushSharingRefreshes, sharingStore.pruneExpiredRequests
- L34 · runVaultRetentionSchedule calls (conditional paths may differ): Date.now, readCloudflareProfile, isCommunityEdition, createDb, createSubscriptionFeature, readPolarProductIdsByPlanId, createVaultRetentionFeature, retention.run
Environment references: env.DB · env.SYNC_COORDINATOR · env.VAULT_PURGE_QUEUE
Environment references: env.POLAR_PLUS_MONTHLY_PRODUCT_ID · env.POLAR_PLUS_ANNUAL_PRODUCT_ID · env.POLAR_STARTER_MONTHLY_PRODUCT_ID · env.POLAR_STARTER_ANNUAL_PRODUCT_ID
- L75 · createCoordinatorApplication calls (conditional paths may differ): createSubscriptionFeature, isCommunityEdition, createVaultOrganizationReader, sharingStore.vault, sharingStore.accessVersions, sharingAccess.isSuspended, Math.max, createSyncTokenFeature, syncAccess.require, vaultOrganizationReader.readVaultOrganizationId, subscriptionFeature.policyReader.readOrganizationPolicy, bindCoordinatorApi, syncAccess.authorizeVerified, createCoordinatorApp, syncTokenService.verifySyncToken, syncAccess.refresh, healthService.dispose
- L57 · parseCloudflareHttpConfig calls (conditional paths may differ): readCloudflareProfile, resolveUrlBinding, parseBooleanBinding, resolveOriginBinding, capabilitiesFor
- L81 · readCloudflareProfile calls (conditional paths may differ): createCloudflareProfile, parseBooleanBinding
- L89 · parseBooleanBinding calls (conditional paths may differ): value.trim
Environment references: env.BETTER_AUTH_URL · env.DEV_MODE · env.WWW_BASE_URL · env.POLAR_SANDBOX · env.SELF_HOSTED
- L104 · isFixedLengthStreamError calls (conditional paths may differ): error.message.includes
- L241 · maintenanceRetryDelayMs calls (conditional paths may differ): Math.min, Math.max
- L248 · maintenanceJobDueAt calls (conditional paths may differ): Math.ceil
- L255 · formatCompactError calls (conditional paths may differ): error.message.trim, error.message.slice, slice, String
- L262 · logMaintenanceJobError calls (conditional paths may differ): console.error, formatLogError
- L278 · formatLogError calls (conditional paths may differ): String
- L232 · readSerializedAttachment calls (conditional paths may differ): socket.deserializeAttachment
- L236 · selectSyncWebSocketProtocol calls (conditional paths may differ): request.headers.get, value.trim
- L243 · isClosedWebSocketSendError calls (conditional paths may differ): test
- L247 · isClosedWebSocketCloseError calls (conditional paths may differ): test
- L95 · openExclusiveSqliteConnection calls (conditional paths may differ): sqlite.pragma, sqlite.exec, sqlite.close
Build and deployment pipeline · 7 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: workflow_dispatch, pull_request, push
detect · no job dependencies declared
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - ./.github/actions/detect-affected
./.github/actions/detect-affected
api · after detect
Condition: needs.detect.outputs.api-affected == 'true'
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Shell command
pnpm check:api-lockfile - Shell command
pnpm -C apps/api check:vault-crypto - Shell command
pnpm -C apps/api typecheck - Shell command
pnpm -C apps/api test:web - Shell command
pnpm -C apps/api test:unit - Shell command
pnpm -C apps/api test:e2e:node - Shell command
pnpm -C apps/api test:integration:cloudflare - Build Node artifact
pnpm -C apps/api build:node - Build Cloudflare community artifact
pnpm -C apps/api build:cloudflare:community - Verify standalone Cloudflare template
template_dir="$(mktemp -d)" trap 'rm -rf "$template_dir"' EXIT tar \ --exclude=node_modules \ --exclude=.wrangler \ --exclude=dist \ --exclude=public \ -C apps/api -cf - . | tar -C "$template_dir" -xf - pnpm --dir "$template_dir" install --frozen-lockfile pnpm --dir "$template_dir" run build:cloudflare:community - Build Cloudflare managed artifact
pnpm -C apps/api build:cloudflare:managed - Build Docker image
docker build -f apps/api/Dockerfile -t synch-api:ci . - Smoke test Docker image
docker run --detach --name synch-api-smoke \ --publish 127.0.0.1:8787:8787 \ --env PUBLIC_URL=http://127.0.0.1:8787 \ --env BETTER_AUTH_SECRET=ci-better-auth-secret-that-is-long-enough \ --env SYNC_TOKEN_SECRET=ci-sync-token-secret-that-is-long-enough \ --env AUTH_ALLOWED_EMAILS=ci@example.com \ synch-api:ci cleanup() { docker rm --force synch-api-smoke >/dev/null 2>&1 || true; } trap cleanup EXIT for _ in $(seq 1 30); do if curl --fail --silent http://127.0.0.1:8787/health >/dev/null; then exi…
Triggers: workflow_dispatch
sync-e2e · no job dependencies declared
Uses: ./.github/workflows/sync-e2e.yml
deploy · after sync-e2e
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Shell command
pnpm check:api-lockfile - Shell command
pnpm -C apps/api check:vault-crypto - Shell command
pnpm -C apps/api typecheck - Shell command
pnpm -C apps/api test:web - Shell command
pnpm -C apps/api test:unit - Shell command
pnpm -C apps/api test:e2e:node - Shell command
pnpm -C apps/api test:integration:cloudflare - Build Node artifact
pnpm -C apps/api build:node - Build Cloudflare managed artifact
pnpm -C apps/api build:cloudflare:managed - Validate deployment configuration
test -n "$CLOUDFLARE_API_TOKEN" || { echo "Set CLOUDFLARE_API_TOKEN before deploying." exit 1 } test -n "$CLOUDFLARE_ACCOUNT_ID" || { echo "Set CLOUDFLARE_ACCOUNT_ID before deploying." exit 1 } - Deploy API
pnpm -C apps/api deploy:cloudflare:managed
Triggers: pull_request, push
detect · no job dependencies declared
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - ./.github/actions/detect-affected
./.github/actions/detect-affected
obsidian-plugin · after detect
Condition: needs.detect.outputs.plugin-affected == 'true'
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Typecheck affected packages
pnpm -r --if-present $SINCE_FILTER --filter '!@synch/api' --filter '!@synch/cli' run typecheck - Test affected packages
pnpm -r --if-present $SINCE_FILTER --filter '!@synch/api' --filter '!@synch/cli' run test -- --run - Shell command
pnpm -C apps/obsidian-plugin build
Triggers: workflow_dispatch
sync-e2e · no job dependencies declared
Uses: ./.github/workflows/sync-e2e.yml
release · after sync-e2e
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Validate release configuration
test -n "$API_BASE_URL" || { echo "Set repository variable OBSIDIAN_PLUGIN_API_BASE_URL before releasing." exit 1 } - Bump version
VERSION="$(pnpm -C apps/obsidian-plugin bump:version ${{ inputs.version_bump }} | tail -n 1)" pnpm -C apps/obsidian-plugin sync:metadata echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$VERSION" >> "$GITHUB_OUTPUT" - Shell command
pnpm -C apps/obsidian-plugin test - Shell command
pnpm -C apps/obsidian-plugin build - Generate release artifact attestation
actions/attest@v4 - Prepare release notes
pnpm -C apps/obsidian-plugin release-notes:prepare ${{ steps.version.outputs.version }} - Commit release metadata
pnpm -C apps/obsidian-plugin release-notes:reset git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add apps/obsidian-plugin/package.json apps/obsidian-plugin/manifest.json apps/obsidian-plugin/versions.json apps/obsidian-plugin/release-notes/next.md manifest.json versions.json git commit -m "chore: release obsidian plugin ${{ steps.version.outputs.version }}" - Create tag
git tag "${{ steps.version.outputs.tag }}" git push origin HEAD:"${GITHUB_REF_NAME}" git push origin "${{ steps.version.outputs.tag }}" - Create GitHub release
softprops/action-gh-release@v3
Triggers: issue_comment, workflow_dispatch
benchmark · no job dependencies declared
Condition: github.event_name == 'workflow_dispatch' || ( github.event.issue.pull_request && github.event.issue.state == 'open' && contains(github.event.comment.body, '@synch bench') && ( github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' ) )
- Checkout requested revision
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Compare base and candidate with identical measurement definitions
pnpm bench:sync:compare -- --base HEAD^1 --candidate HEAD --runtime "${{ matrix.runtime }}" --suite "$SUITE" --output "$RUNNER_TEMP/sync-benchmark-${{ matrix.runtime }}.json" - Preserve raw results
actions/upload-artifact@v6Condition: always()
publish · after benchmark
Condition: always() && needs.benchmark.result != 'skipped' && github.event_name == 'issue_comment'
- Checkout trusted report formatter
actions/checkout@v6 - actions/setup-node@v6
actions/setup-node@v6 - actions/download-artifact@v7
actions/download-artifact@v7 - Validate and format reports
node .github/scripts/format-sync-client-benchmark.mjs "$RUNNER_TEMP/sync-benchmark" "$RUNNER_TEMP/sync-benchmark-comment.md" - Publish benchmark result
comment_id="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("<!-- synch-sync-client-benchmark -->")) | .id' | tail -n 1)" comment_body="$(cat "$RUNNER_TEMP/sync-benchmark-comment.md")" if [ -n "$comment_id" ]; then gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" -f body="$comment_body" else gh api --method POST "repos/${GITHUB_REPOSITORY}… - Fail on missing or invalid reports
exit 1Condition: steps.format.outcome == 'failure' || needs.benchmark.result != 'success'
Triggers: workflow_call, workflow_dispatch
sync-e2e · no job dependencies declared
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Shell command
pnpm -C tests/sync-e2e typecheck - Verify encrypted sync and conflict recovery
pnpm -C tests/sync-e2e test:e2e
Triggers: workflow_dispatch
deploy · no job dependencies declared
- actions/checkout@v6
actions/checkout@v6 - pnpm/action-setup@v6
pnpm/action-setup@v6 - actions/setup-node@v6
actions/setup-node@v6 - Shell command
pnpm install --frozen-lockfile - Validate deployment configuration
test -n "$CLOUDFLARE_API_TOKEN" || { echo "Set CLOUDFLARE_API_TOKEN before deploying." exit 1 } test -n "$CLOUDFLARE_ACCOUNT_ID" || { echo "Set CLOUDFLARE_ACCOUNT_ID before deploying." exit 1 } test -n "$PUBLIC_API_URL" || { echo "Set the PUBLIC_API_URL GitHub Actions variable before deploying." exit 1 } - Build WWW
pnpm -C apps/www build - Deploy WWW
pnpm -C apps/www deploy:built
build: pnpm -r --if-present buildbuild:www: pnpm -C apps/www builddeploy:www: pnpm -C apps/www deploybuild:plugin: pnpm -C apps/obsidian-plugin buildrelease:plugin: pnpm -C apps/obsidian-plugin release:prepare
build:node: pnpm run build:public && node ./scripts/build-node.mjsbuild:public: node ./scripts/build-public.mjsbuild:cloudflare:community: pnpm run build:public && wrangler deploy --dry-run --env "" --outdir dist/cloudflare-communitybuild:cloudflare:managed: pnpm run build:public && wrangler deploy --dry-run --env managed --outdir dist/cloudflare-manageddeploy:cloudflare:community: pnpm run build:public && wrangler deploy && pnpm run db:migrate:cloudflare:communitydeploy:cloudflare:managed: pnpm run build:public && wrangler deploy --env managed && pnpm run db:migrate:cloudflare:manageddeploy: pnpm run deploy:cloudflare:communitybuild:storybook: storybook build --disable-telemetry
build: tsgo --noEmit --skipLibCheck && node esbuild.config.mjs
build: tsgo --noEmit --skipLibCheck && node esbuild.config.mjs productionrelease-notes:prepare: node scripts/prepare-release-notes.mjsrelease-notes:reset: node scripts/reset-release-notes.mjsrelease:prepare: pnpm run sync:metadata && pnpm run build
build: astro builddeploy: pnpm build && wrangler deploydeploy:built: wrangler deploybuild:storybook: storybook build --disable-telemetry
Repository README
View original on GitHub ↗Full upstream document by @hjinco · README.md · snapshot d38fcfb
Synch
End-to-end encrypted sync for Obsidian.
Website · Cloudflare deployment · Docker deployment
English | 한국어 | 日本語 | Deutsch | 简体中文 | 繁體中文
Keep your Obsidian vault in sync across devices with local encryption, version history, and conflict-safe file handling.
Synch is an independent community plugin and service. It is not affiliated with Obsidian.
Why Synch?
- Private by design — Vault data is encrypted on your device before upload.
- Fast sync — Changes are detected frequently and synced across devices.
- Recoverable — Restore previous versions and deleted files from encrypted history.
- Conflict-safe — Non-overlapping Markdown edits can be merged automatically.
- Your choice of hosting — Use Synch Cloud or run your own Synch server.
How it works
flowchart LR
device["Your device"] --> encrypt["Encrypt vault data locally"]
encrypt --> server["Synch Cloud or your self-hosted server"]
server --> other["Download and decrypt on another device"]
The sync service stores encrypted file blobs and encrypted sync metadata. It is designed so that the hosted service cannot read your plaintext notes, plaintext file paths, or vault keys.
Compare Obsidian sync options
Every option has a different balance of convenience, control, and setup effort.
| Option | Encryption | Storage model | Conflict handling | Best fit |
|---|---|---|---|---|
| Synch | Device-side E2EE | Synch Cloud or self-hosted | Automatically merges non-overlapping Markdown edits; preserves overlapping conflicts | Users who want a simple, open-source, privacy-focused workflow |
| Obsidian Sync | E2EE by default; standard encryption is also available | Obsidian-hosted | Official Obsidian integration and sync history | Users who prefer the official hosted service |
| Self-hosted LiveSync | E2EE | Self-hosted CouchDB, object storage, or optional WebRTC | Automatically merges simple conflicts | Users who want maximum backend control |
| Remotely Save | Optional password-based E2EE | Your S3, WebDAV, Dropbox, OneDrive, Google Drive, and other storage | Basic conflict detection; advanced smart conflict handling is available in Pro | Users who already have a preferred storage provider |
This comparison is intentionally high-level. Check each project's current documentation and settings before migrating an important vault.
Features
- Near-instant synchronization
- Encrypted version history
- Deleted file recovery
- Automatic Markdown conflict merging
- Conflict copies when edits overlap
- Markdown files enabled by default
- Images, audio, video, and PDF files enabled by default
- Additional file and folder exclusions
- Hosted Synch Cloud
- Custom API URLs for self-hosted deployments
- Desktop and mobile Obsidian support
Get started
Synch Cloud
- Open Settings → Community plugins in Obsidian.
- Turn off Restricted mode and select Browse.
- Search for Synchrun.
- Install and enable the plugin.
- Open Synchrun's settings and sign in.
- Create or connect a remote vault.
Once connected, keep Obsidian open while Synch uploads local changes and downloads remote changes.
Self-hosted Synch
The Cloudflare deployment guide deploys Synch to your own Cloudflare account. For a non-Cloudflare deployment, use the Docker/systemd guide.
You can run Synch on:
- Cloudflare
- Docker
- Your own hardware with systemd
See the deployment guides:
After deployment, set the custom API base URL in the plugin settings.
Safety notes
Always create a full backup of your vault before:
- Installing a new synchronization provider
- Migrating from another sync solution
- Changing encryption settings
- Resetting or reconnecting a remote vault
Do not run multiple synchronization providers against the same vault unless you fully understand how their file watchers and conflict resolution interact.
Disclosures
Show disclosures
This section is provided for Obsidian developer policy review and for users who want to understand what the plugin does before installing it.
Account requirements
Synch requires a Synch account to use the hosted sync service. The account is used to authenticate devices, create and connect remote vaults, issue sync tokens, enforce storage limits, and manage service access.
Network use
Synch connects to the configured Synch API base URL over HTTPS and WebSocket
connections. For the hosted service, this is Synch-operated infrastructure.
The default hosted API endpoint is https://api.synch.run, and realtime sync
uses wss://api.synch.run WebSocket connections. The plugin uses network
requests to:
- Sign in and maintain an authenticated device session.
- Create, list, and connect remote vaults.
- Upload encrypted file blobs and encrypted sync metadata.
- Download encrypted file blobs and encrypted sync metadata.
- Exchange realtime sync messages over WebSocket connections.
- Read account, billing, quota, storage, and sync status.
Synch-hosted infrastructure uses third-party providers, including Cloudflare for hosting, storage, networking, databases, queues, and related infrastructure. Billing is handled by Polar.
Data sent to Synch
Vault file contents and file path metadata are encrypted on your device before they are uploaded. Synch stores encrypted blobs and encrypted sync metadata and is designed so that the hosted service cannot read your plaintext notes, plaintext file paths, or plaintext vault keys.
End-to-end encryption does not hide all operational metadata. Synch may process account information, vault identifiers and names, organization and membership records, local vault identifiers, blob identifiers, file sizes, storage usage, timestamps, sync cursors, session information, IP addresses, User-Agent strings, billing identifiers for hosted subscriptions, and similar operational metadata.
Local vault access
Synch reads and writes files inside the current Obsidian vault so it can sync selected vault files. It stores plugin settings with Obsidian's plugin data API, stores the device session token with Obsidian's secret storage API, and stores local sync state in browser IndexedDB.
Synch does not intentionally read or write files outside the current Obsidian vault.
Payments
The hosted service offers free and paid subscription plans. The current paid hosted plan is Sync Starter, available with monthly or annual billing. Payment processing and subscription management are handled by Polar.
Telemetry, ads, and privacy
The Synch Obsidian plugin does not include client-side telemetry and does not show ads. The hosted service may process operational logs and service metadata needed to run, secure, troubleshoot, and improve the service.
For details, read the hosted service legal documents:
Contributing
Issues, bug reports, documentation improvements, and pull requests are welcome. See the contributing guide for more about when to open an issue or a pull request.
License
Synch is open source under the MIT License.
Frequently asked about Synch
What is Synch?+
Synch is a self-hosted Obsidian Sync alternative built on the Cloudflare developer platform. Self-hosted encrypted vault synchronization for Obsidian
What does Synch replace?+
Synch is listed as an alternative to Obsidian Sync. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does Synch use?+
Synch is built on D1, Durable Objects, R2, Workers.
How much does Synch cost to run?+
A small top-level self-hosted deployment can fit Workers, D1, standard R2 and SQLite Durable Object free allowances. Hosted Polar/email/queue configuration is not required for this path. Vault media and retained versions can exceed storage quotas quickly. Use the top-level SELF_HOSTED=true configuration with your own AUTH_ALLOWED_EMAILS, BETTER_AUTH_SECRET and SYNC_TOKEN_SECRET. E2E encryption does not hide account, vault, blob-size or timing metadata from the service; no independent encryption or runtime security audit is claimed. Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested. Check current Cloudflare pricing before deploying.
Is Synch open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/hjinco/synch/d38fcfbf44b9f0c8e319dd42e74b17f6ad61f8f1/LICENSE. Source code and contributor credit are available at https://github.com/hjinco/synch.


Discussion · 0
sign in to comment →