An open-source virtual office platform for remote teams. Proximity-based spatial audio/video, a live 2D world built on Phaser, and a real-time multi-user engine powered by Colyseus.
git clone https://github.com/lass-machen/meetropolis.git
cd meetropolis
cp .env.example .env # edit with your values
mkdir -p data/postgres data/packs
sudo chown -R 999:999 data/postgres # Postgres in the container is UID 999; not needed on macOS/Windows with Docker Desktop
# JWT_SECRET and API_TOKEN_PEPPER ship EMPTY in .env.example — generate
# both and set them in .env before the first `docker compose up`:
echo "JWT_SECRET=$(openssl rand -hex 32)"
echo "API_TOKEN_PEPPER=$(openssl rand -hex 16)"
docker compose up -d # builds OSS images; starts db, server, web, livekitAfter startup:
| Service | URL |
|---|---|
| Web app | http://localhost:5173 |
| Server (Colyseus + REST) | http://localhost:2567 |
| LiveKit signal | http://localhost:7880 |
Required env values: JWT_SECRET, API_TOKEN_PEPPER. Both are empty
in .env.example on purpose — compose.yaml refuses to start
(:?required) until you set them, so generate and fill them in
before the first docker compose up, not after a failed start.
compose.yaml ships no reverse proxy and no TLS terminator — you are
expected to put your own (Traefik, Caddy, nginx, etc.) in front of the
server and web containers for production. See ## Production deployment
below for the minimum env surface.
The shipped compose.yaml is the production-style self-host stack. For
hot-reload, bind-mounted source or extra services (Prometheus, Grafana,
load-test harness, NPC automation), add an uncommitted
compose.override.yaml next to it; Docker Compose picks the override up
automatically on up. The OSS repo intentionally ships only the single
production-style compose file — extra dev tooling lives in your own
override or in the (private) deploy repo.
Requires a running PostgreSQL instance. Configure DATABASE_URL in .env.
npm install
npm run dev # starts server + web concurrentlyThe server's predev script runs prisma generate, db push and db seed
automatically before the dev server boots.
To run workspaces individually:
npm run dev:server # apps/server only
npm run dev:web # apps/web only| Requirement | Version |
|---|---|
| Node.js | >=24.0.0 <25 (enforced by engines in package.json) |
| npm | 11.7.0 (pinned via packageManager) |
| Docker + Docker Compose | optional, for the full-stack local setup |
| PostgreSQL 16 | required for local dev without Docker |
macOS / Windows + Docker Desktop note: Chrome filters loopback ICE candidates. For the self-host stack (
compose.yaml) setLK_NODE_IPand / orLK_NAT_1_TO_1_IPSto your public IP (or LAN IP for local testing) and restart LiveKit:docker compose restart livekit.
meetropolis/
├── apps/
│ ├── server/ # Express + Colyseus + Prisma
│ │ └── prisma/ # schema + migrations
│ ├── web/ # React + Vite + Phaser + i18next
│ │ └── src/locales/ # i18n catalog (en, de)
│ ├── npc-service/ # NPC automation service
│ └── loadtest/ # Load-testing harness
├── packages/
│ └── shared/ # Shared types and utilities
├── scripts/
│ ├── enforce-budgets.js # LoC budget gate (runs via npm run lint)
│ └── lint-stats.cjs # ESLint warning regression gate
├── docs/ # Extended docs
├── AGENTS.md # Dev guidelines and quality budgets
├── LIBRARY_BOUNDARIES.md # Type-boundary patterns for unsafe library edges
├── TEST_STRATEGY.md # Testing approach and coverage expectations
├── lint-stats.json # Tracked lint warning baseline (committed)
├── eslint.config.mjs # ESLint flat config
├── commitlint.config.mjs # Conventional commits enforcement
├── compose.yaml # Self-host stack (Postgres + server + web + LiveKit)
└── .env.example # All available environment variables
The server and web app contain a small number of dynamic-import boundaries
(apps/server/src/{tenancyLoader,billingLoader,adminLoader}.ts,
apps/web/src/lib/{enterpriseWebLoader,brandLoader,desktopLoader}.ts,
apps/web/optional-submodules.ts) that resolve to null in this
distribution. Tiamat operates additional closed-source modules against these
boundaries; the OSS build runs fully without them.
| Script | What it does |
|---|---|
npm run dev |
Start server + web concurrently |
npm run dev:server |
Server only |
npm run dev:web |
Web only |
npm run build |
Build web (Vite) and server (tsc) |
npm run typecheck |
tsc --noEmit in all workspaces |
npm run lint |
ESLint + budget gate + lint-stats regression check |
npm run lint:fix |
ESLint with auto-fix |
npm run lint:stats:update |
Update the committed lint warning baseline |
npm run format |
Prettier write |
npm run format:check |
Prettier check (no write) |
npm run generate |
Run prisma generate for the server workspace |
npm run prisma:migrate |
Create and apply a new migration |
Copy .env.example to .env. Key variables:
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
JWT_SECRET |
JWT signing secret (32+ chars in production) |
LIVEKIT_URL |
LiveKit server URL |
LIVEKIT_API_KEY |
LiveKit API key |
LIVEKIT_API_SECRET |
LiveKit API secret |
HOST_IP |
Host LAN IP for LiveKit ICE (Docker Desktop on macOS) |
CORS_ORIGIN |
Allowed CORS origins (required in production) |
VITE_API_BASE |
Server URL baked into the web bundle at build time |
VITE_LIVEKIT_URL |
LiveKit URL baked into the web bundle at build time |
See .env.example for the full list including optional variables.
VITE_*variables are build-time, not runtime.compose.yamlpassesVITE_API_BASEandVITE_LIVEKIT_URLas Docker build args for thewebimage (defaulthttp://localhost:2567/ws://localhost:7880). Changing them in.envafter the image is built has no effect — the values are compiled into the static bundle. For a domain / reverse-proxy setup, set both to your public URLs (VITE_API_BASE=https://api.example.com,VITE_LIVEKIT_URL=wss://livekit.example.com) and rebuild thewebimage:docker compose build web. Reverse-proxy labels alone do not fix this — the bundle still points atlocalhostuntil it is rebuilt. Seecompose.override.traefik.yaml.examplefor a worked reverse-proxy setup.
Tests use Vitest.
# Web (apps/web)
npm --workspace=@meetropolis/web run test
# Server (apps/server)
npm --workspace=@meetropolis/server run testSee TEST_STRATEGY.md for the project's testing philosophy, coverage expectations, and guidance on where to place new tests.
npm run lint # full lint pipeline
npm run lint:fix # auto-fix where possible
npm run format # Prettier write
npm run format:check # Prettier checklint-stats.json records the current warning count baseline. The
lint:stats script fails if the warning count exceeds the baseline. Before
accepting an intentional suppression or a temporary warning increase, update
the baseline:
npm run lint:stats:updateCommit the updated lint-stats.json alongside your change.
scripts/enforce-budgets.js enforces file-size limits defined in
AGENTS.md:
- React/TS/server files: target <= 400 LoC, hard limit 600 LoC
- Phaser scene files: target <= 300 LoC, hard limit 450 LoC (with documented exceptions)
- Utility modules: target <= 300 LoC, hard limit 450 LoC
Files exceeding the hard limit block the lint step. Refactor before merging.
Files that are intentional exceptions (Phaser scene classes, composite
hooks) are listed in .budgetignore with a written reason.
Third-party libraries, runtime globals and optional submodule boundaries
require special handling to stay compatible with strict TypeScript and the
@typescript-eslint/no-unsafe-* rules. The project's four-tier approach
(wrapper types, module augmentation, file-scoped overrides, inline disables
with written justification) is documented in
LIBRARY_BOUNDARIES.md. Read it before adding any
as any or ESLint disable.
Commits follow Conventional Commits.
commitlint enforces this via a Husky commit-msg hook. A pre-commit
hook runs lint-staged.
The Tiamat-managed instance at meetropolis.me runs additional closed-source modules (Brand, Enterprise, Desktop) which are not part of this open-source distribution.
OSS user limit: 25 concurrent users across the whole server. This is a
compile-time constant in packages/shared/src/tenancy.ts and there is no
env-var override - raising the cap is part of what the commercial edition
sells. The server still runs above 25 (nothing crashes), but the 26th
joining user is kicked with oss_limit_reached.
Before deploying for your team or customers:
- Replace brand assets in
apps/web/public/brand/(logo, favicon). - Provide your own legal pages (
/privacy,/terms,/impressum). The default build renders neutral placeholders. - Update the HTML title and meta description in
apps/web/index.html. - Configure your own analytics tracking (
VITE_META_PIXEL_ID) or leave it disabled. - Review and adjust source strings that reference "Meetropolis" if you intend to publish a derivative product (see TRADEMARKS.md).
Control your presence remotely via personal API tokens:
- Open "API Tokens and Docs" from the top-right menu.
- Create a token. It is shown only once, save it.
- Use it to control mic, camera, screenshare and DND status:
curl -X POST "http://localhost:2567/controls" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mic": false, "dnd": true}'The bundled compose.yaml covers the minimum self-host case (Postgres
- Server + Web + LiveKit, no reverse proxy). For real production add
your own TLS terminator (Traefik, Caddy, nginx), backups for the
./data/postgresbind mount, a proper TURN/TLS LiveKit config, and rotate the default secrets:
| Variable | Requirement |
|---|---|
NODE_ENV |
production |
JWT_SECRET |
Cryptographically random, 32+ characters |
API_TOKEN_PEPPER |
Random string for API token hashing |
CORS_ORIGIN |
Explicit list of allowed origins |
COOKIE_SECURE |
true |
See docs/livekit-production.md for the
LiveKit-specific hardening walkthrough (replacing --dev, rotating
keys, ICE / NAT setup, verification).
Known asymmetry:
package-lock.jsonreflects the OSS-only install state. Internally, Tiamat builds the same Dockerfile with the closed-source sibling repos mounted viaadditional_contexts, which can causenpm installto re-resolve some workspace edges. The lockfile committed here is the OSS-only ground truth — Tiamat regenerates a private lockfile on its build host. Self-hosters never see this; the OSS image build is deterministic against the committed lockfile.
The OSS server can send transactional email (invites, guest magic links, verify links, welcome emails) over plain SMTP via nodemailer. Sensitive tokens (password reset) are NEVER sent by email — the admin generates them via the admin UI.
The mail loader resolves a single active provider at server boot:
- EE-Resend — when the optional
@meetropolis/tenancymodule is installed and bothRESEND_API_KEY+RESEND_FROMare set. - OSS-SMTP (nodemailer) — when
SMTP_HOST+SMTP_FROMare set. - Console fallback — last-resort, logs subject + recipient domain
only (no body in production), returns
falsefromsend()so verify-requests keep surfacing tokens via the API response. A one-time WARN log fires at boot:email.fallback_console.
Pick whichever fits your compliance and budget needs. All four below work over plain SMTP and require no EE module:
| Provider | SMTP host | Notes |
|---|---|---|
| Amazon SES | email-smtp.<region>.amazonaws.com |
Cheapest at scale; needs AWS account + verified domain |
| Brevo | smtp-relay.brevo.com |
EU-resident option, generous free tier |
| Mailgun | smtp.mailgun.org |
Mature, good deliverability dashboards |
| Postmark | smtp.postmarkapp.com |
Transactional-only, strict on quality |
| Resend SMTP | smtp.resend.com |
Works without the EE module, modern API |
SMTP_HOST=email-smtp.eu-central-1.amazonaws.com
SMTP_PORT=587
SMTP_SECURE=auto
SMTP_USER=AKIA...
SMTP_PASS=BO...
SMTP_FROM="Meetropolis <noreply@example.com>"
# Optional:
SMTP_REPLY_TO=support@example.com
MAIL_BRAND_NAME=Meetropolis
MAIL_DEFAULT_LOCALE=de
MAIL_SUPPORT_EMAIL=support@example.comMailpit is a Docker-friendly local SMTP server with a web UI on port 8025. It does not require TLS:
# in compose.override.yaml (next to compose.yaml, gitignored)
services:
mailpit:
image: axllent/mailpit
ports:
- '1025:1025' # SMTP
- '8025:8025' # Web UISMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_FROM="Meetropolis <dev@local>"
SMTP_TLS_REJECT_UNAUTHORIZED=falseSetting SMTP_HOST is not enough — without DKIM/SPF alignment, mails
land in spam or get bounced outright. Configure all three records on
the domain you put after @ in SMTP_FROM:
SPF (TXT record on example.com)
example.com. IN TXT "v=spf1 include:amazonses.com -all"
Adjust the include: for your provider:
| Provider | SPF include |
|---|---|
| SES | include:amazonses.com |
| Brevo | include:spf.brevo.com |
| Mailgun | include:mailgun.org |
| Postmark | include:spf.mtasv.net |
| Resend | include:_spf.resend.com |
DKIM (CNAME records on selectors provided by your sender)
Your provider's dashboard shows the exact CNAME targets. Example for SES:
abc123._domainkey.example.com. IN CNAME abc123.dkim.amazonses.com.
def456._domainkey.example.com. IN CNAME def456.dkim.amazonses.com.
xyz789._domainkey.example.com. IN CNAME xyz789.dkim.amazonses.com.
DMARC (TXT record on _dmarc.example.com)
Start in monitor mode, then ratchet up:
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; pct=100"
After verifying via aggregate reports for ≥ 1 week, switch p=none to
p=quarantine, then eventually p=reject.
SMTP_TLS_REJECT_UNAUTHORIZED defaults to true. NEVER set it to
false in production, except for internal relays with known
self-signed certificates. The server emits a WARN
(email.smtp.tls_validation_disabled) when it sees that combination
in production.
The OSS compose.yaml starts LiveKit with --dev --bind 0.0.0.0 and
no TURN flags. WebRTC then negotiates connections via STUN only, which
covers ~80–90 % of home-internet setups. The remaining 10–20 %
(symmetric NAT, carrier-grade NAT, strict corporate firewalls that
block UDP) cannot establish audio/video without a TURN relay.
If your users sit behind such networks — typical for remote teams on
VPN, mobile hotspots or restricted office wifi — enable LiveKit's
built-in TURN server. No separate coturn container is required;
the LiveKit binary ships TURN as a library.
- A public FQDN you control (e.g.
turn.example.com). - A valid TLS certificate for that FQDN. Easiest path: terminate TLS at Traefik / Caddy / nginx in front of LiveKit so Let's Encrypt handles the renewal automatically.
- Open ports on the host:
5349/tcp(TURN over TLS)3478/tcpand3478/udp(TURN/STUN)7882/udp(LiveKit RTC UDP, already exposed by the OSS compose)
Drop a file next to compose.yaml (gitignored, auto-merged by Docker
Compose):
services:
livekit:
command:
- --dev
- --bind
- 0.0.0.0
- --turn.enabled=true
- --turn.domain=${TURN_HOST}
- --turn.tls_port=5349
- --turn.external_tls=true
ports:
# Add the TURN ports on top of the OSS defaults.
- '5349:5349/tcp'
- '3478:3478/tcp'
- '3478:3478/udp'Then set TURN_HOST in your .env:
TURN_HOST=turn.example.comIf you front LiveKit's TURN-TLS port with Traefik, add a TCP router
that forwards HostSNI(TURN_HOST):5349 to the livekit container.
Caddy / nginx work analogously with TLS passthrough on port 5349.
--dev keeps an in-memory key store and relaxes a couple of defaults.
For a production instance, move keys into a livekit.yaml and swap
the command to --config /etc/livekit.yaml. The LiveKit docs cover
the full file layout; the OSS stack stays out of the way as long as
LIVEKIT_API_KEY / LIVEKIT_API_SECRET in .env match whatever the
config file declares.
Small in-house teams that all share the same office wifi, or a single remote group on broadband at home, will rarely hit a NAT scenario that TURN solves. STUN-only (the OSS default) is enough. Only invest in TURN once a real user reports audio/video that never connects.
These items are deliberately not part of the OSS-SMTP stack and would require dedicated blocks:
- Reply-To per tenant (currently a single global
SMTP_REPLY_TO). - List-Unsubscribe / one-click unsubscribe headers (transactional mail generally exempt under CAN-SPAM, but consult counsel for marketing).
- Bounce handling. nodemailer does not parse bounces. Configure your provider's dashboard (SES SNS, Brevo webhook) or run a local Postfix relay with bounce logs.
- Per-tenant rate-limiting for guest invites.
express-rate-limitis installed but not wired up — track as a follow-up. - Self-service password reset via email. Block C is invite/verify/guest only; password reset remains admin-triggered with token-in-UI.
Read AGENTS.md before opening a PR. It covers architecture rules, quality budgets, naming conventions, commit workflow and PR expectations. Most of these are enforced by tooling (Husky, commitlint, ESLint, budget scripts).
Also read:
- CONTRIBUTING.md - general contribution process
- CODE_OF_CONDUCT.md
- SECURITY.md - responsible disclosure and known advisories
Meetropolis is dual-licensed:
- Server components (
apps/server,apps/npc-service,apps/loadtest, and the repository root) are licensed under AGPL-3.0-only. - The web client (
apps/web) and the shared types package (packages/shared) are licensed under MIT.
A commercial license that removes the AGPL-3.0 obligations is available from Tiamat UG. Contact mail@meetropolis.me for details.
See LICENSING.md for the rationale, per-component breakdown, and our promises around license stability. See NOTICE for third-party attributions.
The "Meetropolis" name and logo are trademarks of Tiamat UG and are not granted by either license. See TRADEMARKS.md.
