- TypeScript 91.7%
- HTML 5.2%
- PLpgSQL 2.6%
- Shell 0.3%
- Go 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github | ||
| android | ||
| deploy/juicebox | ||
| docker | ||
| public | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| bun.lock | ||
| compose.yaml | ||
| Dockerfile | ||
| FEDERATION.md | ||
| instances.json | ||
| INSTANCES.md | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
Xitter
A self-hosted backend for the X (Twitter) web client. The server hosts a pinned build of the official client from your own domain and answers its API from Postgres, so the real x.com interface runs against your own instance.
The client itself is not in this repository. Each instance downloads its own copy during setup, and it stays on that machine.
What works
- Posting, replies, quotes, reposts, likes, bookmarks (with folders), pinned posts, pinned replies, highlights, @mentions and hashtags; editing posts, polls, scheduled threads (up to 26 posts), places
- Scheduled threads publish together. Failed threads retry; permanent failures or exhausted retries return the complete thread to drafts. Each account can queue 100 future posts.
- Who can reply (everyone, followed accounts, verified, mentioned), post views, reposts, likes and quotes lists
- Images, GIFs and video (converted with ffmpeg), alt text, content warnings on media, long-form articles
- Profiles with all their tabs, editing, follows, followers, private (protected) accounts with follow requests, "About this account" with the country or region it is based in
- Lists (with banners) and communities (rules, members, moderation tools, custom banners, a topic from the instance's topics, community label on posts)
- Home timeline: a ranked "For you" and a chronological "Following"
- Notifications with unread counts, browser push (with the instance's own VAPID
key, made on first start in
data/push-vapid.json); the profile bell (new posts from that account arrive as notifications) and turning an account's reposts off - Search with X's operators, including full federated handles, trends, Explore; trends by location (Explore settings: worldwide, a country, or yours with "Trends for you")
- PostgreSQL trigram indexes for post text and account search; startup installs
the
pg_trgmextension and builds the indexes on existing data once - News and sports cards at the top of Explore, written by staff (Admin > Explore cards); sports cards are scoreboards you update as the game goes
- Topics: staff define them with keywords; people follow them from "Topics to follow" or a post's menu, and "For you" brings in and labels matching posts
- Blocks, mutes and muted words
- Who to follow and "You might like" ranked by shared follows, with accounts like the profile you're on; uploaded contacts (Settings > Contacts) for people who let themselves be found by email
- Direct messages, end-to-end encrypted, with key backup
- Community Notes
- Invite-only sign-up with a required email (proven with a mailed 6-digit code when mail is set up; Settings > Email changes it by a confirmation link), pride flag badges, organisations with affiliates, several signed-in accounts
- Account settings: password change, username change, language, deactivation
(reactivates on sign-in within 30 days), downloadable JSON and offline HTML
data archives (including drafts,
scheduled posts, polls, media metadata, bookmark folders and visible encrypted
chat history without decryption keys), email notification
settings (activity and new-message digests, sent while you're away), sessions
list with revoke, account access history (each sign-in
with its app and address), two-factor sign-in with an
authenticator app and backup codes, passkeys (Settings > Passkeys; the
sign-in dialog offers them in the username field and with a button, and
/account/passkeyhas one too; changing or resetting the password removes them), password reset by email - Moving an account between instances (Settings > Move account,
/account/migrate): see Moving accounts - Reporting posts and accounts
- Account analytics (Overview, Content and each post's page) from the instance's own counts
- Sticker packs made by staff (Admin > Stickers), picked from a button beside the composer's emoji button and posted as images
- Branded hashtags (X's branded likes and hashmoji, Admin > Branded hashtags): for a set time, liking a post with the hashtag plays a custom Lottie animation, and the hashtag shows an emoji (optionally with hashfetti)
- Conversation cards: the composer’s conversation-card icon adds up to four hashtag buttons with prefilled post text, a title and an optional thank-you message, destination URL and display URL to one image, GIF or video. The editor previews the attached media, title and buttons together; its crop control opens the client’s image editor for still images. Published cards use the client’s native image/video conversation-card design. Each button opens an editable post carrying the original card; hashtag fields add the # prefix automatically. Post text is optional, including the prefilled text for each choice (which defaults to its hashtag). Drafts and scheduled posts preserve card settings and media. Incomplete cards can be saved as drafts; scheduling requires a valid card with one finished upload. Empty-text card shares include the title and hashtags when federation is enabled.
- Import from X (off by default; Admin > Settings, feature switches, then
Settings > Your account > Import from X): upload the archive x.com makes
(up to
ARCHIVE_MAX_MB, default 1024) and its posts, threads, photos, videos and profile come over with their original dates and X ids. Imported posts pass the same length limit, automod and blocked phrases as new ones (those that don't are left out and counted) and count toward the post rate limit, so the import waits on it. Retweets and likes stay behind, mentions stay as text, imported posts aren't sent to other instances, and an upload that isn't imported within a day is deleted. By default only accounts a week old can import, and staff approve each import first (Admin > Settings, Import from X) - Polls with a photo on each choice
- YouTube links (videos, Shorts, live, youtu.be, with
t=start times) become player cards: the thumbnail comes throughEMBED_PROXY, and pressing play loads the video in place from youtube-nocookie.com - Previews when a public post or profile is shared in other apps (Discord, Slack, iMessage…): OpenGraph and Twitter card tags and oEmbed, for active, unprotected accounts only. Admin > Settings > Features switches them off
- Dim (X dropped it from Display settings) and custom themes, including gradients, per-token colours, and Google Fonts served from this instance; Settings > Display or /account/theme. Organisation profile decor keeps its own look on the profile column.
- Staff labels under posts, as X shows them: Visibility limited (also kept out of For you and search), Misleading, Stay informed, Get the latest, Legally required notice; staff can also set the counts a post or profile shows
- Admin > Feature switches: any of the client's own switches, set for everyone
- Rate limits per account and address (
src/ratelimit.ts) - A public JSON API under
/api/v1with personal tokens (/account/api, read or read and write), including chunked video uploads with progress and retry support;/apidocuments it. Pending uploads expire after one hour and are lost if the server restarts. Changing or resetting a password revokes all of that account's API tokens. - Connected apps: developers register a public client at
/account/apps/developer, users approve read or read-and-write access with OAuth 2 authorization code and mandatory PKCE, and revoke access at/account/apps. Tokens expire after 30 days;/apidocuments the flow. Apps can skip registering on each instance by using the URL of a client ID metadata document as theirclient_id, so one app signs in with one tap on every Xitter (/.well-known/oauth-authorization-serveradvertises it). - An admin panel at
/admin: users, standing (restrict, limit, suspend), badges (blue, gold organisation, grey government organisation or official), translator globes (blue translator, gold translator moderator, yellow approver), roles, organisations, posts, reports, invites, instance settings, feature switches, announcements, topics, Explore cards and an audit log - Staff roles (admin, moderator, support) whose powers an admin chooses: Admin >
Staff permissions turns each permission (changing standing, deleting posts,
editing profiles, resetting passwords…) on or off for moderators and support,
and who sees IP addresses, email addresses, invite codes and server errors
(
src/staff-permissions.ts;bun scripts/test-staff-permissions.tschecks it)
Not built yet: Spaces and live video, Moments. Email is sent over SMTP to your own mail server when
SMTP_URL is set, or through Resend when
RESEND_API_KEY is (with both, Resend catches what SMTP refused); Admin >
Email sends a test mail (it shows the server's answer) and lists every mail
tried.
Running it locally
What you need
- Bun 1.4 or newer
- PostgreSQL 14 or newer, with an empty database
- Optional: ffmpeg on the PATH, for video uploads
Steps
git clone https://github.com/Xittered/xitter
cd xitter
bun install
# 1. a database (any name; set DATABASE_URL if it isn't the default below)
createdb xitter
export DATABASE_URL=postgres://<user>@localhost:5432/xitter
# 2. download a client build (no X account needed)
bun scripts/fetch-client.ts
# 3. the first account, made an admin
bun scripts/create-user.ts <name> <password> "<display name>"
bun scripts/make-admin.ts <name>
# 4. run
bun src/server.ts
Open http://localhost:3000 and sign in with the client's own form. The
tables are created on the first start.
More accounts: make invite codes in the admin panel (/admin > Invites) or with
bun scripts/invite.ts. On the sign-in form, a username that doesn't exist yet
starts sign-up and asks for the code; an invite link (/signup?code=<code>)
fills it in.
On Windows, run the commands in Git Bash or set the variables with
$env:DATABASE_URL = "..." in PowerShell.
Without an account the script pins the build x.com gives signed-out visitors
at /i/tweetdeck, with the guest set of feature switches. --latest re-pins
the current build. --token <auth_token> (the cookie from a signed-in x.com
tab) or --har <file> pins the signed-in page instead, with that account's
switches; nothing about the account is kept.
With Docker Compose
compose.yaml runs Xitter and Postgres 18, with ffmpeg and pg_dump in the
image. The client is downloaded into its volume on the first start.
docker/env.sh # writes .env with fresh secrets; edit PUBLIC_ORIGIN, HOST_PORT, mail keys there
docker compose up -d --build
docker compose exec app bun scripts/create-user.ts <name> <password> "<display name>"
docker compose exec app bun scripts/make-admin.ts <name>
Add --profile keybackup to up to also run the Juicebox realm for chat
passcode recovery (built from deploy/juicebox, with its own Postgres); its
realm ID and tenant secret come from .env, so keep that file and back it up
with the volumes (db, client, media, data, realm-db). Other scripts
run the same way, e.g. docker compose exec app bun scripts/invite.ts, and
any variable from the Configuration table can be added to .env.
FETCH_CLIENT=0 skips the first-start download; docker compose exec app bun scripts/fetch-client.ts --latest re-pins the build.
The image has a health check: /healthz answers ok while the server runs
and reaches its database, and docker compose ps shows the app as
unhealthy when it doesn't. Docker doesn't restart unhealthy containers by
itself; a watcher such as
autoheal can.
For an optional PgBouncer connection pool, add DB_HOST=pool and
DB_PORT=6432 to .env, then start with
docker compose --profile sqlpool up -d --build. PgBouncer uses session pooling
so Bun's prepared statements and startup migration keep their connection
semantics. PGBOUNCER_POOL_SIZE defaults to 20 server connections; tune it
alongside DB_POOL_MAX for the number of app replicas. Backups use a direct
PostgreSQL connection. The Compose PostgreSQL service also uses conservative
cache and autovacuum settings; adjust them for the host's memory and workload.
Keep these private
- An
auth_tokencookie is a live login to your X account. Use it only forfetch-client.tsand don't share it. client/holds X's own client code. It is downloaded per instance and must not be committed or shared.
Configuration
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
postgres://dev@localhost:5432/xitter |
Postgres connection |
BACKUP_DATABASE_URL |
DATABASE_URL |
Direct Postgres connection for pg_dump when the app uses a pooler |
DB_POOL_MAX |
20 |
Postgres connections the server keeps open |
DB_STATEMENT_TIMEOUT, DB_LOCK_TIMEOUT |
30s, 10s |
A query running or waiting on a lock longer than this is cancelled, so one stuck query can't hold every connection (the migration at start-up has no limit) |
PORT |
3000 |
Listen port |
PUBLIC_ORIGIN |
http://localhost:$PORT |
Address browsers use to reach the instance |
CLIENT_DIR |
client |
Where the pinned client is stored |
MEDIA_DIR |
media |
Uploaded images and videos |
BRANDING_DIR |
data/branding |
Logos uploaded in the admin panel |
FFMPEG, FFPROBE |
ffmpeg, ffprobe |
Video tools |
MAX_VIDEO_MB, MAX_VIDEO_SECONDS |
200, 600 |
Starting video limits (changeable in the panel) |
SOURCE_URL |
this repository | The source link shown to users |
LOG_REQUESTS |
unset | 1 logs every API call, errors also prints the client's error reports |
RATE_LIMITS |
on | off turns the rate limits off (for the test scripts; limits are in src/ratelimit.ts, scripts/test-ratelimit.ts checks them) |
TRUSTED_PROXIES |
unset | Comma-separated addresses or ranges (127.0.0.1, 172.16.0.0/12) whose CF-Connecting-IP header is believed, besides Cloudflare's own ranges: set it to a Cloudflare tunnel's address. A plain nginx or Caddy passes the visitor's header on, so leave it out of this list |
EMBED_PROXY |
https://cors.estrogen.delivery/ |
where link previews are fetched from and their images served, as <proxy>?url=<link>, so neither the instance nor its readers reach the linked site |
JUICEBOX_URL |
http://localhost:8580 |
Private address of the key-backup realm, proxied through /juicebox/ |
CHAT_TOKEN_SECRET |
saved in data/chat-secrets.json |
Signing secret for chat socket tokens |
JUICEBOX_REALM_ID, JUICEBOX_TENANT_SECRET |
saved in data/chat-secrets.json |
Stable realm identity and tenant signing secret |
MAX_CHAT_MEDIA_MB |
200 |
Maximum encrypted chat attachment size |
SMTP_URL |
unset | Own mail server for sign-up codes, password resets and notifications: smtp://user:pass@host:587 (STARTTLS) or smtps://user:pass@host:465; percent-encode @ in the user; the host must be the name on the server's certificate |
RESEND_API_KEY |
unset | Resend key, used when SMTP_URL isn't set or refused a mail; without either nothing is sent |
MAIL_FROM |
Xitter <[email protected]> |
Sender of that mail (its domain must be the mail server's, or verified in Resend) |
ARCHIVE_DIR, ARCHIVE_MAX_MB |
data/imports, 1024 |
Where uploaded X archives wait while they import, and the largest accepted |
ARCHIVE_MAX_JSON_MB |
64 |
Largest JSON file in an archive (tweets.js and its parts) an import reads; it's parsed in memory |
KLIPY_API_KEY |
unset | GIF search through Klipy (see src/gifs.ts); without it or TENOR_API_KEY the GIF button is hidden |
TENOR_API_KEY |
unset | GIF search through Tenor's v2 API instead, used when KLIPY_API_KEY isn't set |
XITTER_VERSION |
version in package.json |
The version this instance reports in /.well-known/xitter and compares when an account moves |
MIGRATION_ALLOW_HTTP, MIGRATION_ALLOW_PRIVATE |
unset | 1 lets an account move from an instance over plain HTTP, or on a private network. For test instances only |
Everything else (name, sign-up mode, features, limits, announcement, rules, logos) is set in the admin panel.
Federation
Instances listed in instances.json share public accounts and posts with each
other. FEDERATION.md explains how to set up an instance and
join the list.
Moving accounts
An account can move from one Xitter instance to another, federated or not.
Both sides are on the Move account page (/account/migrate):
- On the old instance, the account makes a migration code with its password. The code works for 24 hours and lets one other instance read the account's data; a new code replaces it.
- On the new instance, the account it moves into enters the old instance's
address and the code, and presses Check. Nothing is copied yet: the new
instance asks the old one what it runs and what the account holds, and shows
both side by side.
- It refuses when the old instance exports a newer archive format than it reads, when the address is its own, or while another import runs.
- It warns, and starts only once the warnings are accepted, when the two run different versions (or the same version with a different database schema, meaning one is modified), when the two don't federate with each other (so the account's follows, likes, replies and the like involving accounts there can't be matched), when the account here already has posts or follows, and when files are over this instance's size limits.
- The import runs in the background, with its progress and a report on the same page: files, polls, articles, posts with their threads, quotes, edit history and original times, drafts and scheduled posts, the profile, follows, follow requests, blocks, mutes, muted words, lists, likes, reposts, bookmarks and their folders. Other accounts and posts are matched to the ones this instance already knows (its own, or copies from federation); the report lists accounts it couldn't find. Every object brought over is recorded, so an import that failed, or is run again, only adds what's missing.
- When it's done, the old instance records where the account went, cancels its scheduled posts there, and if asked leaves "Moved to @name@instance" as the profile's bio and deactivates the account.
Followers can't be moved for them, and encrypted chats stay behind, since their
keys never leave the browser. The old instance only answers on the public
internet over HTTPS unless MIGRATION_ALLOW_HTTP and MIGRATION_ALLOW_PRIVATE
are set.
docker/migration-test.yaml runs two throwaway instances on ports 3101 and
3102; bun scripts/test-migration.ts moves a seeded account between them and
checks what arrived (its first lines give the variables).
Encrypted chat and key backup
Chat encrypts messages and attachments in the browser. Xitter keeps ciphertext,
public keys, membership and event history. Images, videos and files use the same
multipart ciphertext store under MEDIA_DIR/chat; downloads require membership.
Requests from accounts you do not follow stay separate until accepted. Group
creation, membership changes, edits, reactions, read receipts and typing use the
pinned client's XChat events.
Passcode recovery requires a Juicebox software realm. deploy/juicebox builds
upstream commit 2e485aac584a6c9ada1fb9f156dc4d7cad3ab338 with our Postgres
provider patch. It is also AGPL. The compose stack has its own persistent Postgres
volume and binds the realm to 127.0.0.1:8580, with no public database port.
Start Xitter once to generate data/chat-secrets.json, then prepare the private
configuration and run the stack from the project root:
bun scripts/prepare-realm.ts deploy/juicebox
docker compose -f deploy/juicebox/compose.yaml -p xitter-juicebox up --build -d
realm.env is ignored by Git; the preparation script refuses to overwrite an
existing configuration. Keep the realm ID, signing secret and database password
stable. Back up the Postgres volume together with that configuration. Changing
the realm ID or losing its database prevents recovery of existing chat keys.
If Xitter runs inside Docker, connect it to the realm's network and set
JUICEBOX_URL=http://realm:8580; browsers continue using Xitter's authenticated
same-origin proxy. The local development realm and the server realm have separate
record stores: starting a server realm does not transfer local backups.
Development
Client apps and Community Notes
A post's page shows the app it came from after its time ("11:21 PM · Oct 14,
2026 · Xitter for Web"). Admins create and revoke app tokens under Client
apps, with a label such as Xitter for Android. The token is shown once; the
database stores its SHA-256 hash. The app sends it as Authorization: Bearer <token> alongside the user's session cookies and CSRF header. It names the app,
not the user, and grants no account access. Revoking a token rejects later
requests using it; posts made with it keep their label. Requests from the
browser client are Xitter for Web.
Community Notes use the pinned client's native viewing, writing and rating screens. Admins grant or revoke Community Notes writing access under a user's Access section. Writing is denied by default and enforced server-side. Contributors have random aliases independent of their handles. Signed-in active users can request notes and rate other people's notes; authors cannot rate their own notes. Requests and ratings are deduplicated. Authors can delete their notes; admins and moderators can remove notes, with staff removals recorded in the audit log. Blocked accounts cannot interact through notes.
The local scoring rule needs at least five active ratings and a 95% Wilson
lower bound above 50% before a misleading-post note is displayed as native
context. Ratings from suspended accounts are excluded. Authors can appeal a
Not Helpful note at /account/notes/appeals; admins and moderators review
appeals there, with decisions recorded in the audit log. A staff decision does
not change the reader score. This is not X's matrix-factorization scoring
system. Media-match notes and automated translation are not implemented.
Run bun scripts/test-notes.ts against the development server with the existing
pozo/goldcorp test accounts. It creates temporary posts, tokens and voters,
restores writing access, and removes its fixtures.
Moderation
Besides reports and standings, staff can send an account a message, warning or
strike. It arrives in the account's Notifications tab and opens a page where a
warning or strike can be appealed; a suspended account appeals at /appeal
with its password. Standings can be temporary (they lift on their own). Staff
keep private notes on accounts, see and end sessions, hide posts and articles
(staff still see them), mark media sensitive (X's content warning), give
accounts custom badges and delete uploads. Settings also hold maintenance mode
(everyone but staff sees a message), blocked phrases, hidden and promoted
trends, and a post pinned to the top of every home timeline.
bun scripts/test-admin-extra.ts checks these.
The automod (src/hate.ts) refuses slurs, explicit hate, transphobia and
abuse like "kill yourself" in posts, replies, scheduled posts, profiles, names,
polls, image descriptions, lists, communities, articles and Community Notes.
It reads through look-alike letters from other alphabets, Unicode styles,
leetspeak, invisible characters, accents, repeated letters, letters spelled
out one by one and reversed or upside-down text. Reclaimed queer words pass
unless the text attacks someone, and NSFW text isn't touched. DMs are end-to-end
encrypted, so the server can't read them. The first refusals each week (5 by
default) are second chances: the content isn't published, and the person gets a
notice and a modal saying what was caught and how many chances are left. After
that each refusal is an appealable strike, and strikes, from staff or
automatic, follow a ladder set in Settings > Automod (by default a warning, a
day, a week, 30 days, then permanent). bun test ./scripts/test-hate.ts checks evasions and false
positives without a server; bun scripts/test-automod.ts checks the ladder,
expiry, write paths and request limits against one.
GET /_debug/unknown(admins only) lists the client calls no handler answers yet, most frequent first. Start there when a screen is empty.bun run test:unitdiscovers and runs the scripts that usebun:test, without needing a server..github/workflows/tests.ymlruns these on pushes and pull requests, then checks password and two-factor concurrency, poll visibility, search operators, API request limits, email confirmations, data exports, scheduled threads and quotas, bookmark pagination, media privacy, chunked video uploads, account settings and encrypted chat against a fresh PostgreSQL 18 database and a local server. The pinned client is not downloaded; checks that need it report a skip.- The
test-*-concurrency.ts,test-poll-visibility.tsandtest-search-operators.tsscripts can also run against a dedicated migrated test database without a server. SetDATABASE_URLto that database; each script creates and removes its own test accounts. scripts/test-*.tscheck each feature end to end against a running server, for examplebun scripts/test-posts.ts <user A> <user B> <password>. Each script's first lines give its arguments.bun scripts/test-chat.tscreates and removes temporary users and checks encryption with emusks, access controls, requests, groups, socket delivery, attachments and history.bun test ./scripts/test-chat-token.tschecks token validation without a running server.bun scripts/test-juicebox.tsverifies realm registration and recovery; run it withrecoverafter a realm/database restart to check durability (TEST_JUICEBOX_URLselects the realm).bun scripts/test-conversation-live.ts public/logo-light.pngverifies conversation card uploads, publication, native previews, sharing and JSON storage against a running server and database. It creates and removes its own test account.- Other checks:
test-account-settings,test-two-factor,test-password-reset,test-reply-limits,test-views,test-protected,test-communities,test-lists,test-polls,test-scheduledand the rest ofscripts/test-*.ts. A passing script is not a working screen: check the UI withdrive.tstoo. - Where the account's country comes from: the
CF-IPCountryheader, which only exists behind Cloudflare. Without it the About page shows no location. bun scripts/drive.ts <path> <steps...>clicks through the real client in headless Edge and takes screenshots (uses the browser profilescripts/probe.shleaves signed in).
Donate
If Xitter is useful to you, crypto donations keep the main instance running. Send only the coin on the network listed for each address.
| Coin | Network | Address |
|---|---|---|
| SOL | Solana | 5nmzmrxFrvacVuQhNPVFJzbbkAgRMefWsWmPSoft1cJF |
| BTC | Bitcoin | bc1qj2kedn4kqavwg2r5z5n4cldtx09gy2recswak9 |
| ETH | Ethereum | 0x5A7cFbf7F877BE5b6DB4aE248178761E68129881 |
| USDT | Tron (TRC20) | THvC2xDHpF8Yr9s6jhHCXYGDya9ZnvjG79 |
License
AGPL-3.0. If you run a modified instance for other people, you must
offer them its source; the signed-out home page links to it (SOURCE_URL, or the admin
panel's Settings).