Migrate a Mattermost workspace (PostgreSQL backend) to Rocket.Chat — users, channels, direct messages, messages with original timestamps, threads, reactions, edits and file attachments.
Built for a real-world migration (Mattermost Enterprise 11.x → Rocket.Chat 8.x
Community, ~50 users, ~200k messages incl. delta/cutover mode) and released so
others can reuse it. Tested against Rocket.Chat 8.5.x and 8.8.x; the
direct-MongoDB parts are inherently version-sensitive, so always run verify
after a load and test against your exact Rocket.Chat version first.
Two decoupled commands connected by a portable archive format:
mattermost2rocketchat extract --out ./archive # runs on the Mattermost host (read-only)
mattermost2rocketchat load --archive ./archive # runs on the Rocket.Chat host
mattermost2rocketchat verify --archive ./archive # count + content comparison
- extract reads the Mattermost PostgreSQL database read-only
(
users,teams,channels,channelmembers,posts,reactions,fileinfo) and copies the file blobs from the Mattermost data directory into an archive: JSONL files + blob tree +manifest.jsonwith counts and SHA-256 checksums. - load imports the archive:
- users, public/private channels and memberships via the REST API (Rocket.Chat keeps its own consistency),
- direct/group messages as rooms directly in MongoDB (the API cannot create DMs between arbitrary users),
- all messages directly in MongoDB with deterministic 17-char IDs derived from the Mattermost post ID — this is the only way to preserve historical timestamps, and it makes every run idempotent,
- file attachments via REST upload (staged through a private
mm2rc-stagingroom so the admin always has write access, then re-linked to the target room in MongoDB); files the API rejects (corrupt images, blocked types) fall back to a direct store write whenrocketchat.uploads_diris configured, - room counters,
lastMessage, thread aggregates and read markers, - cleanup of system-message noise Rocket.Chat itself generates during the
import (
subscription-role-added/removed,room_changed_topic, ...).
- verify compares per-channel message counts, user/channel/file mappings, thread counts and file sizes between archive and Rocket.Chat, and writes a machine-readable report.
load is incremental by design: re-running it only inserts what is missing.
The typical cutover is: full extract+load → validate → maintenance window →
Mattermost read-only → final extract+load (delta) → switch DNS/proxy.
The SQLite state file (
<archive>/mm2rc-state.sqlite3) holds the Mattermost↔Rocket.Chat ID mappings. Keep it next to the archive — without it, user/channel/file linkage cannot be resumed (messages are still idempotent via deterministic IDs).
Requires Python 3.11+.
pip install .
# or for development
pip install -e '.[dev]'A Dockerfile is included for running on servers without a Python setup:
docker build -t mattermost2rocketchat .YAML file (-c config.yaml) and/or environment variables:
mattermost:
pg_dsn: postgresql://user:pass@mattermost-host:5432/mattermost
data_dir: /opt/mattermost/data
team_strategy: flatten # flatten | prefix (channel name collision handling)
rocketchat:
url: http://rocketchat:3000
admin_user: admin
admin_password: secret
mongo_url: mongodb://mongodb:27017/rocketchat?replicaSet=rs0&directConnection=true
uploads_dir: /opt/rocketchat/uploads # host path of RC's upload store (direct-store fallback)
options:
placeholder_email_domain: localhost.invalid # for MM users without an email
include_system_messages: true # join/leave/add/remove/...
batch_size: 1000Environment overrides: MM_PG_DSN, MM_DATA_DIR, RC_URL, RC_ADMIN_USER,
RC_ADMIN_PASSWORD, RC_MONGO_URL, RC_UPLOADS_DIR.
# on the Mattermost host
mattermost2rocketchat -c config.yaml extract --out /tmp/mm-archive
# copy the archive to the Rocket.Chat host, then:
mattermost2rocketchat -c config.yaml load --archive /tmp/mm-archive --dry-run
mattermost2rocketchat -c config.yaml load --archive /tmp/mm-archive
mattermost2rocketchat -c config.yaml verify --archive /tmp/mm-archive --out-json report.json| Mattermost | Rocket.Chat | Notes |
|---|---|---|
| Users (incl. deactivated) | Users via REST API | random unusable password; use LDAP/OAuth merge or password reset |
| User avatars | users.setAvatar |
profile pictures (users/<id>/profile.png) uploaded per user |
| Public/private channels | Channels/groups via REST API | display names (umlauts/spaces/case) preserved as fname; name collisions across teams get suffixed |
| Channel memberships + admins | Subscriptions + owner role | |
| DMs and group DMs | d rooms (direct MongoDB) |
incl. self-DMs |
| Messages | Messages (direct MongoDB) | original timestamps, deterministic IDs |
| Threads | Threads (tmid, tcount, tlm) |
|
| Reactions | Reactions (:emoji:) |
custom emoji render as text unless the emoji exists in RC |
| Edited messages | editedAt/editedBy |
|
| File attachments | Uploads + linked messages | all files per post; API upload with direct-store fallback for rejected files |
@mentions, #channel refs, URLs |
mentions/channels/urls subdocs | ~channel rewritten to #channel |
| System messages (join/leave/add/remove/topic/header/purpose/rename) | RC system message types | best-effort; unmapped types are skipped (counted in stats) |
- Passwords are not migrated (Mattermost stores bcrypt hashes; Rocket.Chat uses a different scheme). Users authenticate via LDAP/OAuth merge or password reset. For LDAP: enable merge existing users and matching usernames.
- No bots/webhooks/integrations, no user preferences (channel mutes, notification settings), no pinned/saved messages, no channel moderation settings.
- Mattermost teams are flattened (Rocket.Chat teams are an optional,
different concept); use
team_strategy: prefixif you have name collisions. - Rocket.Chat re-encodes uploaded images (strips metadata / recompresses),
so image file sizes in Rocket.Chat can differ from the archive;
verifytolerates this for image types and compares sizes exactly for all other files. - Direct MongoDB inserts target the Rocket.Chat 8.x schema (tested on 8.5.x
and 8.8.x). Pin your version, test, and always run
verify. - Messages imported via MongoDB lack the pre-rendered
mdmarkdown AST; the web client renders them frommsg. If you rely on API consumers that requiremd, let us know (issue welcome).
pip install -e '.[dev]'
pytest # unit tests (no external services)
ruff check .Integration fixtures (seeded Postgres + Rocket.Chat + MongoDB) live in
docker-compose.test.yml — see tests/integration/.
Apache-2.0 — see LICENSE.