Self-hosted cross-platform social sync for X, Mastodon, and Bluesky. Post to any one of them and the post fans out to the other two within ~15 minutes.
Single-tenant (one user per deployment), runs on Cloudflare Workers + D1 + R2 + Queues. MIT-licensed.
For the design rationale, especially loop prevention, see docs/architecture.md. For X API cost tuning, see docs/costs.md.
/shared TypeScript types shared between worker and web
/worker Cloudflare Worker (pollers, publishers, orchestrator, queue consumer, admin API)
/migrations D1 schema migrations + seed template
/tests Vitest unit tests (loop-prevention canary lives here)
/web Next.js (App Router) dashboard, deployed as a Cloudflare Worker via OpenNext
/docs Architecture + cost notes
Requirements: Node 20+, a Cloudflare account, wrangler (installed as a worker devDependency — call via npx wrangler or the workspace scripts).
git clone <repo-url> social-sync
cd social-sync
npm install
npm run test # 38 tests, all local — no Cloudflare setup required yetIf tests pass, the code is healthy. Now set up Cloudflare.
cd worker
npx wrangler login
npx wrangler d1 create social-sync
# → copy the returned database_id into worker/wrangler.toml
# (the top-level [[d1_databases]] block)
npx wrangler r2 bucket create social-sync-media
npx wrangler queues create social-sync-publish
npx wrangler queues create social-sync-publish-dlqOpen worker/wrangler.toml and paste the D1 database_id into the empty database_id = "" field. If you also want a preview environment, repeat for social-sync-preview, social-sync-media-preview, and social-sync-publish-preview and fill the [env.preview.*] block.
# from worker/
npm run db:migrate:local # for `wrangler dev`
npm run db:migrate:remote # for the deployed workerCopy the template and fill in the values you need:
# from worker/
cp .dev.vars.example .dev.vars # for local `wrangler dev`For production, set each as a Cloudflare secret instead (values below, only the platforms you want to sync are required):
# Mastodon (required for Mastodon sync)
npx wrangler secret put MASTODON_INSTANCE_URL
npx wrangler secret put MASTODON_ACCESS_TOKEN
# Bluesky (required for Bluesky sync)
npx wrangler secret put BLUESKY_IDENTIFIER
npx wrangler secret put BLUESKY_APP_PASSWORD
# X (optional — only if you set ENABLE_X_SYNC="true" in wrangler.toml)
npx wrangler secret put X_BEARER_TOKEN
npx wrangler secret put X_CLIENT_ID
npx wrangler secret put X_CLIENT_SECRET
# Admin auth fallback (skip if using Cloudflare Access — see Admin section)
npx wrangler secret put ADMIN_PASSWORD_HASHPlatform-specific instructions for obtaining these values are in the Platform setup section below.
# from worker/
cp migrations/seed.sql.example migrations/seed.sql
# edit seed.sql, fill in your handles/IDs, then:
npx wrangler d1 execute social-sync --file=./migrations/seed.sql # local
npx wrangler d1 execute social-sync --file=./migrations/seed.sql --remote # productionThe seed.sql file is gitignored, so your IDs stay out of version control.
If you auto-post from a personal blog to all three networks via some other automation, set BLOG_FEED_URL in worker/wrangler.toml to the RSS/Atom feed URL of that blog. Social Sync will skip any social post that links to a blog entry published in the last 2 hours — preventing re-amplification of your own cross-network announcements.
Leave BLOG_FEED_URL = "" to disable this feature entirely.
Deploy the worker:
cd worker && npx wrangler deployDeploy the dashboard. It's a Next.js App Router app with server components, compiled to a Cloudflare Worker (with static assets) via the OpenNext Cloudflare adapter. The dashboard Worker reaches the API Worker via a service binding (configured in web/wrangler.toml), not a public URL — so the API Worker has workers_dev = false and is not exposed to the open internet.
cd ../web
cp .env.example .env.local # used only for `npm run dev` locally
npm run cf:deploy # opennextjs-cloudflare build + deployThe dashboard deploys as a separate Worker named social-sync-web — distinct from the API Worker. You can preview locally with npm run cf:preview. The service binding requires the API Worker (social-sync) to exist first; step 6 already took care of that.
Then give the dashboard the admin password so it can authenticate service-binding calls to the API Worker:
# from web/
npx wrangler secret put ADMIN_TOKEN # plaintext admin password (the one you hashed into ADMIN_PASSWORD_HASH on the API worker)Optional — IP allowlist: to restrict dashboard access to specific IPs (e.g. your VPN egress), set a comma-separated list of IPv4/IPv6 addresses and/or IPv4 CIDR ranges:
npx wrangler secret put ALLOWED_IPS # e.g. "203.0.113.42" or "203.0.113.0/24,198.51.100.5"Leave ALLOWED_IPS unset to accept any IP. The check is implemented as Next.js middleware against Cloudflare's cf-connecting-ip header.
If you're using Cloudflare Access on a custom domain instead, skip ADMIN_TOKEN and ALLOWED_IPS — but note that Access doesn't protect *.workers.dev URLs, so you'd need to put the dashboard behind a zone you own.
- Log in to your Mastodon instance.
- Settings → Development → New Application.
- Scopes:
read:accounts,read:statuses,write:statuses,write:media. - Save, then copy "Your access token".
Values:
MASTODON_INSTANCE_URL— e.g.https://mastodon.socialMASTODON_ACCESS_TOKEN— from the app page
Docs: https://docs.joinmastodon.org/client/token/
Use an app password, not your main password.
- Settings → Privacy and Security → App passwords → Add.
- Copy the generated password.
Values:
BLUESKY_IDENTIFIER— e.g.you.bsky.socialBLUESKY_APP_PASSWORD— the app passwordBLUESKY_SERVICE_URL— optional, defaults tohttps://bsky.social
Docs: https://docs.bsky.app/docs/advanced-guides/app-passwords
Requires a developer account and an app with Read and Write user permissions. X's API has per-call pricing — read docs/costs.md before enabling. Keep ENABLE_X_SYNC = "false" in wrangler.toml until you've confirmed the spend ceiling is set the way you want.
Values:
X_BEARER_TOKEN,X_CLIENT_ID,X_CLIENT_SECRET
Docs: https://developer.x.com/en/docs
Two options:
Cloudflare Access (recommended). Wrap the deployed worker URL in a Zero Trust Access application. The worker trusts the CF-Access-Authenticated-User-Email header that Access injects. Nothing to configure in this repo.
Password fallback. If you'd rather not set up Access:
printf 'your-long-random-password' | shasum -a 256
npx wrangler secret put ADMIN_PASSWORD_HASH # paste the hex digestThe dashboard then sends this password in the x-admin-token request header.
npm install
npm run test # all workspaces, ~1s
npm run typecheck # all workspaces
npm run worker:dev # wrangler dev on :8787
npm run web:dev # next dev on :3000The canary test (worker/tests/loop-prevention.test.ts) is the one test you must never let regress — it covers the X→Mastodon→poll-Mastodon scenario and each loop-prevention rule individually. If this test fails, stop and investigate.
npx wrangler tail social-sync— live worker logs./logson the dashboard — last 500 sync events from D1./on the dashboard — current month's X spend vs. ceiling.
Pull requests welcome. Two ground rules:
- The loop-prevention canary test must stay green. If you touch any of the rules in
worker/src/lib/loop-prevention.tsor the surrounding infrastructure, add a regression test for the scenario you're changing. - If you add platform-specific behavior (new field, new limit, new quirk), add a short note to
docs/architecture.mdunder "Text transformation and platform specifics" so the reasoning survives future refactors.
MIT. See LICENSE.