first off, before you use this, follow me on twitter (X) and Bluesky, its just respectful honestly.
https://bsky.app/profile/coah80.com
mirrors your twitter/x account to bluesky so you can be active there without opening the app. reads your tweets through emusks, posts them to bluesky, and keeps a sqlite db of tweet id → bluesky post mappings so threads and self quotes can sync up nicely with a little bow on top 🎀.
syncs tweets, media, self quotes, self replies, deletions, and profile changes. quotes of other people get attached as a screenshot card, and if the quoted tweet has a video the card becomes a video with the clip playing inside it. retweets and replies to other people are skipped. no backfill, it starts at your newest tweet.
you need two credentials, both go in .env:
cp .env.example .env
twitter auth token — sign in at x.com, hit F12, Application tab, Cookies, https://x.com, copy the auth_token cookie value into TWITTER_AUTH_TOKEN. set TWITTER_USERNAME to your handle without the @. the syncer is read only on the twitter side. twitter invalidates this token on password changes, logouts, and sometimes for no visible reason, if login starts failing grab a fresh cookie.
bluesky app password — bluesky settings, Privacy and Security, App Passwords, make one, put it in BSKY_APP_PASSWORD. set BSKY_HANDLE to your full handle (like you.bsky.social). dont use your main password, app passwords are revokable.
optional: POLL_INTERVAL_SECONDS (default 180) and BSKY_SERVICE if youre not on bsky.social.
on startup the syncer registers your account with the bsky-sync labeler so you get a bsky-sync badge on your profile. all it sends is your bluesky DID plus a signed token proving its you — no tweets, no credentials. only people subscribed to the labeler see the badge, and if the labeler is unreachable the sync just carries on without it. dont want it? SYNC_BADGE=off in .env.
docker compose up -d --build
state lives in ./data/sync.db on the host, so rebuilding or updating the image never loses the tweet mappings. logs:
docker logs -f bsky-sync
it restarts unless you stop it, and comes back up with the docker daemon after a reboot.
docker compose run --rm bsky-sync node src/index.js --once --dry-run
logs into both sides, fetches, builds the posts, uploads NOTHING and doesnt move the checkpoint. if that looks right, docker compose up -d.
theres also an offline render test that needs zero credentials, good for checking chromium works:
docker compose run --rm bsky-sync node src/index.js --render-test
needs node 24+.
npm install
npx playwright install chromium
npm start
same flags apply, node src/index.js --once for a single poll, --dry-run to not post.
npm test
or in docker: docker compose run --rm bsky-sync npm test