Running Locally
Prerequisites
- Node.js 20+, pnpm 9 (
packageManagerfield pins it —corepack enablepicks it up automatically) - Docker (for Postgres + Redis, and for a full container run)
The backend, in a few commands
git clone https://github.com/aiyu-ayaan/BetweenUs.git betweenus
cd betweenus
cp .env.example .env
pnpm install
pnpm dev:infra # Postgres + Redis via Docker Compose
pnpm db:generate
pnpm db:migrate
pnpm dev:backend # every service, watch mode
pnpm dev:infra reads .env from infrastructure/docker/, not the repo
root — the script passes --env-file .env for you; running docker compose
by hand without that flag starts every service with empty ${VAR}s.
pnpm dev:backend runs the services only. pnpm dev additionally starts
the desktop renderer on port 5173, which collides with pnpm dev:duo (see
Testing) — use dev:backend when you plan to run dev:duo
alongside it.
A service port something else already holds
Each service listens on <NAME>_PORT from the repo .env (AUTH_SERVICE_PORT,
CHAT_SERVICE_PORT, ...). The Vite proxies, pnpm dev:gateway and
pnpm dev:duo read the same values through scripts/dev-services.mjs, so
changing one line in .env moves a service for all of them. A <NAME>_URL
exported in the shell still wins, for a service running somewhere else.
If another program already holds a port - 3001 is a common one - that service
exits with EADDRINUSE and the one in its place answers with its own 404s,
which the sign-in form can only show as "Request failed". The dev gateway checks
every service's /health 15 seconds after it starts and names the port that
answers as something else. Set that service's _PORT in .env to a free port
and restart pnpm dev. Production containers pin their ports in Compose and do
not read these. The image build still copies scripts/dev-services.mjs in,
though: apps/web's vite.config.ts imports it and the web build type-checks
that config, so an image without it fails with TS2307.
Desktop client
pnpm --filter @betweenus/desktop dev # run in development (the Dev channel)
pnpm desktop:package # package production executable (.exe / installer)
pnpm desktop:package:dev # package the Dev channel, installable beside it
Flavours: BetweenUs and BetweenUs Dev
A development run used to be the same application as the BetweenUs somebody
has installed, and not by analogy: app.getName() reads the manifest inside the
bundle rather than the product name on the shortcut, so the installed client and
pnpm dev both answered to @betweenus/desktop - one userData directory, one
single-instance lock, and a development window that never opened while the
installed one was running.
A development run is named BetweenUs Dev instead, and because Electron derives
everything else from the name, that one difference gives it its own
%APPDATA%\BetweenUs Dev (its own settings, secrets, E2EE device key and
downloads), its own single-instance lock, its own Windows notification identity
and its own tray tooltip.
So the installed client can stay open, in the tray, signed in, while you
develop — closing it first is no longer part of the loop. The rule is one line
in apps/desktop/electron/flavor.ts: unpackaged is always the Dev channel, and
a packaged build is whatever product name it was built with.
pnpm desktop:package:dev builds that Dev channel as a real installer
(electron-builder.dev.yml), which installs beside the stable one rather than
over it. A Dev build is never offered updates — every GitHub release is a
stable-flavour installer and would install as the other application.
Web client
pnpm dev:web
Serves at http://localhost:5175. The dev server proxies the same routes
the desktop client talks to, minus /api/v1/remote and /ws/remote — the
browser build has no remote-desktop section.
Admin panel
pnpm dev:admin
The first administrator is created from the CLI, not a web form — a page open to the internet that could mint the first admin is a race anyone could win:
pnpm admin:create
Two signed-in windows (chat, voice, presence)
pnpm dev:duo
See Testing for what it sets up and what to try.
Full container stack
cp .env.example .env
pnpm prod:build
pnpm prod:up # pull published images, or:
pnpm prod:up:build # build images locally
See Docker Compose for the service list and networks.
Database
pnpm db:generate # Prisma client
pnpm db:migrate # apply migrations (dev)
pnpm db:seed # seed data
pnpm db:studio # Prisma Studio, browse the data
Android
pnpm android:build # debug APK
pnpm android:test # unit tests
pnpm android:run # install it on a connected device and start it
Needs a JDK on PATH to launch the wrapper, but no particular version of
one: Gradle provisions the daemon JVM itself, pinned to 17 by
apps/android/gradle/gradle-daemon-jvm.properties. android:run also wants
an Android SDK and adb on PATH.
local.properties sets betweenus.serverUrl, which is only a default — the
login screen's server picker overrides it at runtime. The emulator reaches
this machine at 10.0.2.2, so http://10.0.2.2:8090 is pnpm dev:gateway
seen from inside it.
Docs site
From the repo root (docs/ is a standalone npm project, not a pnpm
workspace member, so these wrap npm --prefix docs):
pnpm docs:install
pnpm docs # http://localhost:3000, live reload
pnpm docs:build # static site into docs/build/
Or directly:
cd docs
npm install
npm start
npm run build
npm run serve # serve the production build locally
See Docs Deployment for how a !docs commit
publishes this to GitHub Pages.
pnpm db:migrate can reset your database, and what stops it
db:migrate runs prisma migrate dev. That command verifies the whole
migration history before it applies anything, and the only remedy it offers for
a history it cannot reconcile is to drop the database and replay from empty. On
a developer's machine that prompt arrives at the end of a wall of output and is
easy to answer yes to, because it reads as being about the migration you just
wrote.
The usual way to get there is a renamed migration directory. Your database
holds a _prisma_migrations row naming a directory that is no longer on disk,
migrate dev calls that a diverged history, and offers the reset. It has
happened here once already — see
packages/database/prisma/reconcile/2026-09-02-rename-custom-roles-and-attachments.sql.
pnpm db:migrate now runs prisma/preflight.mjs first, which:
- renames the rows for any directory listed in its
RENAMEDmap, so a known rename repairs itself and the migration carries on; - stops with a non-zero exit for any other applied-but-missing migration,
naming it, before
migrate devgets the chance to offer a reset.
It only ever updates _prisma_migrations.migration_name. It never drops,
truncates or alters anything else.
If you rename a migration directory, add the old and new names to RENAMED in
the same commit. A rename with no entry there is the bug this exists for.
Two other habits worth having:
pnpm db:migrate:check # does the migration history still replay to schema.prisma?
pnpm db:backup # a dump, before anything that touches the schema
db:migrate:check catches a hand-written migration that disagrees with
schema.prisma — which is the other way to arrive at a drift prompt. Neither
of these is run for you by db:migrate, and db:backup needs Docker.