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.
- 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, accounts you follow, your network (them and the accounts they follow), verified accounts, accounts you mention, or accounts based in countries or regions you pick (several at once, changeable after posting); accounts mentioned in the post can always reply. Post views, reposts, likes and quotes lists
- Images, GIFs and video (converted with ffmpeg), alt text, content warnings on media, long-form articles
- Optional compression for instances short on disk (Admin > Settings > Media storage): photos are shrunk to the size the app shows and saved again as JPEG (PNG when see-through), videos encoded again at the size and quality set there, and only the smaller file is kept. "Compress stored files" does the same for what was uploaded before. Animated images are left alone
- 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 and where it joined from (staff can set either, including a name of their own)
- 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) - Equicoins (in the side bar): every account has a wallet and sends money
to other accounts, here or on a peer instance, with a note. It is play money,
not real currency: a wallet starts with
money.starting_cents(default $5), one payment moves up tomoney.max_transfer_cents(default $1,000), each like on your posts earns youmoney.like_cents(default 50¢, once per post and account). Admin > Equicoins sets those three, shows the money in circulation, the biggest balances and every payment, and has staff adjust a balance or reverse a payment (Staff permissions: Equicoins; all recorded in the audit log).- Requests: ask one account for an amount (it gets a notification to pay or decline), or anyone, as a "Pay @you" card in a post that anyone who sees it can pay, with or without a set amount
- A payment's or request's link (
/i/xmoney/p/<id>,/i/xmoney/r/<id>) in a post by its own side shows as X's card: "$100 sent to liza", "Pay @james" with a Pay button, or a purchase ("$3.52 at Taco Bell"). The composer's $ button adds a Pay button or one of your payments; "Share it in a post" does it when sending or requesting - Merchant mode (Settings > Your account > Equicoins), for gold organisations: payments to them read as purchases under the name they choose, with their banner if they like
- Payments show checkmarks and an affiliate's organisation badge; money received, requests and declined requests come as notifications and pushes
- A payments feed (Admin > Equicoins, off until an account is named): that account posts every payment as "Alice sent $5 to Liza! #Money" (names, so nobody is mentioned) over its card, without the note; reversing a payment takes its post down, and the Send screen says payments are public
- Across instances: a wallet lives on its account's home instance, and a
payment to
name@peermoves the money there once (signed, retried until the peer answers, refunded if it refuses). Likes from peers' accounts pay too. Requests, balance adjustments and reversals stay on one instance. Admin > Equicoins settings:- Use the network's economy: the starting balance, like reward and largest
payment come from xitter.space instead of this instance's own
(
money.follow_network, off) - Most taken in from one instance a day, its payments and likes together;
over it, payments are refused and refunded and likes stop paying
(
money.peer_daily_cents, default $1,000, 0 for no limit). A peer's word is all there is for what it sends, so this is the only brake on a peer minting money here - An account moving to another instance takes its balance along
(
money.move_balance, on); the new instance takes it only for an account it imported from there, once per move and up to 100 times its largest payment - A payment a peer hasn't taken after three days fails and goes back to the sender
- Share this instance's totals with peers and show the network's in
circulation, wallets and payments (
money.network_totals, off)
- Use the network's economy: the starting balance, like reward and largest
payment come from xitter.space instead of this instance's own
(
- The shop (
/account/money/shop, opened in Admin > Equicoins): accounts spend their balance on listings staff make. An admin's listing goes up at once; one from a moderator waits for an admin. A purchase is charged at once and waits for staff, who deliver it or refund it; an invite code is the only thing that comes without review. Moderators deliver badges (Staff permissions: Equicoins shop); everything else below is delivered by an admin. A paid announcement stays up until an admin takes it down in Admin > Equicoins. What a listing can give:- A custom badge staff made, or a badge the buyer designs (an image up to 256 KB, GIFs animate)
- An instance announcement the buyer writes
- A blue checkmark for the listing's days (default 30), with longer posts while it lasts; it comes off by itself, and one staff gave is never touched
- An invite code, good for one sign-up within 30 days (not while sign-up is open)
- A hashflag: the buyer's emoji after a hashtag, for its days (default 7)
- A sticker pack of up to 6 of the buyer's stickers, in everyone's composer
- An Explore card (title, summary, a search it opens, a picture) for its days (default 1)
- A post boost: one of the buyer's posts shows once on each page of For you, marked as an ad, for its days (default 1)
- Badges, custom badges and checkmarks can be gifts to another account here, which gets a notification once staff deliver it
- Approved listings are shared with peer instances, where they stay hidden until that instance's staff approve them too; buying across instances isn't available yet
- The first time Equicoins opens, each account accepts a disclaimer: the money is virtual, has no real-world value and can't be exchanged
- Off by default: Admin > Settings, feature switches turns it on. Switched
off, the Equicoins tab, its pages, the composer's $ button, Pay buttons, like
earnings and its notifications go away, but cards already in posts still
show.
bun scripts/test-money.tschecks it
- 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. Bulk tools: strike many accounts at once (by handle, optionally with alts seen at the same addresses), hide, mark sensitive or delete many posts, and delete many files, each reporting what worked and what didn't. An account's page links its profile picture, banner and all its uploads; pride flags go up to four per account, in the order picked - 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) - Updating from the panel (Admin > Server update, off until
SELF_UPDATE=on): admins update a git checkout from a branch of Xittered/xitter, or of the other GitHub repositories the owner allows, after giving their password and a two-factor code again. It backs up the database, fetches and checks out the branch, runsbun install, stays on the old commit if the new code doesn't load, and restarts; "Go back" returns to the commit before
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.
- Bun 1.4 or newer
- PostgreSQL 14 or newer, with an empty database
- Optional: ffmpeg on the PATH, for video uploads
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.tsOpen 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.
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.
- 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.
| 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 |
"<instance name>" <[email protected]> |
Sender of that mail. Set it on your own instance: 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 |
GIF_PROVIDER |
auto |
Instance default provider (klipy, tenor or giphy); users can override it in Settings > GIF provider. Auto prefers configured Klipy, then Tenor, then Giphy |
KLIPY_API_KEY |
unset | Account-specific key from the Klipy Partner Panel. This server-proxied integration requires Klipy's prior written approval |
TENOR_API_KEY |
built-in public client key | Tenor API key override; set empty to disable Tenor |
TENOR_API_URL |
https://api.tenor.com/v1 |
Tenor API base URL |
GIPHY_API_KEY |
built-in public client key | Giphy API key override; set empty to disable Giphy |
GIPHY_API_URL |
https://api.giphy.com/v1/gifs |
Giphy API base URL |
KLIPY_API_URL |
https://api.klipy.com/v2 |
Klipy API base URL, for a proxy or a test server |
FFMPEG_THREADS |
2 |
Threads each ffmpeg job may use; jobs run at a lower priority beside the server |
PUBLIC_DIR |
public |
Logos and icons served to everyone |
BACKUP_DIR, PG_DUMP |
data/backups, pg_dump |
Where database backups (Admin > Backups) go, and the tool that makes them; media/ and data/ need copying separately |
ADMIN_ANY_IP |
unset | 1 opens the admin panel to every address, ignoring the allow-list in its settings (to get back in after locking yourself out) |
GOOGLE_FONTS_CSS_ORIGIN, GOOGLE_FONTS_FILE_ORIGIN |
Google's | Where theme fonts are fetched from before this instance serves them |
FEDERATION |
on | off stops federating (see FEDERATION.md) |
FEDERATION_KEY_FILE |
data/federation-key.json |
This instance's federation key; back it up |
FEDERATION_REGISTRY_URL |
instances.json on this repository's main |
The list of instances to federate with |
FEDERATION_MEDIA, FEDERATION_MEDIA_CACHE_MB |
unset, 64 |
store keeps other instances' media on disk; otherwise it's fetched on view and the newest kept in memory |
FEDERATION_EVENTS_PER_HOUR, FEDERATION_MAX_ACCOUNTS |
20000, 100000 |
Changes taken from one other instance per hour before its sync pauses, and the most of its accounts this instance takes |
FEDERATION_ALLOW_HTTP |
unset | 1 federates over plain HTTP. For test instances only |
XITTER_VERSION |
version in package.json |
The version this instance reports in /.well-known/xitter and compares when an account moves |
XITTER_COMMIT |
read from .git |
The commit /.well-known/xitter shows, for images built without .git; the update check also uses it to count how far behind the instance is |
UPDATE_CHECK, UPDATE_REPO, UPDATE_BRANCH, UPDATE_GITHUB_TOKEN |
on, the GitHub repository SOURCE_URL names (else Xittered/xitter), main, unset |
Every six hours the server asks GitHub whether this branch has a newer Xitter, and admins see a banner atop the panel when it does. UPDATE_CHECK=off turns that off; a token raises GitHub's limit of 60 requests an hour per address |
SELF_UPDATE, SELF_UPDATE_REPOS, SELF_UPDATE_RESTART |
off, UPDATE_REPO (else Xittered/xitter), exit |
on lets admins update this checkout from Admin > Server update; they need two-factor on, and give their password and a code each time. The code they pick runs as this server, so by default only the update repository is allowed: SELF_UPDATE_REPOS names others (owner/name, comma-separated), and * allows any. The server's user must own the checkout and reach pg_dump (PG_DUMP). To restart, the process exits with code 75, for systemd (Restart=on-failure or always) or Docker to start it again; SELF_UPDATE_RESTART runs a shell command instead. An update whose code loads but fails on start (a migration that breaks, say) leaves the instance down, and the panel with it: go back over SSH with git checkout of the commit in data/self-update.json (previous), bun install, and a restart. A SELF_UPDATE_RESTART script that restarts, checks the server answers, and does that itself when it doesn't covers this case |
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.
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.
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).
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 -drealm.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.
CONTRIBUTING.md has the rules for changes and how to run
the tests; AGENTS.md explains how the code fits together and what
to watch out for (it's written for coding agents, and works for people too).
sh scripts/ci-local.sh runs the same checks as CI against a throwaway
database.
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.
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).
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 |
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).