Self-host Rocket.Chat with Docker Compose
Self-host Rocket.Chat on a VPS with Docker Compose: the single-node MongoDB replica set it requires, TLS, backups, and every failure mode fixed.
What you are building
A private team chat you own outright: Rocket.Chat running on your own VPS under Docker Compose, terminated by TLS, with every message sitting in a MongoDB database you can back up and move. Rocket.Chat is the mature open-source Slack and Teams alternative — channels, direct messages, threads, file sharing, and voice and video, all on hardware you rent and control. The application is a single container that comes up in minutes. Everything that actually goes wrong lives in the database beside it, so most of this guide is about MongoDB, and in particular the one requirement that surprises everybody the first time: Rocket.Chat will not run against a standalone MongoDB. It needs a replica set, even if that "set" is a single node.
Prerequisites, and the RAM math nobody tells you
Size the box honestly. The realistic floor for a small team is 2 vCPU and 4 GB of RAM. Rocket.Chat's Node.js process wants roughly 1 to 1.5 GB on its own, and MongoDB's WiredTiger cache claims about half of whatever RAM is left by default. On a 2 GB VPS the two of them fit at boot and then collide the moment real traffic arrives: MongoDB grows its cache, Node grows its heap, the kernel runs out of pages, and the out-of-memory killer shoots whichever process is biggest — usually mongod. The container prints Killed, Docker restarts it, and you get a chat server that drops every few minutes under load it should shrug off. 2 GB is fine for kicking the tyres with two people; it is not a team server. Start at 4 GB, and give it 8 GB if you expect dozens of concurrent users, video calls, or a growing upload history.
You also need three things in place before you start. A domain name with an A record pointing at the VPS's public IP — Rocket.Chat's real-time features and mobile clients need a stable hostname, not a bare IP. Ports 80 and 443 open on both the server firewall and your provider's network firewall, which is a separate control on most panels. And a fresh Ubuntu 24.04 KVM VPS with root or sudo. If you are still deciding whether a chat server is the right first service to run, the guide to what is worth self-hosting in 2026 frames the tradeoffs.
Install the Docker engine and the Compose plugin
Use Docker's own apt repository, not the docker.io package Ubuntu ships and not the ancient standalone docker-compose Python binary. Modern Compose is a Docker plugin you invoke as docker compose — a space, not a hyphen. The old docker-compose v1 is end-of-life and mishandles the healthcheck and dependency syntax below.
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm both pieces are present:
sudo docker version
sudo docker compose version
docker compose version printing something like Docker Compose version v2.x is the check that matters. If it errors with docker: 'compose' is not a docker command, the plugin did not install and you are about to hit confusing failures later — fix it here.
The compose file: MongoDB as a single-node replica set
This is the part people get wrong, so read it slowly. Rocket.Chat uses MongoDB change streams to push new messages to connected clients in real time, and change streams are only available on a replica set. Point Rocket.Chat at a plain standalone mongod and it will connect, fail to open a change stream, and restart-loop forever. The fix is not exotic: you run a single, ordinary MongoDB container, but you start it with --replSet and then initialise a one-member set.
Create a working directory and a compose.yml:
services:
mongodb:
image: mongo:8.0
restart: always
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--oplogSize", "128"]
volumes:
- mongodb_data:/data/db
- mongodb_config:/data/configdb
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 10s
retries: 12
rocketchat:
image: registry.rocket.chat/rocketchat/rocket.chat:8.5.1
restart: always
depends_on:
mongodb:
condition: service_healthy
environment:
MONGO_URL: "mongodb://mongodb:27017/rocketchat?replicaSet=rs0"
MONGO_OPLOG_URL: "mongodb://mongodb:27017/local?replicaSet=rs0"
ROOT_URL: "https://chat.example.com"
PORT: "3000"
ports:
- "127.0.0.1:3000:3000"
volumes:
mongodb_data:
mongodb_config:
A few choices here are deliberate. The Rocket.Chat port is published to 127.0.0.1:3000, not 0.0.0.0 — the app itself has no TLS, so only the reverse proxy on the same box should reach it; binding it to every interface would put a plaintext login page straight on the public internet. MongoDB is not published to the host at all; it is reachable only over Compose's internal network under the name mongodb, which is exactly the hostname the MONGO_URL uses. MONGO_URL carries ?replicaSet=rs0 — leave that off and the driver treats the server as standalone even though it is a replica set, and change streams still fail. MONGO_OPLOG_URL points at the local database where the oplog lives; modern Rocket.Chat prefers change streams, but setting it is harmless and keeps older code paths happy. The depends_on uses condition: service_healthy, so Compose waits until MongoDB answers a ping before it even starts Rocket.Chat — that is what the healthcheck is for.
Pin real version tags on both images — mongo:8.0 and an explicit Rocket.Chat release such as 8.5.1 here — and never use :latest, which turns an unattended docker pull into an accidental, un-migratable upgrade. Check the current stable Rocket.Chat release and the MongoDB versions it supports before you pin. Rocket.Chat publishes a machine-readable info document per release: curl -s https://releases.rocket.chat/8.5.1/info | jq '{compatibleMongoVersions, lts}' returns compatibleMongoVersions: ["8.0"] for 8.5.1, so mongo:8.0 is the only supported engine, plus an lts flag telling you whether that release is a long-term-support build worth pinning for a server you would rather not babysit.
Initialise the replica set
Bring the stack up:
sudo docker compose up -d
Rocket.Chat will start crashing immediately and Docker will keep restarting it — that is expected, because the replica set does not exist yet. Create it once, by hand:
sudo docker compose exec mongodb mongosh --eval 'rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongodb:27017"}]})'
A correct result is { ok: 1 }. Within a few seconds the single node elects itself primary; confirm with:
sudo docker compose exec mongodb mongosh --quiet --eval 'rs.status().members[0].stateStr'
You want to see PRIMARY. The single most important detail on this whole page is the host: "mongodb:27017" argument. If you run a bare rs.initiate() with no members list, MongoDB advertises the replica set under the container's internal hostname — a random hash like a1b2c3d4e5f6. Rocket.Chat, connecting from its own container, cannot resolve that name, so the MongoDB driver fails DNS on it and loops forever logging MongoServerSelectionError: getaddrinfo ENOTFOUND a1b2c3d4e5f6. Always initiate with the explicit service name that matches your MONGO_URL.
First boot: watch it come up
Once the set is primary, Rocket.Chat's next restart connects cleanly and begins its first-run migrations. Follow the logs:
sudo docker compose logs -f rocketchat
The line you are waiting for is the startup banner:
+--------------------------------------------+
SERVER RUNNING
Rocket.Chat Version: 8.5.1
NodeJS Version: 22.22.3 - x64
+--------------------------------------------+
First boot is slow — the app runs database migrations and builds indexes, so give it a minute or two before you worry. If the log instead repeats MongoServerSelectionError: Server selection timed out after 30000 ms with a topology description of type ReplicaSetNoPrimary, the replica set is not initiated; if it repeats getaddrinfo ENOTFOUND on a random hash, it was initiated with the wrong host. Either way, go back one step. Once you see SERVER RUNNING, Rocket.Chat is listening on 127.0.0.1:3000 and it is time to put a real hostname and TLS in front of it.
Put it behind TLS
Never expose Rocket.Chat on plain HTTP. Log in over http:// once and you have handed your admin password to anyone on the path. Terminate TLS in a reverse proxy on the same box and forward to 127.0.0.1:3000. Two things matter: the proxy must forward the WebSocket upgrade headers, because Rocket.Chat is real-time and breaks without them, and the container's ROOT_URL must exactly match the public HTTPS address users type.
Start with a plain HTTP nginx server block that proxies to the app and forwards the upgrade headers. Save it as /etc/nginx/sites-available/rocketchat, symlink it into sites-enabled, and reload:
server {
listen 80;
server_name chat.example.com;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Leave it on port 80 for now — a block with listen 443 ssl; and no certificate will not even pass sudo nginx -t. Reload nginx (sudo nginx -t && sudo systemctl reload nginx), then issue the certificate. The cleanest path on Ubuntu is Let's Encrypt TLS certificates with Certbot and nginx: certbot --nginx rewrites the block above in place, adding listen 443 ssl;, the ssl_certificate lines, and an automatic 80-to-443 redirect, and it schedules renewal for you. If you already run several containers behind one proxy, Traefik with automatic TLS for many Docker apps is the tidier option — add router and service labels to the rocketchat service and Traefik requests and renews the certificate for you, with no nginx block at all. Either way, set ROOT_URL to https://chat.example.com in compose.yml and re-run sudo docker compose up -d so the container picks up the change. If you want the server reachable only from inside your own network rather than the public internet, front it with a self-hosted WireGuard VPN on the VPS and bind the proxy to the tunnel address.
The first-run setup wizard
Browse to https://chat.example.com and Rocket.Chat walks you through a short wizard. First, the admin account — a real name, username, email, and a strong password; this is the only account that exists, so do not lose it. Next, organisation and server info — name, industry, size, the site name and default language; cosmetic, fill it in and move on. Then the choice that actually matters: register this workspace with Rocket.Chat Cloud, or keep it standalone.
Registering enables mobile push notifications through Rocket.Chat's gateway and the add-on marketplace, at the cost of a control-plane relationship with Rocket.Chat's cloud. Standalone keeps the server fully private and dependency-free, but iOS and Android push notifications stop working, because Apple and Google will not let a self-built app hold the push certificates — the official apps route through the cloud gateway. Pick standalone if privacy is the whole point and your users live in the web app; pick registration if mobile push is non-negotiable. You can change your mind later under Admin.
Lock it down before you invite anyone
Rocket.Chat ships with open registration on — by default the Registration Form is set to Public, so anyone who finds the URL can create an account. On a public hostname that is an open door. Go to Admin → Settings → Accounts → Registration and set Registration Form to Disabled, so you create accounts by hand or by invite link, or to Secret URL. While you are there, turn off Allow Anonymous Read and Allow Anonymous Write unless you specifically want a public read-only channel.
Decide where uploads go, too. The default File Upload storage is GridFS, which stores every image and attachment inside MongoDB itself. That is simple, but it means your database — and every mongodump you take — grows without bound as people paste screenshots. Under Admin → Settings → File Upload you can switch the storage to the local filesystem or an S3-compatible bucket, and set a sane maximum file size. For a small team GridFS is fine; just know your backups get heavier over time.
Backups with mongodump
All of your data lives in the mongodb_data volume. Do not just copy the volume out from under a running database — take a consistent dump with mongodump, streamed to a file on the host:
sudo docker compose exec -T mongodb mongodump --db rocketchat --archive --gzip > rocketchat-$(date +%F).archive.gz
That single gzipped archive is your whole workspace: users, channels, messages, settings, and — if you left uploads on GridFS — the files too. If you moved uploads to the filesystem or S3, back that store up separately. Restore onto a fresh stack by initialising the replica set first, then:
sudo docker compose exec -T mongodb mongorestore --archive --gzip --drop < rocketchat-2026-07-15.archive.gz
Copy the archive off the box — object storage, another server, anywhere the VPS dying does not take the backup with it — and run the dump from cron nightly. A backup you have never restored is a hope, not a backup; practise the restore once on a throwaway VPS so you know it works before you need it.
Upgrades: pin tags, read the notes, respect the Mongo matrix
Two rules keep upgrades boring. First, upgrade Rocket.Chat one major version at a time. It runs schema migrations on boot and deliberately refuses to jump majors; try to go from 6.x straight to 8.x and it stops with a migration error rather than corrupting your data. Bump the image tag to the next major's latest release, read that release's notes for breaking changes, run docker compose up -d, and watch the logs finish migrating before moving on. Second, respect the MongoDB support matrix. Each Rocket.Chat release supports a specific set of MongoDB versions, and curl -s https://releases.rocket.chat/<version>/info | jq .compatibleMongoVersions tells you which. When you do move MongoDB — say 7.0 to 8.0 — step one major at a time and set the feature-compatibility version after each hop. On MongoDB 8.0 that command requires an explicit confirm: true, or it refuses with a message telling you to re-run it with the confirmation flag:
sudo docker compose exec mongodb mongosh --eval 'db.adminCommand({setFeatureCompatibilityVersion: "8.0", confirm: true})'
Take a mongodump before every upgrade of either component. That is the whole insurance policy.
Failure modes, with the exact strings
Rocket.Chat restart-loops right after docker compose up, and docker compose logs rocketchat fills with a MongoServerSelectionError. MongoDB is running but the driver cannot select a primary, and the exact string tells you which mistake you made. Server selection timed out after 30000 ms with a topology type of ReplicaSetNoPrimary means you never ran rs.initiate() — the set has no config yet. getaddrinfo ENOTFOUND followed by a random hash means you initiated without the explicit host: "mongodb:27017", so MongoDB advertised an unresolvable container hostname. Diagnose with sudo docker compose exec mongodb mongosh --eval 'rs.status()': if it errors MongoServerError: no replset config has been received, initiate the set; if it shows a member whose name is a random hash, re-initiate with the service name.
The web UI loads but login spins forever and never completes. Open the browser console and you will see WebSocket connection to 'wss://chat.example.com/websocket' failed. This is almost always a ROOT_URL mismatch or a proxy that is not forwarding the upgrade headers. Confirm ROOT_URL equals the exact public address including https://, and that your nginx location block sets Upgrade and Connection "upgrade" with proxy_http_version 1.1. Change either and re-run docker compose up -d.
A container keeps dying and docker compose ps shows it Restarting. docker compose logs cuts off mid-line and sudo dmesg | tail shows Out of memory: Killed process 12345 (mongod) from the oom-killer; the exit code is 137. The box is out of RAM. The real fix is a bigger VPS — 4 GB minimum. As a stopgap add swap and cap MongoDB's cache with --wiredTigerCacheSizeGB 1 in its command, but swap only delays the next OOM under real load:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
docker compose up fails with Error response from daemon: driver failed programming external connectivity ... bind: address already in use. Something already holds port 3000 — often a previous Rocket.Chat container that did not stop cleanly, or another app. Find it with sudo ss -ltnp | grep :3000, stop that process or container, or change the host side of the mapping to 127.0.0.1:3001:3000 and update your proxy's proxy_pass to match.
FAQ
Does Rocket.Chat really need a MongoDB replica set?
Yes, even for a single server with one database node. Rocket.Chat delivers messages in real time using MongoDB change streams, and change streams are a replica-set-only feature — a standalone mongod cannot open one. You do not need multiple machines; you run one MongoDB container started with --replSet rs0 and initialise a one-member set with rs.initiate(). Skip that step and the driver never finds a primary, so Rocket.Chat restart-loops with MongoServerSelectionError: Server selection timed out and never finishes booting.
How much RAM does self-hosted Rocket.Chat need?
Plan for 4 GB as the practical minimum and 8 GB for a busy team. Rocket.Chat's Node process uses about 1 to 1.5 GB and MongoDB claims roughly half the remaining RAM for its WiredTiger cache, so on a 2 GB box the two collide and the out-of-memory killer terminates mongod under any real load, showing Killed in the logs and exit code 137. Two GB is only enough to evaluate the software with a couple of test users.
How do I put Rocket.Chat behind HTTPS?
Run a reverse proxy on the same VPS that terminates TLS and forwards to 127.0.0.1:3000, and set the container's ROOT_URL to your public https:// address. The proxy must forward the WebSocket upgrade headers or login will hang. Certbot with nginx is the simplest single-app setup; Traefik is cleaner if you run several containers behind one proxy and want automatic certificate management.
How do I back up a self-hosted Rocket.Chat?
Take a consistent database dump with mongodump rather than copying the volume: docker compose exec -T mongodb mongodump --db rocketchat --archive --gzip > backup.archive.gz. That archive contains users, channels, messages, and settings, plus uploaded files if you left storage on GridFS. Copy it off the server, automate it nightly with cron, and rehearse a mongorestore on a throwaway box so you know the restore actually works.
How do I upgrade Rocket.Chat without breaking MongoDB?
Upgrade Rocket.Chat one major version at a time — it runs migrations on boot and refuses to skip majors — and read each release's notes before bumping the pinned image tag. Check which MongoDB versions your target release supports with curl -s https://releases.rocket.chat/<version>/info | jq .compatibleMongoVersions, and when you move MongoDB, step one major at a time and set setFeatureCompatibilityVersion with confirm: true after each hop. Always take a mongodump first.