Self-hosted Matrix homeserver for the Cosmos community, with an Element web client and a mautrix-discord bridge that mirrors the CosmosOS Discord server, so the community can chat from Matrix or Discord and everyone sees the same conversations.
Everything runs in rootless Docker on a single VPS, hardened following the OWASP Docker Top 10.
- 📋 Architecture & security plan: docs/secure-chat-zone.md
- 🗺️ Status & next tasks: ROADMAP.md
| Endpoint | Role |
|---|---|
https://chat.gocosmos.org |
Element web client |
https://matrix.gocosmos.org |
Synapse (client API + federation over 443) |
@user:gocosmos.org |
User IDs (delegated via .well-known on gocosmos.org) |
| Service | Image | Notes |
|---|---|---|
| Caddy | caddy:2 |
TLS termination, routing, security headers; the only service exposed publicly |
| Synapse | matrixdotorg/synapse |
Matrix homeserver, loopback-only, behind Caddy |
| Element | vectorim/element-web |
Static web client, separate origin from Synapse |
| PostgreSQL | postgres:17 |
Databases for Synapse and the bridge, internal network only |
| mautrix-discord | dock.mau.dev/mautrix/discord |
Discord ↔ Matrix bridge (relay mode via webhooks) |
| Onboarding | python:3.13-slim |
Discord reaction → auto-created Matrix account (onboarding/onboard.py) |
| Join page | python:3.13-slim |
Public signup at /join behind a self-hosted ALTCHA captcha (join/join.py) |
| Forum mirror | python:3.13-slim |
Discord forum channels as an index room of post cards plus one room per post, replies and !post both ways, plus the API behind Element's forum view (forum/forum.py) |
| Draupnir | gnuxie/draupnir |
Moderation bot for the rooms open to other servers: raid lockdown, shared ban list, bans across every room (draupnir/README.md) |
Security highlights: images pinned by sha256 digest, cap_drop: ALL,
no-new-privileges, read-only root filesystems, memory/pid limits, an
internal: true backend network, captcha-gated registration (see below), and the
Synapse admin API blocked at the reverse proxy (reachable only through an SSH
tunnel to the VPS loopback).
Secrets (.env, synapse/homeserver.yaml, bridge configs) are not in this
repo: they are generated locally and never committed.
# 1. Generate secrets (writes .env + synapse/homeserver.yaml, both gitignored)
./scripts/gen-secrets.sh
$EDITOR .env # set ACME_EMAIL; set COMPOSE_PROFILES=public
# once DNS points at the VPS (starts Caddy)
# 2. Ship the repo to the VPS (a dedicated non-sudo user runs the stack)
scp -r . <vps>:/tmp/cosmos-chat && \
ssh <vps> 'sudo rm -rf /home/chat/cosmos-chat && sudo mv /tmp/cosmos-chat /home/chat/ && sudo chown -R chat:chat /home/chat/cosmos-chat'
# 3. Upload wellknown/* to the gocosmos.org web hosting
# → must be reachable at https://gocosmos.org/.well-known/matrix/{server,client}
# 4. Start the stack (on the VPS, as the chat user)
cd ~/cosmos-chat
# one-time: synapse runs as 991:991 with all capabilities dropped,
# so the data volume must be pre-owned by that UID
docker run --rm -v cosmos-chat_synapse-data:/data alpine chown -R 991:991 /data
docker compose up -d
# 5. Verify
# https://federationtester.matrix.org/#gocosmos.org
# https://chat.gocosmos.org
# 6. Create the first admin account
docker compose exec synapse register_new_matrix_user \
-c /config/homeserver.yaml -a http://localhost:8008
# 7. Bridge the Discord server → bridge/README.mdTwo doors, both self-hosted, no Google services involved:
- https://chat.gocosmos.org/join: the public signup link (shared from
gocosmos.org). Protected by ALTCHA, a FOSS
proof-of-work captcha served entirely from our own stack (widget vendored
in
join/altcha.js, MIT), plus a honeypot field and per-IP rate limits. Accounts are created through Synapse's shared-secret endpoint and auto-joined to every bridged channel and the space (same flow as the Discord door); visitors already signed in are redirected to the app. - Discord reaction: reacting ✅ on the watched announcement message
auto-creates a mirrored account (see
onboarding/onboard.py).
Every other surface is closed: the raw client registration API is disabled
(enable_registration: false; both doors above use internal admin endpoints,
which keep working). Element keeps its native UI, but the served app shell is
patched (scripts/patch-element-index.sh): every Create account button and
the #/register route land on /join, and Google's recaptcha hosts are
stripped from the CSP, so the ALTCHA page is the only captcha that can ever
run in a visitor's browser.
No new account is needed for people already on Matrix (matrix.org,
mozilla.org, ...): from their own client they join the space
#cosmos:gocosmos.org (https://matrix.to/#/#cosmos:gocosmos.org), then
any public channel from it. The space is public; the rooms of the channels
@everyone can see on Discord are joinable by its members, staff channels stay
invite-only. The onboarding daemon keeps this in line with Discord every 10
minutes, and only opens a room once Draupnir moderates it
(draupnir/README.md). chat.gocosmos.org itself only signs in gocosmos.org
accounts.
Arrivals show in one place, like on Discord: the onboarding daemon greets
every new member of the space in #welcome, and chat.gocosmos.org hides
join/leave, avatar and name changes by default (setting_defaults in
element/config.json, each member can turn them back on; Element reads
these three under their legacy names hideJoinLeaves, hideAvatarChanges
and hideDisplaynameChanges, a showJoinLeaves: false is silently
ignored). New accounts
join the open channels directly, so no invite shows up in them. They also
get Element's "People" section turned off on every CosmosOS space, so DMs
stay in Home instead of following them into each category (a per-account,
per-space setting the Element config cannot default).
Discord's forum channels (#cosmos-projects, #cosmos-help, #other-projects)
have no Matrix equivalent, and Element has no forum room type. The forum
mirror (forum/forum.py) gives each forum an index room of post cards and
one room per post, synced both ways with Discord. On chat.gocosmos.org an
Element plugin (element/modules/cosmos-forum/index.js, no fork; the
Element image registers every /modules/<name>/index.js it finds, see
compose.yml) turns the index room into a
Discord-style forum, under Element's own room header and next to the room
list:
- every post of the Discord forum, active or archived (1,585 at launch), with search, tag filters, sorting by activity, age or replies, and a New post form that creates the post on Discord under the member's name
- a post opens as a normal room with a "← #forum" button back, without leaving the space you are in. Element only keeps you in a space for rooms you joined (it ignores a room's own parent space unless you may manage that space), so opening a post joins it, and moving on leaves it again unless you wrote or reacted there: reading does not follow a post, like on Discord, and you can reply at once
- a post that has no room yet (anything quiet for more than 60 days) is brought over on the spot with its opening message and last 100 replies, so old posts only become rooms when someone reads them
- a Chat view / Forum view toggle in the forum room's header shows the
plain timeline of cards, which is also what other Matrix apps see (they
keep
!post)
The plugin gets its data from the mirror's API at
chat.gocosmos.org/forum-api/, authenticated with a Matrix OpenID token
checked against Synapse (local accounts only). Element waits for its
plugins before starting, so the plugin never throws out of its setup: if
the API is down, forum rooms show their timeline of cards; if an Element
upgrade renames the room view, the same. Its module API version is
accepted as a range, so an upgrade cannot lock Element out, but check the
forum view after each Element bump.
compose.yml # the whole stack (Caddy, Synapse, Postgres, Element, bridge)
caddy/Caddyfile # TLS, routing, security headers
synapse/ # homeserver.example.yaml (template) + log.config
element/config.json # Element web configuration
element/modules/ # Element plugins (<name>/index.js): cosmos-forum, the forum view
postgres/ # first-boot init script (bridge DB)
bridge/ # mautrix-discord setup guide (configs generated, gitignored)
onboarding/onboard.py # Discord reaction -> Matrix account daemon
join/ # public signup page (ALTCHA captcha + vendored widget)
forum/forum.py # Discord forum -> Matrix index room + one room per post, forum view API
draupnir/ # moderation bot config + setup guide
wellknown/ # files served at gocosmos.org/.well-known/matrix/
scripts/gen-secrets.sh # creates .env + homeserver.yaml with random secrets
scripts/setup-draupnir.sh # one-time Draupnir account, management room, space address
docs/secure-chat-zone.md # architecture & threat-model documentation