
Mattermost with Docker Compose: Your Own Slack Alternative
Your team already lives in a chat app. Decisions, passwords that should not be there, customer screenshots, the “quick question” that turns into a design review: it all ends up in chat. The real question is whose server that chat lives on, who can read it, and what happens to your history when the free plan changes its rules again. With Mattermost and a small VPS you can answer all three yourself: your server, your database, your rules, and an interface your colleagues will recognize from the first minute.
In this tutorial we install Mattermost with Docker Compose, backed by PostgreSQL, behind automatic HTTPS. Then we set up email notifications and connect the desktop and mobile apps, so at the end you have a private team chat that works everywhere your team does. The project lives at mattermost.com and its source code at github.com/mattermost/mattermost .
What Mattermost is and why self-host it
Mattermost is an open source team messaging platform: channels, private groups, direct messages, threads, file sharing, emoji reactions, search, integrations and webhooks. If you have used Slack, you already know how to use it. The difference is that you run the server, so messages and files sit on a disk you control, in a data center you chose, under the jurisdiction you chose.
For a small business or a student project this matters for very practical reasons: no per-user monthly fee, no message history that disappears after 90 days, no surprise when a vendor decides your workspace is now a “legacy plan”. You also get webhooks and bots that can post deploy notifications, monitoring alerts or form submissions straight into a channel.
Team Edition or the Enterprise image?
Mattermost publishes two server images. Team Edition (mattermost/mattermost-team-edition) is the free, fully open source build, and it is what we use here. The Enterprise image runs without a license too, but it is designed around paid features and its free mode has had limits that change between versions. For a private chat of a few dozen people, Team Edition is the straightforward choice. You can switch images later if you ever buy a license, because both use the same database.
How the pieces fit together
Before we type any command, let us define the building blocks, because they appear in every step.
- A container is an application packaged with everything it needs to run (libraries, runtime, default config), isolated from the rest of the system. Docker runs containers, and Docker Compose lets us describe several containers that work together in one YAML file.
- A port is a numbered “door” on a server. Web traffic uses 80 (HTTP) and 443 (HTTPS), SSH uses 22. Mattermost itself listens on 8065 and PostgreSQL on 5432, but we will never open those two to the internet.
- A reverse proxy is the front desk: it receives HTTPS requests from the world, handles the TLS certificate, and forwards the traffic to the right container. We use Caddy, because it obtains and renews Let’s Encrypt certificates automatically and handles WebSockets (which Mattermost uses for live messages) without extra configuration.
- A volume is storage that survives when a container is recreated. Messages live in PostgreSQL’s volume, uploaded files and configuration in Mattermost’s folders.
One important detail in that picture: Docker writes its own firewall rules for published ports, and they take effect before ufw’s rules. That means ufw cannot block a port that Docker publishes. This is why our design simply never publishes 8065 or 5432: the only published ports are Caddy’s 80 and 443, which we want open anyway.
What you need
- A VPS with 2 vCPU and 2 GB of RAM (4 GB if you expect more than about 50 active users or heavy file sharing) and at least 20 GB of disk. A KVM VPS is the easiest option. On an unprivileged LXC container, Docker needs nesting (and usually keyctl) enabled; if
docker run hello-worldfails there, ask support to enable it. - Debian 13 or Ubuntu 24.04, freshly installed, and a user with
sudorights. - A domain or subdomain you can edit DNS for, for example
chat.example.com. - SMTP credentials for email notifications: a mailbox on your mail server or a transactional email provider. You can add this later, but invites and password resets need it.
- About 45 minutes.
The plan at a glance
Step 1: Point a domain at your VPS
Mobile apps refuse servers without a valid certificate, and Let’s Encrypt only issues certificates for domain names, not bare IP addresses. So the very first thing is DNS.
In your DNS provider’s panel, create an A record for chat.example.com pointing to your VPS’s IPv4 address. If your VPS also has IPv6, add an AAAA record with the IPv6 address. Then check from the VPS that the name resolves:
getent hosts chat.example.com
You should see your VPS’s IP next to the name. If you see nothing or an old address, wait a few minutes (DNS changes propagate according to the record’s TTL) and try again. Do not continue to Step 6 until this is correct, or the certificate request will fail.
Step 2: Open the firewall for SSH and web
A firewall decides which ports accept connections. We use ufw (“uncomplicated firewall”). Ubuntu 24.04 ships with it; on Debian 13 you have to install it first:
# Debian only (Ubuntu already has ufw)
sudo apt update && sudo apt install -y ufw
Now allow SSH before enabling the firewall, otherwise you lock yourself out. If you moved SSH to another port, use that number instead of 22.
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw status verbose
The output of the last command should list 22/tcp, 80/tcp, 443/tcp and 443/udp as ALLOW IN, with the default incoming policy set to deny. As explained above, Docker will open 80 and 443 for Caddy regardless of ufw; the rules are still useful as documentation of intent and keep working if you ever move the proxy out of Docker. UDP 443 is for HTTP/3, which Caddy supports out of the box.
In an unprivileged LXC container ufw may fail to load its rules. If it does, keep the container’s other ports closed by not running anything else on them, and ask us to help with filtering at the network level.
Step 3: Install Docker and the Compose plugin
First bring the system up to date:
sudo apt update && sudo apt full-upgrade -y
On Ubuntu, if the file /var/run/reboot-required exists afterwards, reboot with sudo reboot. On Debian that file is not always created, so reboot if the upgrade installed a new linux-image package.
Next, add Docker’s official repository. The block below works on both distributions: it reads /etc/os-release to find out whether you run Debian or Ubuntu and which release, then writes Docker’s current deb822 source file /etc/apt/sources.list.d/docker.sources.
sudo apt install -y ca-certificates curl openssl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/${ID}
Suites: ${UBUNTU_CODENAME:-$VERSION_CODENAME}
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Check that everything works:
sudo docker run --rm hello-world
sudo docker compose version
The first command prints “Hello from Docker!”, the second something like Docker Compose version v2.x.x. Note that it is docker compose with a space: Compose v2 is a Docker plugin, and the old docker-compose command is not needed. Throughout this guide we prefix Docker commands with sudo; adding your user to the docker group would avoid that, but membership in that group is equivalent to root access, so we prefer to keep it explicit.
Step 4: Create the project folder and secrets
Everything for this install lives in one folder, ~/mattermost. Mattermost’s container runs as user and group 2000, not root, so the folders it writes to must belong to that ID:
mkdir -p ~/mattermost && cd ~/mattermost
mkdir -p volumes/mattermost/{config,data,logs,plugins,client-plugins,bleve-indexes}
sudo chown -R 2000:2000 volumes/mattermost
These are bind mounts: regular folders on the host that appear inside the container. They make backups easy, because you can see and archive the files directly.
Next, choose the Mattermost version. Mattermost releases often, so check the current one on the releases page or ask Docker Hub for the newest tags:
curl -s "https://hub.docker.com/v2/repositories/mattermost/mattermost-team-edition/tags?page_size=25&ordering=last_updated" | grep -o '"name":"[^"]*"'
Pick the highest plain version number in the form X.Y.Z (ignore names like latest). We pin an exact version instead of latest on purpose: an update to Mattermost runs database migrations, and you want to decide when that happens, after a backup, not whenever the container happens to be recreated.
Now create the .env file. Compose reads it automatically and substitutes the values into the compose file. The database password is generated randomly; we use hex characters because the password ends up inside a connection URL, where symbols like @ or / would break it.
cat > .env <<EOF
DOMAIN=chat.example.com
TZ=Europe/Bucharest
# Example only: replace with the newest X.Y.Z tag you found above
MM_TAG=11.1.0
POSTGRES_USER=mmuser
POSTGRES_DB=mattermost
POSTGRES_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env
nano .env
In the editor, set DOMAIN to your real subdomain and MM_TAG to the version you chose, then save with Ctrl+O and exit with Ctrl+X. chmod 600 makes the file readable only by you, since it contains the database password.
Step 5: Write docker-compose.yml and the Caddyfile
Create docker-compose.yml in the same folder (nano docker-compose.yml) and paste:
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
TZ: ${TZ}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
security_opt:
- no-new-privileges:true
mattermost:
image: mattermost/mattermost-team-edition:${MM_TAG}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
TZ: ${TZ}
MM_SQLSETTINGS_DRIVERNAME: postgres
MM_SQLSETTINGS_DATASOURCE: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable&connect_timeout=10"
MM_BLEVESETTINGS_INDEXDIR: /mattermost/bleve-indexes
MM_SERVICESETTINGS_SITEURL: "https://${DOMAIN}"
volumes:
- ./volumes/mattermost/config:/mattermost/config
- ./volumes/mattermost/data:/mattermost/data
- ./volumes/mattermost/logs:/mattermost/logs
- ./volumes/mattermost/plugins:/mattermost/plugins
- ./volumes/mattermost/client-plugins:/mattermost/client/plugins
- ./volumes/mattermost/bleve-indexes:/mattermost/bleve-indexes
networks:
- backend
- frontend
security_opt:
- no-new-privileges:true
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- mattermost
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN: ${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
networks:
frontend:
backend:
internal: true
volumes:
pgdata:
caddy_data:
caddy_config:
What this does, service by service:
- postgres uses the official
postgres:17image, stores its files in the named volumepgdata, and has a healthcheck so Compose knows when the database is really ready to accept connections. It sits only on thebackendnetwork, which is markedinternal: true: no ports, no internet access, reachable only by Mattermost. (If you choose PostgreSQL 18 later, note that its image expects the volume at/var/lib/postgresqlinstead, and a major version change needs a dump and restore, not just a new tag.) - mattermost waits for a healthy database, connects to it through
MM_SQLSETTINGS_DATASOURCE(Mattermost reads anyMM_...variable as a config override), and knows its public address throughMM_SERVICESETTINGS_SITEURL. Settings defined this way appear greyed out in the System Console, which is a feature: nobody can accidentally break the database connection from the web UI. It joinsfrontendtoo, so it can reach Caddy and the internet (for email and push notifications). Notice there is noports:section: 8065 is never exposed. - caddy is the only service with published ports. Its certificates live in
caddy_data, so they survive restarts and you do not hit Let’s Encrypt rate limits.
no-new-privileges stops processes inside the container from gaining extra privileges through setuid binaries, a cheap hardening step.
Now the Caddyfile (nano Caddyfile):
{$DOMAIN} {
encode zstd gzip
reverse_proxy mattermost:8065
}
That is the entire proxy configuration. {$DOMAIN} is read from the environment we passed in, Caddy requests a certificate for that name, redirects HTTP to HTTPS, and forwards everything, WebSocket connections included, to the Mattermost container by its service name. Validate the compose file before starting:
sudo docker compose config --quiet && echo "compose file OK"
If you see compose file OK, the YAML and variables are fine. Otherwise the error message tells you the line to fix (usually indentation).
Step 6: Start the stack and get HTTPS
sudo docker compose up -d
sudo docker compose ps
The first run downloads the images, which takes a minute or two. docker compose ps should show three containers, with postgres marked healthy. Now watch Caddy obtain the certificate:
sudo docker compose logs -f caddy
Look for a line containing certificate obtained successfully for your domain, then press Ctrl+C. Do the same for Mattermost; its first start runs database migrations and may take a minute:
sudo docker compose logs -f mattermost
When you see a message containing Server is listening on, Mattermost is up. A quick check from the VPS itself:
curl -sI "https://chat.example.com" | head -n 1
Expected output: HTTP/2 200. Your Mattermost Docker Compose stack is running.
Step 7: Create the admin account and your first team
Open https://chat.example.com in your browser. Mattermost asks you to create an account: email, username and a strong password. The first account created becomes the System Admin, so do this right away, before anyone else finds the page.
Next, the setup wizard asks for an organization name and creates your first team (a workspace with its own channels; one installation can host several teams). You land in the “Town Square” and “Off-Topic” channels that every team starts with.
Now lock down signups. Open the product menu (top left), go to System Console > Authentication > Signup and check that Enable Open Server is set to false. With that, people can only join through an invite link or an email invite. To invite colleagues, use Invite People from the team menu: you can copy an invite link or send invitations by email (that needs Step 8). If an invite link leaks, you can regenerate it from the team settings.
Step 8: Set up email notifications (SMTP)
Email is how Mattermost sends invites, password resets and notifications for mentions and direct messages you missed while offline. Mattermost does not deliver mail itself; it hands messages to an SMTP server (the server that relays email), such as your company mailbox or a transactional email service.
Use port 587 with STARTTLS. Many providers block outgoing port 25 on virtual servers to fight spam, and 587 is the standard port for authenticated submission anyway.
In the web interface, go to System Console > Environment > SMTP and fill in:
- SMTP Server: your provider’s host, e.g.
smtp.example.net - SMTP Server Port:
587 - Enable SMTP Authentication:
true - SMTP Server Username and SMTP Server Password: your mailbox credentials
- Connection Security:
STARTTLS(useTLSif your provider requires port 465)
Click Test Connection. A green confirmation means Mattermost could log in. Save, then go to System Console > Site Configuration > Notifications and set Enable Email Notifications to true, a Notification Display Name (e.g. “Acme Chat”), and a Notification From Address on your own domain, such as chat@example.com. Save again.
For mail to land in the inbox and not in spam, the sender domain needs correct SPF and DKIM records that authorize your SMTP provider; your provider’s documentation lists the exact values. To test end to end, log out and use “Forgot your password?” with your admin email.
If you prefer configuration as code, you can set the same values as environment variables in the mattermost service and keep the password in .env (add SMTP_PASSWORD=... there):
MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "true"
MM_EMAILSETTINGS_SMTPSERVER: smtp.example.net
MM_EMAILSETTINGS_SMTPPORT: "587"
MM_EMAILSETTINGS_CONNECTIONSECURITY: STARTTLS
MM_EMAILSETTINGS_ENABLESMTPAUTH: "true"
MM_EMAILSETTINGS_SMTPUSERNAME: chat@example.com
MM_EMAILSETTINGS_SMTPPASSWORD: ${SMTP_PASSWORD}
MM_EMAILSETTINGS_FEEDBACKEMAIL: chat@example.com
MM_EMAILSETTINGS_FEEDBACKNAME: Acme Chat
After editing, run sudo docker compose up -d to recreate the container. Those fields then appear read-only in the System Console.
While you are in the console, open System Console > Environment > Push Notification Server. Phone notifications cannot go straight from your server to iPhones and Android phones: Apple and Google only accept them from the app publisher, so Mattermost routes them through its own push proxy. Make sure push notifications are enabled with the Mattermost-hosted option available for your edition. Then, under Site Configuration > Notifications > Push Notification Contents, choose a generic description if you do not want message text passing through that proxy.
Step 9: Connect the desktop and mobile apps
Desktop: download the Mattermost desktop app for Windows, macOS or Linux from mattermost.com/apps
. On first launch it asks for a server: give it a display name and the URL https://chat.example.com, then log in. The app gives you native notifications, a tray icon with unread counts, and lets you add several servers side by side.
Mobile: install “Mattermost” from the App Store or Google Play. Enter the same URL, optionally a display name, and log in. Send yourself a direct message from another account while the phone is locked to confirm that push notifications arrive.
The apps check the certificate strictly, which is exactly why we set up a real Let’s Encrypt certificate in Step 6. If the app says it cannot connect, open the URL in the phone’s browser first: if the browser shows a warning, fix the certificate or DNS before blaming the app.
How much RAM does Mattermost need?
You can see the live numbers on your own server with sudo docker stats --no-stream. Mattermost is a Go application and is fairly frugal, but it grows with active connections, enabled plugins (Calls, Playbooks, Boards) and image previews. The operating system, page cache and PostgreSQL’s working memory also need room, which is why we recommend 2 GB as a comfortable minimum rather than squeezing the stack into 1 GB.
Backups and restores
A chat server holds your team’s memory, so back it up from day one. Two things matter: the database (all messages, users, channels) and the files folder (attachments, plus config.json, which contains your SMTP password, so treat backups as sensitive).
cd ~/mattermost
mkdir -p backup
sudo docker compose exec -T postgres pg_dump -U mmuser -d mattermost -Fc > "backup/db-$(date +%F).dump"
sudo tar czf "backup/files-$(date +%F).tar.gz" volumes/mattermost/config volumes/mattermost/data volumes/mattermost/plugins volumes/mattermost/client-plugins
sudo chown "$USER": backup/*
ls -lh backup/
pg_dump runs inside the database container and connects through PostgreSQL’s local socket, so no password appears on the command line. -Fc produces a compressed custom-format dump. The tar archive is created by root, so the chown line hands it back to your user, otherwise copying it off the server with scp or rsync as your normal user fails with “permission denied”. Copy both files to another machine or storage; a backup that lives only on the same VPS does not survive the loss of that VPS. To automate, put these lines in a script and run it from cron every night.
Restoring always goes into a fresh, empty database, never on top of the live one that Mattermost has already migrated. Stop the app, recreate the database, restore, then start again:
cd ~/mattermost
sudo docker compose stop mattermost
sudo docker compose exec -T postgres dropdb -U mmuser mattermost
sudo docker compose exec -T postgres createdb -U mmuser -O mmuser mattermost
sudo docker compose exec -T postgres pg_restore -U mmuser -d mattermost --no-owner < backup/db-2026-11-10.dump
sudo tar xzf backup/files-2026-11-10.tar.gz -C ~/mattermost
sudo docker compose start mattermost
Replace the dates with your backup’s file names. When restoring onto a brand new server, follow Steps 1 to 5, extract the files archive, start only PostgreSQL with sudo docker compose up -d postgres, restore the dump, and only then start the rest.
Updating Mattermost
Read the release notes for the version you are moving to, especially the “Important Upgrade Notes”, then:
- Take a backup as above.
- Edit
.envand changeMM_TAGto the new version. - Pull and recreate:
cd ~/mattermost
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs -f mattermost
Mattermost migrates the database on startup; wait for Server is listening on again. pull also refreshes caddy:2 and postgres:17 within their major versions, which brings security fixes. If you prefer fewer upgrades, Mattermost publishes Extended Support Releases (ESR) that receive fixes for longer. Do not jump PostgreSQL to a new major version by changing the tag: that requires a dump and restore into a new volume.
Troubleshooting and common mistakes
- Caddy cannot get a certificate. Run
sudo docker compose logs caddy. Almost always the DNS record does not point to this VPS yet, or port 80 is blocked somewhere upstream. Fix DNS, thensudo docker compose restart caddy. - Mattermost restarts in a loop with “permission denied”. The volume folders are not owned by UID 2000. Run
sudo chown -R 2000:2000 ~/mattermost/volumes/mattermostandsudo docker compose up -d. - “password authentication failed for user mmuser”. PostgreSQL reads
POSTGRES_PASSWORDonly when the volume is created for the first time. If you changed the password in.envafterwards, either change it back, or set the new one inside the database interactively:sudo docker compose exec postgres psql -U mmuser -d mattermost, then type\password mmuserand enter it twice. - Messages only appear after a page refresh. The WebSocket connection is failing. Check that the Site URL matches exactly the address you use (https, no trailing path), and that no second proxy or CDN in front of Caddy strips WebSocket upgrades.
- Emails do not arrive. Use Test Connection in the SMTP page first. If it times out, the port is blocked or wrong; if it authenticates but mail goes to spam, fix SPF and DKIM for the sender domain.
- Mobile app says it cannot connect. Open the URL in the phone’s browser. Certificate warnings, a wrong domain or an
http://Site URL are the usual causes. - Containers will not start at all on an LXC VPS. Docker needs nesting enabled in the container; contact support.
Security notes
- Only Caddy is published. Never add
ports:to themattermostorpostgresservices “just to test”. Remember that Docker-published ports bypass ufw, so a published 5432 would be open to the whole internet. - Keep
.envprivate (chmod 600) and out of any Git repository. The same goes for backups, which containconfig.jsonwith the SMTP password. - Signups closed, invites only. Keep Enable Open Server off and regenerate the invite link if it leaks.
- Turn on multi-factor authentication in System Console > Authentication > MFA so users, starting with every admin, can protect their accounts with an authenticator app.
- Patch both layers. Update the host regularly (
sudo apt update && sudo apt full-upgrade, or enableunattended-upgrades) and the containers as described above. Mattermost publishes security fixes as patch releases, so subscribe to its release announcements. - SSH hygiene. Use key-based login and disable password authentication for SSH; your chat server is only as safe as the account that administers it.
So, is self-hosted Mattermost worth it?
If your team wants a familiar chat experience without per-seat fees, with history that stays put and data that lives on a server you control, yes. The Mattermost Docker Compose setup above is three containers, one small config file for the proxy, and a backup routine you can automate in five minutes. The ongoing work is mostly reading release notes and running two commands every few weeks.
You can start this stack on a VPS in our Romanian data center in minutes, with plenty of headroom for a growing team. And if you would rather have someone else handle installation, email deliverability, backups and updates, our server administration team can set it up and look after it for you.
FAQ
How many users can a small VPS handle?
A 2 vCPU / 2 GB VPS comfortably serves a few dozen active users with normal chat and file sharing. Beyond roughly 50 to 100 active users, or with voice calls and many plugins, move to 4 GB of RAM and watch docker stats and disk usage.
Can I import my Slack history?
Yes. Export your workspace from Slack, convert the export with Mattermost’s official mmetl tool, and import it with mmctl. Mattermost’s documentation describes the process; test it on a copy first, because large exports with many files take a while.
Is Mattermost Team Edition really free?
Yes, Team Edition is open source and has no user limit. Paid editions add features such as SAML single sign-on, advanced compliance exports and high availability clustering, which most small teams do not need.
Do push notifications leave my server?
Yes, mobile push notifications pass through Mattermost’s push proxy, because Apple and Google only accept notifications from the app’s publisher. Set Push Notification Contents to a generic description so the message text itself is not included.
Can I use nginx instead of Caddy?
Of course. Any reverse proxy works if it terminates TLS, forwards to mattermost:8065 and passes WebSocket upgrade headers. We chose Caddy because certificates and WebSockets work with a three-line config.