Cloudsteading
Synch overview

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

@hjinco

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.
Check current pricing ↗
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.

external SaaS target
varies

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 ↗
Public interface
Configured entry points2
synch-api
apps/api/wrangler.jsonc
synch-www
apps/www/wrangler.jsonc
↓
App
synch-api
entry
Cloudflare Workers
Entrypoint: src/index.tsConfigured cron (UTC): */5 * * * *
synch-www
entry
Cloudflare Workers
↓

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
apps/api/wrangler.jsonc ↗

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 → D1
  • SYNC_BLOBS → R2
  • SYNC_COORDINATOR → Durable Objects · class SyncCoordinator
  • Static 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 → D1
  • SYNC_BLOBS → R2
  • SYNC_COORDINATOR → Durable Objects · class SyncCoordinator
  • VAULT_PURGE_QUEUE → Queues (producer) · queue synch-vault-purge
  • POLICY_REFRESH_QUEUE → Queues (producer) · queue synch-policy-refresh
  • RETENTION_NOTIFICATION_QUEUE → Queues (producer) · queue synch-retention-notification
  • synch-vault-purge → Queues (consumer) · queue synch-vault-purge
  • synch-policy-refresh → Queues (consumer) · queue synch-policy-refresh
  • synch-retention-notification → Queues (consumer) · queue synch-retention-notification
  • EMAIL → Send Email
apps/www/wrangler.jsonc ↗

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.

apps/api/src/index.ts ↗
  • L11 · fetch handler exported · calls fetch, createRuntimeApp, logServerError, Response.json
  • L25 · queue handler exported · calls handleBatch, createQueueConsumer
  • L28 · scheduled handler exported · calls runScheduledTasks
apps/api/src/errors.ts ↗
  • 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
apps/api/src/sync-coordinator/adapters/inbound/durable-object-rpc/sync-coordinator.ts ↗
  • 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
apps/api/src/runtime/coordinator.ts ↗
  • 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

apps/api/src/runtime/http.ts ↗
  • 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

apps/api/src/runtime/queue.ts ↗
  • 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

apps/api/src/runtime/scheduled.ts ↗
  • 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

apps/api/src/sync-coordinator/adapters/inbound/websocket/protocol.ts ↗
  • L215 · parseClientControlMessage calls (conditional paths may differ): clientControlMessageSchema.safeParse
  • L219 · formatClientControlMessageError calls (conditional paths may differ): issue.path.join
apps/api/src/billing/adapters/outbound/product-ids.ts ↗

    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

    apps/api/src/composition/create-coordinator-application.ts ↗
    • 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
    apps/api/src/config/cloudflare.ts ↗
    • 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

    apps/api/src/db/client.ts ↗
    • L35 · createDb calls (conditional paths may differ): drizzleD1
    • L41 · createLibsqlDb calls (conditional paths may differ): drizzleLibsql
    • L47 · pingDatabase calls (conditional paths may differ): db.run
    apps/api/src/sync-blob-transfer/adapters/outbound/r2-object-storage.ts ↗
    • L104 · isFixedLengthStreamError calls (conditional paths may differ): error.message.includes
    apps/api/src/sync-coordinator/adapters/outbound/scheduler/maintenance-scheduler.ts ↗
    • 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
    apps/api/src/sync-coordinator/adapters/outbound/socket/durable-object-service.ts ↗
    • 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
    apps/api/src/sync-coordinator/adapters/outbound/sqlite/storage-handle.ts ↗
    • 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.

    API CI · .github/workflows/api-ci.yml ↗

    Triggers: workflow_dispatch, pull_request, push

    detect · no job dependencies declared

    1. actions/checkout@v6actions/checkout@v6
    2. pnpm/action-setup@v6pnpm/action-setup@v6
    3. ./.github/actions/detect-affected./.github/actions/detect-affected

    api · after detect

    Condition: needs.detect.outputs.api-affected == 'true'

    1. actions/checkout@v6actions/checkout@v6
    2. pnpm/action-setup@v6pnpm/action-setup@v6
    3. actions/setup-node@v6actions/setup-node@v6
    4. Shell commandpnpm install --frozen-lockfile
    5. Shell commandpnpm check:api-lockfile
    6. Shell commandpnpm -C apps/api check:vault-crypto
    7. Shell commandpnpm -C apps/api typecheck
    8. Shell commandpnpm -C apps/api test:web
    9. Shell commandpnpm -C apps/api test:unit
    10. Shell commandpnpm -C apps/api test:e2e:node
    11. Shell commandpnpm -C apps/api test:integration:cloudflare
    12. Build Node artifactpnpm -C apps/api build:node
    13. Build Cloudflare community artifactpnpm -C apps/api build:cloudflare:community
    14. Verify standalone Cloudflare templatetemplate_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
    15. Build Cloudflare managed artifactpnpm -C apps/api build:cloudflare:managed
    16. Build Docker imagedocker build -f apps/api/Dockerfile -t synch-api:ci .
    17. Smoke test Docker imagedocker 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…
    Deploy API · .github/workflows/api-deploy.yml ↗

    Triggers: workflow_dispatch

    sync-e2e · no job dependencies declared

    Uses: ./.github/workflows/sync-e2e.yml

      deploy · after sync-e2e

      1. actions/checkout@v6actions/checkout@v6
      2. pnpm/action-setup@v6pnpm/action-setup@v6
      3. actions/setup-node@v6actions/setup-node@v6
      4. Shell commandpnpm install --frozen-lockfile
      5. Shell commandpnpm check:api-lockfile
      6. Shell commandpnpm -C apps/api check:vault-crypto
      7. Shell commandpnpm -C apps/api typecheck
      8. Shell commandpnpm -C apps/api test:web
      9. Shell commandpnpm -C apps/api test:unit
      10. Shell commandpnpm -C apps/api test:e2e:node
      11. Shell commandpnpm -C apps/api test:integration:cloudflare
      12. Build Node artifactpnpm -C apps/api build:node
      13. Build Cloudflare managed artifactpnpm -C apps/api build:cloudflare:managed
      14. Validate deployment configurationtest -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 }
      15. Deploy APIpnpm -C apps/api deploy:cloudflare:managed
      Obsidian Plugin CI · .github/workflows/obsidian-plugin-ci.yml ↗

      Triggers: pull_request, push

      detect · no job dependencies declared

      1. actions/checkout@v6actions/checkout@v6
      2. pnpm/action-setup@v6pnpm/action-setup@v6
      3. ./.github/actions/detect-affected./.github/actions/detect-affected

      obsidian-plugin · after detect

      Condition: needs.detect.outputs.plugin-affected == 'true'

      1. actions/checkout@v6actions/checkout@v6
      2. pnpm/action-setup@v6pnpm/action-setup@v6
      3. actions/setup-node@v6actions/setup-node@v6
      4. Shell commandpnpm install --frozen-lockfile
      5. Typecheck affected packagespnpm -r --if-present $SINCE_FILTER --filter '!@synch/api' --filter '!@synch/cli' run typecheck
      6. Test affected packagespnpm -r --if-present $SINCE_FILTER --filter '!@synch/api' --filter '!@synch/cli' run test -- --run
      7. Shell commandpnpm -C apps/obsidian-plugin build
      Release Obsidian plugin · .github/workflows/release-obsidian-plugin.yml ↗

      Triggers: workflow_dispatch

      sync-e2e · no job dependencies declared

      Uses: ./.github/workflows/sync-e2e.yml

        release · after sync-e2e

        1. actions/checkout@v6actions/checkout@v6
        2. pnpm/action-setup@v6pnpm/action-setup@v6
        3. actions/setup-node@v6actions/setup-node@v6
        4. Shell commandpnpm install --frozen-lockfile
        5. Validate release configurationtest -n "$API_BASE_URL" || { echo "Set repository variable OBSIDIAN_PLUGIN_API_BASE_URL before releasing." exit 1 }
        6. Bump versionVERSION="$(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"
        7. Shell commandpnpm -C apps/obsidian-plugin test
        8. Shell commandpnpm -C apps/obsidian-plugin build
        9. Generate release artifact attestationactions/attest@v4
        10. Prepare release notespnpm -C apps/obsidian-plugin release-notes:prepare ${{ steps.version.outputs.version }}
        11. Commit release metadatapnpm -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 }}"
        12. Create taggit tag "${{ steps.version.outputs.tag }}" git push origin HEAD:"${GITHUB_REF_NAME}" git push origin "${{ steps.version.outputs.tag }}"
        13. Create GitHub releasesoftprops/action-gh-release@v3
        Sync Benchmark · .github/workflows/sync-client-benchmark.yml ↗

        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' ) )

        1. Checkout requested revisionactions/checkout@v6
        2. pnpm/action-setup@v6pnpm/action-setup@v6
        3. actions/setup-node@v6actions/setup-node@v6
        4. Shell commandpnpm install --frozen-lockfile
        5. Compare base and candidate with identical measurement definitionspnpm bench:sync:compare -- --base HEAD^1 --candidate HEAD --runtime "${{ matrix.runtime }}" --suite "$SUITE" --output "$RUNNER_TEMP/sync-benchmark-${{ matrix.runtime }}.json"
        6. Preserve raw resultsactions/upload-artifact@v6Condition: always()

        publish · after benchmark

        Condition: always() && needs.benchmark.result != 'skipped' && github.event_name == 'issue_comment'

        1. Checkout trusted report formatteractions/checkout@v6
        2. actions/setup-node@v6actions/setup-node@v6
        3. actions/download-artifact@v7actions/download-artifact@v7
        4. Validate and format reportsnode .github/scripts/format-sync-client-benchmark.mjs "$RUNNER_TEMP/sync-benchmark" "$RUNNER_TEMP/sync-benchmark-comment.md"
        5. Publish benchmark resultcomment_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}…
        6. Fail on missing or invalid reportsexit 1Condition: steps.format.outcome == 'failure' || needs.benchmark.result != 'success'
        Sync E2E · .github/workflows/sync-e2e.yml ↗

        Triggers: workflow_call, workflow_dispatch

        sync-e2e · no job dependencies declared

        1. actions/checkout@v6actions/checkout@v6
        2. pnpm/action-setup@v6pnpm/action-setup@v6
        3. actions/setup-node@v6actions/setup-node@v6
        4. Shell commandpnpm install --frozen-lockfile
        5. Shell commandpnpm -C tests/sync-e2e typecheck
        6. Verify encrypted sync and conflict recoverypnpm -C tests/sync-e2e test:e2e
        Deploy WWW · .github/workflows/www-deploy.yml ↗

        Triggers: workflow_dispatch

        deploy · no job dependencies declared

        1. actions/checkout@v6actions/checkout@v6
        2. pnpm/action-setup@v6pnpm/action-setup@v6
        3. actions/setup-node@v6actions/setup-node@v6
        4. Shell commandpnpm install --frozen-lockfile
        5. Validate deployment configurationtest -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 }
        6. Build WWWpnpm -C apps/www build
        7. Deploy WWWpnpm -C apps/www deploy:built
        package.json ↗
        • build: pnpm -r --if-present build
        • build:www: pnpm -C apps/www build
        • deploy:www: pnpm -C apps/www deploy
        • build:plugin: pnpm -C apps/obsidian-plugin build
        • release:plugin: pnpm -C apps/obsidian-plugin release:prepare
        apps/api/package.json ↗
        • build:node: pnpm run build:public && node ./scripts/build-node.mjs
        • build:public: node ./scripts/build-public.mjs
        • build:cloudflare:community: pnpm run build:public && wrangler deploy --dry-run --env "" --outdir dist/cloudflare-community
        • build:cloudflare:managed: pnpm run build:public && wrangler deploy --dry-run --env managed --outdir dist/cloudflare-managed
        • deploy:cloudflare:community: pnpm run build:public && wrangler deploy && pnpm run db:migrate:cloudflare:community
        • deploy:cloudflare:managed: pnpm run build:public && wrangler deploy --env managed && pnpm run db:migrate:cloudflare:managed
        • deploy: pnpm run deploy:cloudflare:community
        • build:storybook: storybook build --disable-telemetry
        apps/cli/package.json ↗
        • build: tsgo --noEmit --skipLibCheck && node esbuild.config.mjs
        apps/obsidian-plugin/package.json ↗
        • build: tsgo --noEmit --skipLibCheck && node esbuild.config.mjs production
        • release-notes:prepare: node scripts/prepare-release-notes.mjs
        • release-notes:reset: node scripts/reset-release-notes.mjs
        • release:prepare: pnpm run sync:metadata && pnpm run build
        apps/www/package.json ↗
        • build: astro build
        • deploy: pnpm build && wrangler deploy
        • deploy:built: wrangler deploy
        • build:storybook: storybook build --disable-telemetry

        Full upstream document by @hjinco · README.md · snapshot d38fcfb

        Synch

        End-to-end encrypted sync for Obsidian.

        Website · Cloudflare deployment · Docker deployment

        Obsidian Community Plugin MIT License

        English | 한국어 | 日本語 | Deutsch | 简体中文 | 繁體中文

        Synch overview


        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

        1. Open Settings → Community plugins in Obsidian.
        2. Turn off Restricted mode and select Browse.
        3. Search for Synchrun.
        4. Install and enable the plugin.
        5. Open Synchrun's settings and sign in.
        6. 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 →
        No comments yet — be the first.